From ca62cf421fc6ecbec3fa7d633a7f8bc9c78e9aff Mon Sep 17 00:00:00 2001
From: bjoern-m <56024829+bjoern-m@users.noreply.github.com>
Date: Wed, 25 Jan 2023 10:55:23 +0100
Subject: [PATCH] feat: introduce hanko profile element and related api changes
(#495)
* feat: introduce hanko profile element and related api changes
---
backend/audit_log/logger.go | 10 +-
backend/config/config.go | 10 +
backend/dto/config.go | 6 +-
backend/dto/email.go | 31 +
backend/dto/intern/WebauthnCredential.go | 2 +-
backend/dto/intern/WebauthnUser.go | 12 +-
backend/dto/passcode.go | 3 +-
backend/dto/user.go | 28 +
backend/dto/webauthn.go | 34 +
backend/handler/email.go | 244 ++++
backend/handler/email_test.go | 182 +++
backend/handler/passcode.go | 86 +-
backend/handler/passcode_test.go | 14 +-
backend/handler/password_test.go | 27 +-
backend/handler/user.go | 139 +-
backend/handler/user_admin.go | 47 -
backend/handler/user_admin_test.go | 181 +--
backend/handler/user_test.go | 101 +-
backend/handler/webauthn.go | 124 +-
backend/handler/webauthn_test.go | 23 +-
backend/persistence/email_persister.go | 126 ++
.../20221027104800_create_emails.down.fizz | 2 +
.../20221027104800_create_emails.up.fizz | 27 +
.../20221027104900_change_users.down.fizz | 22 +
.../20221027104900_change_users.up.fizz | 3 +
.../20221027123530_change_passcodes.down.fizz | 1 +
.../20221027123530_change_passcodes.up.fizz | 5 +
...4900_change_webauthn_credentials.down.fizz | 1 +
...134900_change_webauthn_credentials.up.fizz | 1 +
backend/persistence/models/audit_log.go | 7 +
backend/persistence/models/email.go | 83 ++
backend/persistence/models/passcode.go | 2 +
backend/persistence/models/primary_email.go | 42 +
backend/persistence/models/user.go | 17 +-
.../persistence/models/webauthn_credential.go | 19 +-
.../models/webauthn_credential_transport.go | 10 +
backend/persistence/passcode_persister.go | 2 +-
backend/persistence/persister.go | 20 +
.../persistence/primary_email_persister.go | 46 +
backend/persistence/user_persister.go | 6 +-
.../webauthn_credential_persister.go | 4 +-
backend/server/admin_router.go | 1 -
backend/server/middleware/session.go | 5 +
backend/server/public_router.go | 18 +-
backend/test/email_persister.go | 81 ++
backend/test/persister.go | 22 +-
backend/test/primary_email_persister.go | 28 +
backend/test/user_persister.go | 11 -
deploy/docker-compose/quickstart.debug.yaml | 2 +-
deploy/docker-compose/quickstart.yaml | 2 +-
.../jsdoc/hanko-frontend-sdk/Client.html | 2 +-
.../jsdoc/hanko-frontend-sdk/Config.html | 4 +-
.../hanko-frontend-sdk/ConfigClient.html | 2 +-
.../hanko-frontend-sdk/ConflictError.html | 2 +-
.../jsdoc/hanko-frontend-sdk/Credential.html | 4 +-
.../jsdoc/hanko-frontend-sdk/Email.html | 316 ++++
.../EmailAddressAlreadyExistsError.html | 428 ++++++
.../jsdoc/hanko-frontend-sdk/EmailClient.html | 1270 +++++++++++++++++
.../jsdoc/hanko-frontend-sdk/EmailConfig.html | 270 ++++
.../jsdoc/hanko-frontend-sdk/Emails.html | 243 ++++
.../jsdoc/hanko-frontend-sdk/Hanko.html | 87 +-
.../jsdoc/hanko-frontend-sdk/Hanko.ts.html | 9 +-
.../jsdoc/hanko-frontend-sdk/HankoError.html | 2 +-
.../jsdoc/hanko-frontend-sdk/Headers.html | 4 +-
.../jsdoc/hanko-frontend-sdk/HttpClient.html | 516 ++++++-
.../InvalidPasscodeError.html | 2 +-
.../InvalidPasswordError.html | 2 +-
.../InvalidWebauthnCredentialError.html | 2 +-
.../hanko-frontend-sdk/LocalStorage.html | 2 +-
.../LocalStoragePasscode.html | 35 +-
.../LocalStoragePassword.html | 2 +-
.../hanko-frontend-sdk/LocalStorageUser.html | 2 +-
.../hanko-frontend-sdk/LocalStorageUsers.html | 2 +-
.../LocalStorageWebauthn.html | 2 +-
.../MaxNumOfEmailAddressesReachedError.html | 429 ++++++
.../MaxNumOfPasscodeAttemptsReachedError.html | 2 +-
.../hanko-frontend-sdk/NotFoundError.html | 2 +-
.../jsdoc/hanko-frontend-sdk/Passcode.html | 4 +-
.../hanko-frontend-sdk/PasscodeClient.html | 107 +-
.../PasscodeExpiredError.html | 2 +-
.../hanko-frontend-sdk/PasscodeState.html | 385 ++++-
.../hanko-frontend-sdk/PasswordClient.html | 91 +-
.../hanko-frontend-sdk/PasswordConfig.html | 25 +-
.../hanko-frontend-sdk/PasswordState.html | 2 +-
.../RequestTimeoutError.html | 2 +-
.../jsdoc/hanko-frontend-sdk/Response.html | 134 +-
.../jsdoc/hanko-frontend-sdk/State.html | 2 +-
.../hanko-frontend-sdk/TechnicalError.html | 2 +-
.../TooManyRequestsError.html | 2 +-
.../hanko-frontend-sdk/UnauthorizedError.html | 2 +-
.../static/jsdoc/hanko-frontend-sdk/User.html | 4 +-
.../jsdoc/hanko-frontend-sdk/UserClient.html | 2 +-
.../jsdoc/hanko-frontend-sdk/UserInfo.html | 27 +-
.../jsdoc/hanko-frontend-sdk/UserState.html | 2 +-
.../UserVerificationError.html | 2 +-
.../hanko-frontend-sdk/WebauthnClient.html | 1039 ++++++++++++--
.../WebauthnCredential.html | 431 ++++++
.../WebauthnCredentials.html | 243 ++++
.../hanko-frontend-sdk/WebauthnFinalized.html | 4 +-
.../WebauthnRequestCancelledError.html | 2 +-
.../hanko-frontend-sdk/WebauthnState.html | 2 +-
.../hanko-frontend-sdk/WebauthnSupport.html | 2 +-
.../WebauthnTransports.html | 243 ++++
.../jsdoc/hanko-frontend-sdk/index.html | 4 +-
.../jsdoc/hanko-frontend-sdk/lib_Dto.ts.html | 91 +-
.../hanko-frontend-sdk/lib_Errors.ts.html | 45 +-
.../lib_WebauthnSupport.ts.html | 2 +-
.../lib_client_Client.ts.html | 2 +-
.../lib_client_ConfigClient.ts.html | 2 +-
.../lib_client_EmailClient.ts.html | 232 +++
.../lib_client_HttpClient.ts.html | 52 +-
.../lib_client_PasscodeClient.ts.html | 71 +-
.../lib_client_PasswordClient.ts.html | 31 +-
.../lib_client_UserClient.ts.html | 2 +-
.../lib_client_WebauthnClient.ts.html | 119 +-
.../lib_state_PasscodeState.ts.html | 28 +-
.../lib_state_PasswordState.ts.html | 2 +-
.../lib_state_State.ts.html | 2 +-
.../lib_state_UserState.ts.html | 2 +-
.../lib_state_WebauthnState.ts.html | 2 +-
docs/static/spec/admin.yaml | 37 -
docs/static/spec/public.yaml | 320 ++++-
e2e/pages/LoginPasscode.ts | 2 +-
e2e/pages/LoginPassword.ts | 4 +-
e2e/pages/RegisterAuthenticator.ts | 2 +-
e2e/pages/RegisterConfirm.ts | 2 +-
frontend/Dockerfile | 2 +-
frontend/elements/README.md | 219 ++-
frontend/elements/demo-ui.png | Bin 51741 -> 0 bytes
frontend/elements/demo.gif | Bin 114824 -> 0 bytes
frontend/elements/example.css | 651 +++++----
frontend/elements/package-lock.json | 6 +-
frontend/elements/package.json | 14 +-
frontend/elements/src/Elements.tsx | 97 ++
frontend/elements/src/Translations.ts | 208 +++
frontend/elements/src/_mixins.sass | 12 +
frontend/elements/src/_preset.sass | 54 +
frontend/elements/src/_variables.sass | 56 +
.../src/components/accordion/Accordion.tsx | 72 +
.../components/accordion/AddEmailDropdown.tsx | 158 ++
.../accordion/AddPasskeyDropdown.tsx | 85 ++
.../accordion/ChangePasswordDropdown.tsx | 97 ++
.../src/components/accordion/Dropdown.tsx | 35 +
.../accordion/ListEmailsAccordion.tsx | 242 ++++
.../accordion/ListPasskeysAccordion.tsx | 130 ++
.../src/components/accordion/styles.sass | 104 ++
.../divider}/Divider.tsx | 3 +-
.../src/components/divider/styles.sass | 28 +
.../error}/ErrorMessage.tsx | 4 +-
.../elements/src/components/error/styles.sass | 20 +
.../components => components/form}/Button.tsx | 14 +-
.../form/CodeInput.tsx} | 64 +-
.../components => components/form}/Form.tsx | 4 +-
.../form/Input.tsx} | 16 +-
.../elements/src/components/form/styles.sass | 141 ++
.../src/components/headline/Headline1.tsx | 24 +
.../src/components/headline/Headline2.tsx | 24 +
.../src/components/headline/styles.sass | 22 +
.../src/components/icons/Checkmark.tsx | 22 +
.../icons}/ExclamationMark.tsx | 2 +-
.../icons/LoadingSpinner.tsx} | 15 +-
.../elements/src/components/icons/styles.sass | 121 ++
.../elements/src/components/link/Link.tsx | 65 +
.../elements/src/components/link/styles.sass | 34 +
.../paragraph}/Paragraph.tsx | 2 +-
.../src/components/paragraph/styles.sass | 11 +
.../src/components/wrapper/Container.tsx | 24 +
.../wrapper}/Content.tsx | 2 +-
.../wrapper}/Footer.tsx | 2 +-
.../src/components/wrapper/styles.sass | 37 +
.../elements/src/contexts/AppProvider.tsx | 149 ++
frontend/elements/src/index.ts | 4 +-
frontend/elements/src/pages/ErrorPage.tsx | 50 +
frontend/elements/src/pages/InitPage.tsx | 87 ++
.../elements/src/pages/LoginEmailPage.tsx | 399 ++++++
.../LoginFinishedPage.tsx} | 18 +-
.../elements/src/pages/LoginPasscodePage.tsx | 208 +++
.../elements/src/pages/LoginPasswordPage.tsx | 137 ++
frontend/elements/src/pages/ProfilePage.tsx | 158 ++
.../src/pages/RegisterConfirmPage.tsx | 89 ++
.../RegisterPasskeyPage.tsx} | 63 +-
.../src/pages/RegisterPasswordPage.tsx | 86 ++
.../elements/src/pages/RenamePasskeyPage.tsx | 94 ++
frontend/elements/src/test.html | 66 +-
frontend/elements/src/ui/HankoAuth.tsx | 102 --
frontend/elements/src/ui/Translations.ts | 109 --
.../elements/src/ui/components/Button.sass | 74 -
.../elements/src/ui/components/Checkmark.sass | 55 -
.../elements/src/ui/components/Checkmark.tsx | 21 -
.../elements/src/ui/components/Container.sass | 14 -
.../elements/src/ui/components/Container.tsx | 44 -
.../elements/src/ui/components/Content.sass | 5 -
.../elements/src/ui/components/Divider.sass | 24 -
.../src/ui/components/ErrorMessage.sass | 17 -
.../src/ui/components/ExclamationMark.sass | 34 -
.../elements/src/ui/components/Footer.sass | 12 -
frontend/elements/src/ui/components/Form.sass | 7 -
.../elements/src/ui/components/Headline.sass | 12 -
.../elements/src/ui/components/Headline.tsx | 22 -
.../elements/src/ui/components/Input.sass | 88 --
.../src/ui/components/InputPasscodeDigit.tsx | 57 -
frontend/elements/src/ui/components/Link.sass | 16 -
frontend/elements/src/ui/components/Link.tsx | 33 -
.../src/ui/components/LoadingIndicator.sass | 3 -
.../src/ui/components/LoadingWheel.sass | 20 -
.../src/ui/components/LoadingWheel.tsx | 9 -
.../elements/src/ui/components/Paragraph.sass | 7 -
.../elements/src/ui/components/_default.sass | 143 --
.../elements/src/ui/components/_preset.sass | 55 -
.../src/ui/components/link/toEmailLogin.tsx | 29 -
.../ui/components/link/toPasswordLogin.tsx | 33 -
.../components/link/withLoadingIndicator.sass | 9 -
.../components/link/withLoadingIndicator.tsx | 43 -
.../elements/src/ui/contexts/AppProvider.tsx | 76 -
.../elements/src/ui/contexts/PageProvider.tsx | 202 ---
.../src/ui/contexts/PasscodeProvider.tsx | 143 --
.../src/ui/contexts/PasswordProvider.tsx | 75 -
.../elements/src/ui/contexts/UserProvider.tsx | 62 -
frontend/elements/src/ui/pages/Error.tsx | 42 -
frontend/elements/src/ui/pages/Initialize.tsx | 59 -
frontend/elements/src/ui/pages/LoginEmail.tsx | 275 ----
.../elements/src/ui/pages/LoginPasscode.tsx | 194 ---
.../elements/src/ui/pages/LoginPassword.tsx | 131 --
.../elements/src/ui/pages/RegisterConfirm.tsx | 90 --
.../src/ui/pages/RegisterPassword.tsx | 100 --
frontend/elements/webpack.config.cjs | 4 +-
frontend/elements/webpack.config.dev.cjs | 6 +-
frontend/frontend-sdk/README.md | 2 +
frontend/frontend-sdk/package-lock.json | 4 +-
frontend/frontend-sdk/package.json | 2 +-
frontend/frontend-sdk/src/Hanko.ts | 7 +
frontend/frontend-sdk/src/index.ts | 32 +-
frontend/frontend-sdk/src/lib/Dto.ts | 89 +-
frontend/frontend-sdk/src/lib/Errors.ts | 43 +-
.../src/lib/client/EmailClient.ts | 115 ++
.../frontend-sdk/src/lib/client/HttpClient.ts | 50 +-
.../src/lib/client/PasscodeClient.ts | 69 +-
.../src/lib/client/PasswordClient.ts | 29 +-
.../src/lib/client/WebauthnClient.ts | 115 +-
.../src/lib/state/PasscodeState.ts | 26 +
.../tests/lib/client/ConfigClient.spec.ts | 2 +-
.../tests/lib/client/EmailClient.spec.ts | 191 +++
.../tests/lib/client/HttpClient.spec.ts | 22 +
.../tests/lib/client/PasscodeClient.spec.ts | 87 +-
.../tests/lib/client/PasswordClient.spec.ts | 44 +-
.../tests/lib/client/WebauthnClient.spec.ts | 195 ++-
quickstart/main.go | 1 +
quickstart/public/assets/css/common.css | 72 +-
quickstart/public/assets/css/index.css | 42 +-
quickstart/public/assets/css/secured.css | 32 +-
quickstart/public/assets/img/bg.jpg | Bin 21594 -> 0 bytes
quickstart/public/assets/img/exampleApp.svg | 3 -
quickstart/public/html/index.html | 4 +-
quickstart/public/html/secured.html | 49 +-
254 files changed, 13813 insertions(+), 4003 deletions(-)
create mode 100644 backend/dto/email.go
create mode 100644 backend/dto/user.go
create mode 100644 backend/dto/webauthn.go
create mode 100644 backend/handler/email.go
create mode 100644 backend/handler/email_test.go
create mode 100644 backend/persistence/email_persister.go
create mode 100644 backend/persistence/migrations/20221027104800_create_emails.down.fizz
create mode 100644 backend/persistence/migrations/20221027104800_create_emails.up.fizz
create mode 100644 backend/persistence/migrations/20221027104900_change_users.down.fizz
create mode 100644 backend/persistence/migrations/20221027104900_change_users.up.fizz
create mode 100644 backend/persistence/migrations/20221027123530_change_passcodes.down.fizz
create mode 100644 backend/persistence/migrations/20221027123530_change_passcodes.up.fizz
create mode 100644 backend/persistence/migrations/20221222134900_change_webauthn_credentials.down.fizz
create mode 100644 backend/persistence/migrations/20221222134900_change_webauthn_credentials.up.fizz
create mode 100644 backend/persistence/models/email.go
create mode 100644 backend/persistence/models/primary_email.go
create mode 100644 backend/persistence/primary_email_persister.go
create mode 100644 backend/test/email_persister.go
create mode 100644 backend/test/primary_email_persister.go
create mode 100644 docs/static/jsdoc/hanko-frontend-sdk/Email.html
create mode 100644 docs/static/jsdoc/hanko-frontend-sdk/EmailAddressAlreadyExistsError.html
create mode 100644 docs/static/jsdoc/hanko-frontend-sdk/EmailClient.html
create mode 100644 docs/static/jsdoc/hanko-frontend-sdk/EmailConfig.html
create mode 100644 docs/static/jsdoc/hanko-frontend-sdk/Emails.html
create mode 100644 docs/static/jsdoc/hanko-frontend-sdk/MaxNumOfEmailAddressesReachedError.html
create mode 100644 docs/static/jsdoc/hanko-frontend-sdk/WebauthnCredential.html
create mode 100644 docs/static/jsdoc/hanko-frontend-sdk/WebauthnCredentials.html
create mode 100644 docs/static/jsdoc/hanko-frontend-sdk/WebauthnTransports.html
create mode 100644 docs/static/jsdoc/hanko-frontend-sdk/lib_client_EmailClient.ts.html
delete mode 100644 frontend/elements/demo-ui.png
delete mode 100644 frontend/elements/demo.gif
create mode 100644 frontend/elements/src/Elements.tsx
create mode 100644 frontend/elements/src/Translations.ts
create mode 100644 frontend/elements/src/_mixins.sass
create mode 100644 frontend/elements/src/_preset.sass
create mode 100644 frontend/elements/src/_variables.sass
create mode 100644 frontend/elements/src/components/accordion/Accordion.tsx
create mode 100644 frontend/elements/src/components/accordion/AddEmailDropdown.tsx
create mode 100644 frontend/elements/src/components/accordion/AddPasskeyDropdown.tsx
create mode 100644 frontend/elements/src/components/accordion/ChangePasswordDropdown.tsx
create mode 100644 frontend/elements/src/components/accordion/Dropdown.tsx
create mode 100644 frontend/elements/src/components/accordion/ListEmailsAccordion.tsx
create mode 100644 frontend/elements/src/components/accordion/ListPasskeysAccordion.tsx
create mode 100644 frontend/elements/src/components/accordion/styles.sass
rename frontend/elements/src/{ui/components => components/divider}/Divider.tsx (89%)
create mode 100644 frontend/elements/src/components/divider/styles.sass
rename frontend/elements/src/{ui/components => components/error}/ErrorMessage.tsx (90%)
create mode 100644 frontend/elements/src/components/error/styles.sass
rename frontend/elements/src/{ui/components => components/form}/Button.tsx (83%)
rename frontend/elements/src/{ui/components/InputPasscode.tsx => components/form/CodeInput.tsx} (72%)
rename frontend/elements/src/{ui/components => components/form}/Form.tsx (81%)
rename frontend/elements/src/{ui/components/InputText.tsx => components/form/Input.tsx} (72%)
create mode 100644 frontend/elements/src/components/form/styles.sass
create mode 100644 frontend/elements/src/components/headline/Headline1.tsx
create mode 100644 frontend/elements/src/components/headline/Headline2.tsx
create mode 100644 frontend/elements/src/components/headline/styles.sass
create mode 100644 frontend/elements/src/components/icons/Checkmark.tsx
rename frontend/elements/src/{ui/components => components/icons}/ExclamationMark.tsx (86%)
rename frontend/elements/src/{ui/components/LoadingIndicator.tsx => components/icons/LoadingSpinner.tsx} (65%)
create mode 100644 frontend/elements/src/components/icons/styles.sass
create mode 100644 frontend/elements/src/components/link/Link.tsx
create mode 100644 frontend/elements/src/components/link/styles.sass
rename frontend/elements/src/{ui/components => components/paragraph}/Paragraph.tsx (89%)
create mode 100644 frontend/elements/src/components/paragraph/styles.sass
create mode 100644 frontend/elements/src/components/wrapper/Container.tsx
rename frontend/elements/src/{ui/components => components/wrapper}/Content.tsx (87%)
rename frontend/elements/src/{ui/components => components/wrapper}/Footer.tsx (88%)
create mode 100644 frontend/elements/src/components/wrapper/styles.sass
create mode 100644 frontend/elements/src/contexts/AppProvider.tsx
create mode 100644 frontend/elements/src/pages/ErrorPage.tsx
create mode 100644 frontend/elements/src/pages/InitPage.tsx
create mode 100644 frontend/elements/src/pages/LoginEmailPage.tsx
rename frontend/elements/src/{ui/pages/LoginFinished.tsx => pages/LoginFinishedPage.tsx} (57%)
create mode 100644 frontend/elements/src/pages/LoginPasscodePage.tsx
create mode 100644 frontend/elements/src/pages/LoginPasswordPage.tsx
create mode 100644 frontend/elements/src/pages/ProfilePage.tsx
create mode 100644 frontend/elements/src/pages/RegisterConfirmPage.tsx
rename frontend/elements/src/{ui/pages/RegisterAuthenticator.tsx => pages/RegisterPasskeyPage.tsx} (50%)
create mode 100644 frontend/elements/src/pages/RegisterPasswordPage.tsx
create mode 100644 frontend/elements/src/pages/RenamePasskeyPage.tsx
delete mode 100644 frontend/elements/src/ui/HankoAuth.tsx
delete mode 100644 frontend/elements/src/ui/Translations.ts
delete mode 100644 frontend/elements/src/ui/components/Button.sass
delete mode 100644 frontend/elements/src/ui/components/Checkmark.sass
delete mode 100644 frontend/elements/src/ui/components/Checkmark.tsx
delete mode 100644 frontend/elements/src/ui/components/Container.sass
delete mode 100644 frontend/elements/src/ui/components/Container.tsx
delete mode 100644 frontend/elements/src/ui/components/Content.sass
delete mode 100644 frontend/elements/src/ui/components/Divider.sass
delete mode 100644 frontend/elements/src/ui/components/ErrorMessage.sass
delete mode 100644 frontend/elements/src/ui/components/ExclamationMark.sass
delete mode 100644 frontend/elements/src/ui/components/Footer.sass
delete mode 100644 frontend/elements/src/ui/components/Form.sass
delete mode 100644 frontend/elements/src/ui/components/Headline.sass
delete mode 100644 frontend/elements/src/ui/components/Headline.tsx
delete mode 100644 frontend/elements/src/ui/components/Input.sass
delete mode 100644 frontend/elements/src/ui/components/InputPasscodeDigit.tsx
delete mode 100644 frontend/elements/src/ui/components/Link.sass
delete mode 100644 frontend/elements/src/ui/components/Link.tsx
delete mode 100644 frontend/elements/src/ui/components/LoadingIndicator.sass
delete mode 100644 frontend/elements/src/ui/components/LoadingWheel.sass
delete mode 100644 frontend/elements/src/ui/components/LoadingWheel.tsx
delete mode 100644 frontend/elements/src/ui/components/Paragraph.sass
delete mode 100644 frontend/elements/src/ui/components/_default.sass
delete mode 100644 frontend/elements/src/ui/components/_preset.sass
delete mode 100644 frontend/elements/src/ui/components/link/toEmailLogin.tsx
delete mode 100644 frontend/elements/src/ui/components/link/toPasswordLogin.tsx
delete mode 100644 frontend/elements/src/ui/components/link/withLoadingIndicator.sass
delete mode 100644 frontend/elements/src/ui/components/link/withLoadingIndicator.tsx
delete mode 100644 frontend/elements/src/ui/contexts/AppProvider.tsx
delete mode 100644 frontend/elements/src/ui/contexts/PageProvider.tsx
delete mode 100644 frontend/elements/src/ui/contexts/PasscodeProvider.tsx
delete mode 100644 frontend/elements/src/ui/contexts/PasswordProvider.tsx
delete mode 100644 frontend/elements/src/ui/contexts/UserProvider.tsx
delete mode 100644 frontend/elements/src/ui/pages/Error.tsx
delete mode 100644 frontend/elements/src/ui/pages/Initialize.tsx
delete mode 100644 frontend/elements/src/ui/pages/LoginEmail.tsx
delete mode 100644 frontend/elements/src/ui/pages/LoginPasscode.tsx
delete mode 100644 frontend/elements/src/ui/pages/LoginPassword.tsx
delete mode 100644 frontend/elements/src/ui/pages/RegisterConfirm.tsx
delete mode 100644 frontend/elements/src/ui/pages/RegisterPassword.tsx
create mode 100644 frontend/frontend-sdk/src/lib/client/EmailClient.ts
create mode 100644 frontend/frontend-sdk/tests/lib/client/EmailClient.spec.ts
delete mode 100644 quickstart/public/assets/img/bg.jpg
delete mode 100644 quickstart/public/assets/img/exampleApp.svg
diff --git a/backend/audit_log/logger.go b/backend/audit_log/logger.go
index 9e92cd856..1409b227e 100644
--- a/backend/audit_log/logger.go
+++ b/backend/audit_log/logger.go
@@ -68,7 +68,9 @@ func (c *logger) store(context echo.Context, auditLogType models.AuditLogType, u
var userEmail *string = nil
if user != nil {
userId = &user.ID
- userEmail = &user.Email
+ if e := user.Emails.GetPrimary(); e != nil {
+ userEmail = &e.Address
+ }
}
var errString *string = nil
if logError != nil {
@@ -103,8 +105,10 @@ func (c *logger) logToConsole(context echo.Context, auditLogType models.AuditLog
Str("time_unix", strconv.FormatInt(now.Unix(), 10))
if user != nil {
- loggerEvent.Str("user_id", user.ID.String()).
- Str("user_email", user.Email)
+ loggerEvent.Str("user_id", user.ID.String())
+ if e := user.Emails.GetPrimary(); e != nil {
+ loggerEvent.Str("user_email", e.Address)
+ }
}
loggerEvent.Send()
diff --git a/backend/config/config.go b/backend/config/config.go
index d0d486a49..d0d6e70e7 100644
--- a/backend/config/config.go
+++ b/backend/config/config.go
@@ -23,6 +23,7 @@ type Config struct {
Service Service `yaml:"service" json:"service" koanf:"service"`
Session Session `yaml:"session" json:"session" koanf:"session"`
AuditLog AuditLog `yaml:"audit_log" json:"audit_log" koanf:"audit_log"`
+ Emails Emails `yaml:"emails" json:"emails" koanf:"emails"`
}
func Load(cfgFile *string) (*Config, error) {
@@ -97,6 +98,10 @@ func DefaultConfig() *Config {
OutputStream: OutputStreamStdOut,
},
},
+ Emails: Emails{
+ RequireVerification: true,
+ MaxNumOfAddresses: 5,
+ },
}
}
@@ -348,6 +353,11 @@ type AuditLogConsole struct {
OutputStream OutputStream `yaml:"output" json:"output" koanf:"output"`
}
+type Emails struct {
+ RequireVerification bool `yaml:"require_verification" json:"require_verification" koanf:"require_verification"`
+ MaxNumOfAddresses int `yaml:"max_num_of_addresses" json:"max_num_of_addresses" koanf:"max_num_of_addresses"`
+}
+
type OutputStream string
var (
diff --git a/backend/dto/config.go b/backend/dto/config.go
index 8358f1531..73421421e 100644
--- a/backend/dto/config.go
+++ b/backend/dto/config.go
@@ -7,9 +7,13 @@ import (
// PublicConfig is the part of the configuration that will be shared with the frontend
type PublicConfig struct {
Password config.Password `json:"password"`
+ Emails config.Emails `json:"emails"`
}
// FromConfig Returns a PublicConfig from the Application configuration
func FromConfig(config config.Config) PublicConfig {
- return PublicConfig{Password: config.Password}
+ return PublicConfig{
+ Password: config.Password,
+ Emails: config.Emails,
+ }
}
diff --git a/backend/dto/email.go b/backend/dto/email.go
new file mode 100644
index 000000000..b5988caee
--- /dev/null
+++ b/backend/dto/email.go
@@ -0,0 +1,31 @@
+package dto
+
+import (
+ "github.com/gofrs/uuid"
+ "github.com/teamhanko/hanko/backend/persistence/models"
+)
+
+type EmailResponse struct {
+ ID uuid.UUID `json:"id"`
+ Address string `json:"address"`
+ IsVerified bool `json:"is_verified"`
+ IsPrimary bool `json:"is_primary"`
+}
+
+type EmailCreateRequest struct {
+ Address string `json:"address"`
+}
+
+type EmailUpdateRequest struct {
+ IsPrimary *bool `json:"is_primary"`
+}
+
+// FromEmailModel Converts the DB model to a DTO object
+func FromEmailModel(email *models.Email) *EmailResponse {
+ return &EmailResponse{
+ ID: email.ID,
+ Address: email.Address,
+ IsVerified: email.Verified,
+ IsPrimary: email.IsPrimary(),
+ }
+}
diff --git a/backend/dto/intern/WebauthnCredential.go b/backend/dto/intern/WebauthnCredential.go
index 53986dac4..37bf845e5 100644
--- a/backend/dto/intern/WebauthnCredential.go
+++ b/backend/dto/intern/WebauthnCredential.go
@@ -10,7 +10,7 @@ import (
)
func WebauthnCredentialToModel(credential *webauthn.Credential, userId uuid.UUID) *models.WebauthnCredential {
- now := time.Now()
+ now := time.Now().UTC()
aaguid, _ := uuid.FromBytes(credential.Authenticator.AAGUID)
credentialID := base64.RawURLEncoding.EncodeToString(credential.ID)
diff --git a/backend/dto/intern/WebauthnUser.go b/backend/dto/intern/WebauthnUser.go
index 9da20d5e9..f62579044 100644
--- a/backend/dto/intern/WebauthnUser.go
+++ b/backend/dto/intern/WebauthnUser.go
@@ -1,17 +1,23 @@
package intern
import (
+ "errors"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/gofrs/uuid"
"github.com/teamhanko/hanko/backend/persistence/models"
)
-func NewWebauthnUser(user models.User, credentials []models.WebauthnCredential) *WebauthnUser {
+func NewWebauthnUser(user models.User, credentials []models.WebauthnCredential) (*WebauthnUser, error) {
+ email := user.Emails.GetPrimary()
+ if email == nil {
+ return nil, errors.New("primary email unavailable")
+ }
+
return &WebauthnUser{
UserId: user.ID,
- Email: user.Email,
+ Email: email.Address,
WebauthnCredentials: credentials,
- }
+ }, nil
}
type WebauthnUser struct {
diff --git a/backend/dto/passcode.go b/backend/dto/passcode.go
index b09c879d6..aa240fbc6 100644
--- a/backend/dto/passcode.go
+++ b/backend/dto/passcode.go
@@ -8,7 +8,8 @@ type PasscodeFinishRequest struct {
}
type PasscodeInitRequest struct {
- UserId string `json:"user_id" validate:"required,uuid4"`
+ UserId string `json:"user_id" validate:"required,uuid4"`
+ EmailId *string `json:"email_id"`
}
type PasscodeReturn struct {
diff --git a/backend/dto/user.go b/backend/dto/user.go
new file mode 100644
index 000000000..d69da0f6e
--- /dev/null
+++ b/backend/dto/user.go
@@ -0,0 +1,28 @@
+package dto
+
+import (
+ "github.com/gofrs/uuid"
+ "github.com/teamhanko/hanko/backend/persistence/models"
+ "time"
+)
+
+type CreateUserResponse struct {
+ ID uuid.UUID `json:"id"` // deprecated
+ UserID uuid.UUID `json:"user_id"`
+ EmailID uuid.UUID `json:"email_id"`
+}
+
+type GetUserResponse struct {
+ ID uuid.UUID `json:"id"`
+ Email *string `json:"email,omitempty"`
+ WebauthnCredentials []models.WebauthnCredential `json:"webauthn_credentials"` // deprecated
+ UpdatedAt time.Time `json:"updated_at"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type UserInfoResponse struct {
+ ID uuid.UUID `json:"id"`
+ EmailID uuid.UUID `json:"email_id"`
+ Verified bool `json:"verified"`
+ HasWebauthnCredential bool `json:"has_webauthn_credential"`
+}
diff --git a/backend/dto/webauthn.go b/backend/dto/webauthn.go
new file mode 100644
index 000000000..6b6281fc1
--- /dev/null
+++ b/backend/dto/webauthn.go
@@ -0,0 +1,34 @@
+package dto
+
+import (
+ "github.com/gofrs/uuid"
+ "github.com/teamhanko/hanko/backend/persistence/models"
+ "time"
+)
+
+type WebauthnCredentialUpdateRequest struct {
+ Name *string `json:"name"`
+}
+
+type WebauthnCredentialResponse struct {
+ ID string `json:"id"`
+ Name *string `json:"name,omitempty"`
+ PublicKey string `json:"public_key"`
+ AttestationType string `json:"attestation_type"`
+ AAGUID uuid.UUID `json:"aaguid"`
+ CreatedAt time.Time `json:"created_at"`
+ Transports []string `json:"transports"`
+}
+
+// FromWebauthnCredentialModel Converts the DB model to a DTO object
+func FromWebauthnCredentialModel(c *models.WebauthnCredential) *WebauthnCredentialResponse {
+ return &WebauthnCredentialResponse{
+ ID: c.ID,
+ Name: c.Name,
+ PublicKey: c.PublicKey,
+ AttestationType: c.AttestationType,
+ AAGUID: c.AAGUID,
+ CreatedAt: c.CreatedAt,
+ Transports: c.Transports.GetNames(),
+ }
+}
diff --git a/backend/handler/email.go b/backend/handler/email.go
new file mode 100644
index 000000000..ec464f3d6
--- /dev/null
+++ b/backend/handler/email.go
@@ -0,0 +1,244 @@
+package handler
+
+import (
+ "errors"
+ "fmt"
+ "github.com/gobuffalo/pop/v6"
+ "github.com/gofrs/uuid"
+ "github.com/labstack/echo/v4"
+ "github.com/lestrrat-go/jwx/v2/jwt"
+ auditlog "github.com/teamhanko/hanko/backend/audit_log"
+ "github.com/teamhanko/hanko/backend/config"
+ "github.com/teamhanko/hanko/backend/dto"
+ "github.com/teamhanko/hanko/backend/persistence"
+ "github.com/teamhanko/hanko/backend/persistence/models"
+ "github.com/teamhanko/hanko/backend/session"
+ "net/http"
+ "strings"
+)
+
+type EmailHandler struct {
+ persister persistence.Persister
+ cfg *config.Config
+ sessionManager session.Manager
+ auditLogger auditlog.Logger
+}
+
+func NewEmailHandler(cfg *config.Config, persister persistence.Persister, sessionManager session.Manager, auditLogger auditlog.Logger) (*EmailHandler, error) {
+ return &EmailHandler{
+ persister: persister,
+ cfg: cfg,
+ sessionManager: sessionManager,
+ auditLogger: auditLogger,
+ }, nil
+}
+
+func (h *EmailHandler) List(c echo.Context) error {
+ sessionToken, ok := c.Get("session").(jwt.Token)
+ if !ok {
+ return errors.New("failed to cast session object")
+ }
+
+ userId, err := uuid.FromString(sessionToken.Subject())
+ if err != nil {
+ return fmt.Errorf("failed to parse subject as uuid: %w", err)
+ }
+
+ emails, err := h.persister.GetEmailPersister().FindByUserId(userId)
+ if err != nil {
+ return fmt.Errorf("failed to fetch emails from db: %w", err)
+ }
+
+ response := make([]*dto.EmailResponse, len(emails))
+
+ for i := range emails {
+ response[i] = dto.FromEmailModel(&emails[i])
+ }
+
+ return c.JSON(http.StatusOK, response)
+}
+
+func (h *EmailHandler) Create(c echo.Context) error {
+ sessionToken, ok := c.Get("session").(jwt.Token)
+ if !ok {
+ return errors.New("failed to cast session object")
+ }
+
+ userId, err := uuid.FromString(sessionToken.Subject())
+ if err != nil {
+ return fmt.Errorf("failed to parse subject as uuid: %w", err)
+ }
+
+ var body dto.EmailCreateRequest
+
+ err = (&echo.DefaultBinder{}).BindBody(c, &body)
+ if err != nil {
+ return dto.ToHttpError(err)
+ }
+
+ emailCount, err := h.persister.GetEmailPersister().CountByUserId(userId)
+ if err != nil {
+ return fmt.Errorf("failed to count user emails: %w", err)
+ }
+
+ if emailCount >= h.cfg.Emails.MaxNumOfAddresses {
+ return dto.NewHTTPError(http.StatusConflict).SetInternal(errors.New("max number of email addresses reached"))
+ }
+
+ newEmailAddress := strings.ToLower(body.Address)
+
+ email, err := h.persister.GetEmailPersister().FindByAddress(newEmailAddress)
+ if err != nil {
+ return fmt.Errorf("failed to fetch email from db: %w", err)
+ }
+
+ return h.persister.Transaction(func(tx *pop.Connection) error {
+ user, err := h.persister.GetUserPersister().Get(userId)
+ if err != nil {
+ return fmt.Errorf("failed to fetch user from db: %w", err)
+ }
+
+ if email != nil {
+ // The email address already exists.
+ if email.UserID != nil {
+ // The email address exists and is assigned to a user already, therefore it can't be created.
+ return dto.NewHTTPError(http.StatusBadRequest).SetInternal(errors.New("email address already exists"))
+ }
+
+ if !h.cfg.Emails.RequireVerification {
+ // Email verification is currently not required and there is no user assigned to the existing email
+ // address. This can happen, when email verification was turned on before, because then the email
+ // address will be assigned to the user only after passcode verification. The email was left unassigned
+ // and has not been verified, so we assign the email to the current user.
+ email.UserID = &user.ID
+
+ err = h.persister.GetEmailPersisterWithConnection(tx).Update(*email)
+ if err != nil {
+ return fmt.Errorf("failed to update the existing email: %w", err)
+ }
+ }
+ } else {
+ // The email address has not been registered so far.
+ if h.cfg.Emails.RequireVerification {
+ // The email address will be assigned to the user only after passcode verification.
+ email = models.NewEmail(nil, newEmailAddress)
+ } else {
+ // No verification required - assign the email to the given user.
+ email = models.NewEmail(&user.ID, newEmailAddress)
+ }
+
+ err = h.persister.GetEmailPersisterWithConnection(tx).Create(*email)
+ if err != nil {
+ return fmt.Errorf("failed to store email to db: %w", err)
+ }
+ }
+
+ err = h.auditLogger.Create(c, models.AuditLogEmailCreated, user, nil)
+ if err != nil {
+ return fmt.Errorf("failed to create audit log: %w", err)
+ }
+
+ return c.JSON(http.StatusOK, email)
+ })
+}
+
+func (h *EmailHandler) SetPrimaryEmail(c echo.Context) error {
+ sessionToken, ok := c.Get("session").(jwt.Token)
+ if !ok {
+ return errors.New("failed to cast session object")
+ }
+
+ userId, err := uuid.FromString(sessionToken.Subject())
+ if err != nil {
+ return fmt.Errorf("failed to parse subject as uuid: %w", err)
+ }
+
+ emailId, err := uuid.FromString(c.Param("id"))
+ if err != nil {
+ return dto.NewHTTPError(http.StatusBadRequest).SetInternal(err)
+ }
+
+ user, err := h.persister.GetUserPersister().Get(userId)
+ if err != nil {
+ return fmt.Errorf("failed to fetch user from db: %w", err)
+ }
+
+ email := user.GetEmailById(emailId)
+ if email == nil {
+ return dto.NewHTTPError(http.StatusNotFound).SetInternal(errors.New("the email address is not assigned to the current user"))
+ }
+
+ if email.IsPrimary() {
+ return c.NoContent(http.StatusNoContent)
+ }
+
+ return h.persister.Transaction(func(tx *pop.Connection) error {
+ var primaryEmail *models.PrimaryEmail
+ if e := user.Emails.GetPrimary(); e != nil {
+ primaryEmail = e.PrimaryEmail
+ }
+
+ if primaryEmail == nil {
+ primaryEmail = models.NewPrimaryEmail(email.ID, user.ID)
+ err = h.persister.GetPrimaryEmailPersisterWithConnection(tx).Create(*primaryEmail)
+ if err != nil {
+ return fmt.Errorf("failed to store new primary email: %w", err)
+ }
+ } else {
+ primaryEmail.EmailID = email.ID
+ err = h.persister.GetPrimaryEmailPersisterWithConnection(tx).Update(*primaryEmail)
+ if err != nil {
+ return fmt.Errorf("failed to change primary email: %w", err)
+ }
+ }
+
+ err = h.auditLogger.Create(c, models.AuditLogPrimaryEmailChanged, user, nil)
+ if err != nil {
+ return fmt.Errorf("failed to create audit log: %w", err)
+ }
+
+ return c.NoContent(http.StatusNoContent)
+ })
+}
+
+func (h *EmailHandler) Delete(c echo.Context) error {
+ sessionToken, ok := c.Get("session").(jwt.Token)
+ if !ok {
+ return errors.New("failed to cast session object")
+ }
+
+ userId, err := uuid.FromString(sessionToken.Subject())
+ if err != nil {
+ return fmt.Errorf("failed to parse subject as uuid: %w", err)
+ }
+
+ emailId, err := uuid.FromString(c.Param("id"))
+
+ user, err := h.persister.GetUserPersister().Get(userId)
+ if err != nil {
+ return fmt.Errorf("failed to fetch user from db: %w", err)
+ }
+
+ emailToBeDeleted := user.GetEmailById(emailId)
+ if emailToBeDeleted == nil {
+ return errors.New("email with given emailId not available")
+ }
+
+ if emailToBeDeleted.IsPrimary() {
+ return dto.NewHTTPError(http.StatusConflict).SetInternal(errors.New("primary email can't be deleted"))
+ }
+
+ return h.persister.Transaction(func(tx *pop.Connection) error {
+ err = h.persister.GetEmailPersisterWithConnection(tx).Delete(*emailToBeDeleted)
+ if err != nil {
+ return fmt.Errorf("failed to delete email from db: %w", err)
+ }
+
+ err = h.auditLogger.Create(c, models.AuditLogEmailDeleted, user, nil)
+ if err != nil {
+ return fmt.Errorf("failed to create audit log: %w", err)
+ }
+
+ return c.NoContent(http.StatusNoContent)
+ })
+}
diff --git a/backend/handler/email_test.go b/backend/handler/email_test.go
new file mode 100644
index 000000000..598578a65
--- /dev/null
+++ b/backend/handler/email_test.go
@@ -0,0 +1,182 @@
+package handler
+
+import (
+ "encoding/json"
+ "fmt"
+ "github.com/gofrs/uuid"
+ "github.com/labstack/echo/v4"
+ "github.com/lestrrat-go/jwx/v2/jwt"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/teamhanko/hanko/backend/config"
+ "github.com/teamhanko/hanko/backend/dto"
+ "github.com/teamhanko/hanko/backend/persistence/models"
+ "github.com/teamhanko/hanko/backend/test"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestNewEmailHandler(t *testing.T) {
+ emailHandler, err := NewEmailHandler(&config.Config{}, test.NewPersister(nil, nil, nil, nil, nil, nil, nil, nil, nil), sessionManager{}, test.NewAuditLogger())
+ assert.NoError(t, err)
+ assert.NotEmpty(t, emailHandler)
+}
+
+func TestEmailHandler_List(t *testing.T) {
+ var emails []*dto.EmailResponse
+ uId1, _ := uuid.NewV4()
+ uId2, _ := uuid.NewV4()
+
+ tests := []struct {
+ name string
+ userId uuid.UUID
+ data []models.Email
+ expectedCount int
+ }{
+ {
+ name: "should return all user assigned email addresses",
+ userId: uId1,
+ data: []models.Email{
+ {
+ UserID: &uId1,
+ Address: "john.doe+1@example.com",
+ },
+ {
+ UserID: &uId1,
+ Address: "john.doe+2@example.com",
+ },
+ {
+ UserID: &uId2,
+ Address: "john.doe+3@example.com",
+ },
+ },
+ expectedCount: 2,
+ },
+ {
+ name: "should return an empty list when the user has no email addresses assigned",
+ userId: uId2,
+ data: []models.Email{
+ {
+ UserID: &uId1,
+ Address: "john.doe+1@example.com",
+ },
+ {
+ UserID: &uId1,
+ Address: "john.doe+2@example.com",
+ },
+ },
+ expectedCount: 0,
+ },
+ }
+
+ for _, currentTest := range tests {
+ t.Run(currentTest.name, func(t *testing.T) {
+ e := echo.New()
+ e.Validator = dto.NewCustomValidator()
+ req := httptest.NewRequest(http.MethodGet, "/emails", nil)
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ c := e.NewContext(req, rec)
+ token := jwt.New()
+ err := token.Set(jwt.SubjectKey, currentTest.userId.String())
+ require.NoError(t, err)
+ c.Set("session", token)
+ p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil, currentTest.data, nil)
+ handler, err := NewEmailHandler(&config.Config{}, p, sessionManager{}, test.NewAuditLogger())
+ assert.NoError(t, err)
+
+ if assert.NoError(t, handler.List(c)) {
+ assert.Equal(t, http.StatusOK, rec.Code)
+ assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &emails))
+ assert.Equal(t, currentTest.expectedCount, len(emails))
+ }
+ })
+ }
+}
+
+func TestEmailHandler_SetPrimaryEmail(t *testing.T) {
+ uId, _ := uuid.NewV4()
+ emailId1, _ := uuid.NewV4()
+ emailId2, _ := uuid.NewV4()
+ testData := []models.User{
+ {
+ ID: uId,
+ Emails: []models.Email{
+ {
+ ID: emailId1,
+ Address: "john.doe@example.com",
+ PrimaryEmail: nil,
+ },
+ {
+ ID: emailId2,
+ Address: "john.doe@example.com",
+ PrimaryEmail: &models.PrimaryEmail{},
+ },
+ },
+ },
+ }
+
+ e := echo.New()
+ e.Validator = dto.NewCustomValidator()
+ req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/emails/%s/set_primary", emailId1.String()), nil)
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ c := e.NewContext(req, rec)
+ c.SetPath("/emails/:id/set_primary")
+ c.SetParamNames("id")
+ c.SetParamValues(emailId1.String())
+ token := jwt.New()
+ err := token.Set(jwt.SubjectKey, uId.String())
+ require.NoError(t, err)
+ c.Set("session", token)
+ p := test.NewPersister(testData, nil, nil, nil, nil, nil, nil, nil, nil)
+ handler, err := NewEmailHandler(&config.Config{}, p, sessionManager{}, test.NewAuditLogger())
+
+ assert.NoError(t, err)
+ assert.NoError(t, handler.SetPrimaryEmail(c))
+ assert.Equal(t, http.StatusNoContent, rec.Code)
+}
+
+func TestEmailHandler_Delete(t *testing.T) {
+ uId, _ := uuid.NewV4()
+ emailId1, _ := uuid.NewV4()
+ emailId2, _ := uuid.NewV4()
+ testData := []models.User{
+ {
+ ID: uId,
+ Emails: []models.Email{
+ {
+ ID: emailId1,
+ Address: "john.doe@example.com",
+ PrimaryEmail: nil,
+ },
+ {
+ ID: emailId2,
+ Address: "john.doe@example.com",
+ PrimaryEmail: &models.PrimaryEmail{},
+ },
+ },
+ },
+ }
+
+ e := echo.New()
+ e.Validator = dto.NewCustomValidator()
+ req := httptest.NewRequest(http.MethodDelete, "/", nil)
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ c := e.NewContext(req, rec)
+ c.SetPath("/emails/:id")
+ c.SetParamNames("id")
+ c.SetParamValues(emailId1.String())
+ token := jwt.New()
+ err := token.Set(jwt.SubjectKey, uId.String())
+ require.NoError(t, err)
+ c.Set("session", token)
+ p := test.NewPersister(testData, nil, nil, nil, nil, nil, nil, nil, nil)
+ handler, err := NewEmailHandler(&config.Config{}, p, sessionManager{}, test.NewAuditLogger())
+
+ assert.NoError(t, err)
+ assert.NoError(t, handler.Delete(c))
+ assert.Equal(t, http.StatusNoContent, rec.Code)
+}
diff --git a/backend/handler/passcode.go b/backend/handler/passcode.go
index f17a23d30..60a05d07b 100644
--- a/backend/handler/passcode.go
+++ b/backend/handler/passcode.go
@@ -81,6 +81,52 @@ func (h *PasscodeHandler) Init(c echo.Context) error {
return dto.NewHTTPError(http.StatusBadRequest).SetInternal(errors.New("user not found"))
}
+ var emailId uuid.UUID
+ if body.EmailId != nil {
+ emailId, err = uuid.FromString(*body.EmailId)
+ if err != nil {
+ return dto.NewHTTPError(http.StatusBadRequest, "failed to parse emailId as uuid").SetInternal(err)
+ }
+ }
+
+ // Determine where to send the passcode
+ var email *models.Email
+ if !emailId.IsNil() {
+ // Send the passcode to the specified email address
+ email, err = h.persister.GetEmailPersister().Get(emailId)
+ if email == nil {
+ return dto.NewHTTPError(http.StatusBadRequest, "the specified emailId is not available")
+ }
+ } else if e := user.Emails.GetPrimary(); e == nil {
+ // Workaround to support hanko element versions before v0.1.0-alpha:
+ // If user has no primary email, check if a cookie with an email id is present
+ emailIdCookie, err := c.Cookie("hanko_email_id")
+ if err != nil {
+ return fmt.Errorf("failed to get email id cookie: %w", err)
+ }
+
+ if emailIdCookie != nil && emailIdCookie.Value != "" {
+ emailId, err = uuid.FromString(emailIdCookie.Value)
+ if err != nil {
+ return dto.NewHTTPError(http.StatusBadRequest, "failed to parse emailId as uuid").SetInternal(err)
+ }
+ email, err = h.persister.GetEmailPersister().Get(emailId)
+ if email == nil {
+ return dto.NewHTTPError(http.StatusBadRequest, "the specified emailId is not available")
+ }
+ } else {
+ // Can't determine email address to which the passcode should be sent to
+ return dto.NewHTTPError(http.StatusBadRequest, "an emailId needs to be specified")
+ }
+ } else {
+ // Send the passcode to the primary email address
+ email = e
+ }
+
+ if email.User != nil && email.User.ID.String() != user.ID.String() {
+ return dto.NewHTTPError(http.StatusForbidden).SetInternal(errors.New("email address is assigned to another user"))
+ }
+
passcode, err := h.passcodeGenerator.Generate()
if err != nil {
return fmt.Errorf("failed to generate passcode: %w", err)
@@ -98,6 +144,7 @@ func (h *PasscodeHandler) Init(c echo.Context) error {
passcodeModel := models.Passcode{
ID: passcodeId,
UserId: userId,
+ EmailID: email.ID,
Ttl: h.TTL,
Code: string(hashedPasscode),
CreatedAt: now,
@@ -123,7 +170,7 @@ func (h *PasscodeHandler) Init(c echo.Context) error {
}
message := gomail.NewMessage()
- message.SetAddressHeader("To", user.Email, "")
+ message.SetAddressHeader("To", email.Address, "")
message.SetAddressHeader("From", h.emailConfig.FromAddress, h.emailConfig.FromName)
message.SetHeader("Subject", h.renderer.Translate(lang, "email_subject_login", data))
@@ -168,6 +215,8 @@ func (h *PasscodeHandler) Finish(c echo.Context) error {
transactionError := h.persister.Transaction(func(tx *pop.Connection) error {
passcodePersister := h.persister.GetPasscodePersisterWithConnection(tx)
userPersister := h.persister.GetUserPersisterWithConnection(tx)
+ emailPersister := h.persister.GetEmailPersisterWithConnection(tx)
+ primaryEmailPersister := h.persister.GetPrimaryEmailPersisterWithConnection(tx)
passcode, err := passcodePersister.Get(passcodeId)
if err != nil {
return fmt.Errorf("failed to get passcode: %w", err)
@@ -231,11 +280,38 @@ func (h *PasscodeHandler) Finish(c echo.Context) error {
return fmt.Errorf("failed to delete passcode: %w", err)
}
- if !user.Verified {
- user.Verified = true
- err = userPersister.Update(*user)
+ if passcode.Email.User != nil && passcode.Email.User.ID.String() != user.ID.String() {
+ return dto.NewHTTPError(http.StatusForbidden, "email address has been claimed by another user")
+ }
+
+ if !passcode.Email.Verified {
+ // Update email verified status and assign the email address to the user.
+ passcode.Email.Verified = true
+ passcode.Email.UserID = &user.ID
+
+ err = emailPersister.Update(passcode.Email)
if err != nil {
- return fmt.Errorf("failed to update user: %w", err)
+ return fmt.Errorf("failed to update the email verified status: %w", err)
+ }
+
+ if user.Emails.GetPrimary() == nil {
+ primaryEmail := models.NewPrimaryEmail(passcode.Email.ID, user.ID)
+ err = primaryEmailPersister.Create(*primaryEmail)
+ if err != nil {
+ return fmt.Errorf("failed to create primary email: %w", err)
+ }
+
+ user.Emails = models.Emails{passcode.Email}
+ user.Emails.SetPrimary(primaryEmail)
+ err = h.auditLogger.Create(c, models.AuditLogPrimaryEmailChanged, user, nil)
+ if err != nil {
+ return fmt.Errorf("failed to create audit log: %w", err)
+ }
+ }
+
+ err = h.auditLogger.Create(c, models.AuditLogEmailVerified, user, nil)
+ if err != nil {
+ return fmt.Errorf("failed to create audit log: %w", err)
}
}
diff --git a/backend/handler/passcode_test.go b/backend/handler/passcode_test.go
index f4031e53f..33f92a50f 100644
--- a/backend/handler/passcode_test.go
+++ b/backend/handler/passcode_test.go
@@ -19,13 +19,13 @@ import (
)
func TestNewPasscodeHandler(t *testing.T) {
- passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(nil, nil, nil, nil, nil, nil, nil), sessionManager{}, mailer{}, test.NewAuditLogger())
+ passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(nil, nil, nil, nil, nil, nil, nil, nil, nil), sessionManager{}, mailer{}, test.NewAuditLogger())
assert.NoError(t, err)
assert.NotEmpty(t, passcodeHandler)
}
func TestPasscodeHandler_Init(t *testing.T) {
- passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, nil, nil, nil, nil, nil, nil), sessionManager{}, mailer{}, test.NewAuditLogger())
+ passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, nil, nil, nil, nil, nil, nil, emails, nil), sessionManager{}, mailer{}, test.NewAuditLogger())
require.NoError(t, err)
body := dto.PasscodeInitRequest{
@@ -47,7 +47,7 @@ func TestPasscodeHandler_Init(t *testing.T) {
}
func TestPasscodeHandler_Init_UnknownUserId(t *testing.T) {
- passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, nil, nil, nil, nil, nil, nil), sessionManager{}, mailer{}, test.NewAuditLogger())
+ passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, nil, nil, nil, nil, nil, nil, nil, nil), sessionManager{}, mailer{}, test.NewAuditLogger())
require.NoError(t, err)
body := dto.PasscodeInitRequest{
@@ -71,7 +71,7 @@ func TestPasscodeHandler_Init_UnknownUserId(t *testing.T) {
}
func TestPasscodeHandler_Finish(t *testing.T) {
- passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, passcodes(), nil, nil, nil, nil, nil), sessionManager{}, mailer{}, test.NewAuditLogger())
+ passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, passcodes(), nil, nil, nil, nil, nil, nil, nil), sessionManager{}, mailer{}, test.NewAuditLogger())
require.NoError(t, err)
body := dto.PasscodeFinishRequest{
@@ -94,7 +94,7 @@ func TestPasscodeHandler_Finish(t *testing.T) {
}
func TestPasscodeHandler_Finish_WrongCode(t *testing.T) {
- passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, passcodes(), nil, nil, nil, nil, nil), sessionManager{}, mailer{}, test.NewAuditLogger())
+ passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, passcodes(), nil, nil, nil, nil, nil, nil, nil), sessionManager{}, mailer{}, test.NewAuditLogger())
require.NoError(t, err)
body := dto.PasscodeFinishRequest{
@@ -119,7 +119,7 @@ func TestPasscodeHandler_Finish_WrongCode(t *testing.T) {
}
func TestPasscodeHandler_Finish_WrongCode_3_Times(t *testing.T) {
- passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, passcodes(), nil, nil, nil, nil, nil), sessionManager{}, mailer{}, test.NewAuditLogger())
+ passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, passcodes(), nil, nil, nil, nil, nil, nil, nil), sessionManager{}, mailer{}, test.NewAuditLogger())
require.NoError(t, err)
body := dto.PasscodeFinishRequest{
@@ -153,7 +153,7 @@ func TestPasscodeHandler_Finish_WrongCode_3_Times(t *testing.T) {
}
func TestPasscodeHandler_Finish_WrongId(t *testing.T) {
- passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, passcodes(), nil, nil, nil, nil, nil), sessionManager{}, mailer{}, test.NewAuditLogger())
+ passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, passcodes(), nil, nil, nil, nil, nil, nil, nil), sessionManager{}, mailer{}, test.NewAuditLogger())
require.NoError(t, err)
body := dto.PasscodeFinishRequest{
diff --git a/backend/handler/password_test.go b/backend/handler/password_test.go
index a248f91c7..8d1802fac 100644
--- a/backend/handler/password_test.go
+++ b/backend/handler/password_test.go
@@ -26,7 +26,6 @@ func TestPasswordHandler_Set_Create(t *testing.T) {
func() models.User {
return models.User{
ID: userId,
- Email: "john.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -47,7 +46,7 @@ func TestPasswordHandler_Set_Create(t *testing.T) {
require.NoError(t, err)
c.Set("session", token)
- p := test.NewPersister(users, nil, nil, nil, nil, []models.PasswordCredential{}, nil)
+ p := test.NewPersister(users, nil, nil, nil, nil, []models.PasswordCredential{}, nil, nil, nil)
handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}, test.NewAuditLogger())
if assert.NoError(t, handler.Set(c)) {
@@ -61,7 +60,6 @@ func TestPasswordHandler_Set_Create_PasswordTooShort(t *testing.T) {
func() models.User {
return models.User{
ID: userId,
- Email: "john.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -82,7 +80,7 @@ func TestPasswordHandler_Set_Create_PasswordTooShort(t *testing.T) {
require.NoError(t, err)
c.Set("session", token)
- p := test.NewPersister(users, nil, nil, nil, nil, []models.PasswordCredential{}, nil)
+ p := test.NewPersister(users, nil, nil, nil, nil, []models.PasswordCredential{}, nil, nil, nil)
handler := NewPasswordHandler(p, sessionManager{}, &config.Config{Password: config.Password{MinPasswordLength: 8}}, test.NewAuditLogger())
err = handler.Set(c)
@@ -98,7 +96,6 @@ func TestPasswordHandler_Set_Create_PasswordTooLong(t *testing.T) {
func() models.User {
return models.User{
ID: userId,
- Email: "john.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -119,7 +116,7 @@ func TestPasswordHandler_Set_Create_PasswordTooLong(t *testing.T) {
require.NoError(t, err)
c.Set("session", token)
- p := test.NewPersister(users, nil, nil, nil, nil, []models.PasswordCredential{}, nil)
+ p := test.NewPersister(users, nil, nil, nil, nil, []models.PasswordCredential{}, nil, nil, nil)
handler := NewPasswordHandler(p, sessionManager{}, &config.Config{Password: config.Password{MinPasswordLength: 8}}, test.NewAuditLogger())
err = handler.Set(c)
@@ -135,7 +132,6 @@ func TestPasswordHandler_Set_Update(t *testing.T) {
func() models.User {
return models.User{
ID: userId,
- Email: "john.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -172,7 +168,7 @@ func TestPasswordHandler_Set_Update(t *testing.T) {
require.NoError(t, err)
c.Set("session", token)
- p := test.NewPersister(users, nil, nil, nil, nil, passwords, nil)
+ p := test.NewPersister(users, nil, nil, nil, nil, passwords, nil, nil, nil)
handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}, test.NewAuditLogger())
if assert.NoError(t, handler.Set(c)) {
@@ -197,7 +193,7 @@ func TestPasswordHandler_Set_UserNotFound(t *testing.T) {
require.NoError(t, err)
c.Set("session", token)
- p := test.NewPersister([]models.User{}, nil, nil, nil, nil, []models.PasswordCredential{}, nil)
+ p := test.NewPersister([]models.User{}, nil, nil, nil, nil, []models.PasswordCredential{}, nil, nil, nil)
handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}, test.NewAuditLogger())
err = handler.Set(c)
@@ -213,7 +209,6 @@ func TestPasswordHandler_Set_TokenHasWrongSubject(t *testing.T) {
func() models.User {
return models.User{
ID: userId,
- Email: "john.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -250,7 +245,7 @@ func TestPasswordHandler_Set_TokenHasWrongSubject(t *testing.T) {
require.NoError(t, err)
c.Set("session", token)
- p := test.NewPersister(users, nil, nil, nil, nil, passwords, nil)
+ p := test.NewPersister(users, nil, nil, nil, nil, passwords, nil, nil, nil)
handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}, test.NewAuditLogger())
err = handler.Set(c)
@@ -275,7 +270,7 @@ func TestPasswordHandler_Set_BadRequestBody(t *testing.T) {
require.NoError(t, err)
c.Set("session", token)
- p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil)
+ p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil, nil, nil)
handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}, test.NewAuditLogger())
err = handler.Set(c)
@@ -291,7 +286,6 @@ func TestPasswordHandler_Login(t *testing.T) {
func() models.User {
return models.User{
ID: userId,
- Email: "john.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -322,7 +316,7 @@ func TestPasswordHandler_Login(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
- p := test.NewPersister(users, nil, nil, nil, nil, passwords, nil)
+ p := test.NewPersister(users, nil, nil, nil, nil, passwords, nil, nil, nil)
handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}, test.NewAuditLogger())
if assert.NoError(t, handler.Login(c)) {
@@ -344,7 +338,6 @@ func TestPasswordHandler_Login_WrongPassword(t *testing.T) {
func() models.User {
return models.User{
ID: userId,
- Email: "john.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -375,7 +368,7 @@ func TestPasswordHandler_Login_WrongPassword(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
- p := test.NewPersister(users, nil, nil, nil, nil, passwords, nil)
+ p := test.NewPersister(users, nil, nil, nil, nil, passwords, nil, nil, nil)
handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}, test.NewAuditLogger())
err = handler.Login(c)
@@ -395,7 +388,7 @@ func TestPasswordHandler_Login_NonExistingUser(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
- p := test.NewPersister([]models.User{}, nil, nil, nil, nil, []models.PasswordCredential{}, nil)
+ p := test.NewPersister([]models.User{}, nil, nil, nil, nil, []models.PasswordCredential{}, nil, nil, nil)
handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}, test.NewAuditLogger())
err := handler.Login(c)
diff --git a/backend/handler/user.go b/backend/handler/user.go
index e7e626dc1..f054c583d 100644
--- a/backend/handler/user.go
+++ b/backend/handler/user.go
@@ -8,22 +8,28 @@ import (
"github.com/labstack/echo/v4"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/teamhanko/hanko/backend/audit_log"
+ "github.com/teamhanko/hanko/backend/config"
"github.com/teamhanko/hanko/backend/dto"
"github.com/teamhanko/hanko/backend/persistence"
"github.com/teamhanko/hanko/backend/persistence/models"
+ "github.com/teamhanko/hanko/backend/session"
"net/http"
"strings"
)
type UserHandler struct {
- persister persistence.Persister
- auditLogger auditlog.Logger
+ persister persistence.Persister
+ sessionManager session.Manager
+ auditLogger auditlog.Logger
+ cfg *config.Config
}
-func NewUserHandler(persister persistence.Persister, auditLogger auditlog.Logger) *UserHandler {
+func NewUserHandler(cfg *config.Config, persister persistence.Persister, sessionManager session.Manager, auditLogger auditlog.Logger) *UserHandler {
return &UserHandler{
- persister: persister,
- auditLogger: auditLogger,
+ persister: persister,
+ auditLogger: auditLogger,
+ sessionManager: sessionManager,
+ cfg: cfg,
}
}
@@ -44,24 +50,91 @@ func (h *UserHandler) Create(c echo.Context) error {
body.Email = strings.ToLower(body.Email)
return h.persister.Transaction(func(tx *pop.Connection) error {
- user, err := h.persister.GetUserPersisterWithConnection(tx).GetByEmail(body.Email)
+ newUser := models.NewUser()
+ err := h.persister.GetUserPersisterWithConnection(tx).Create(newUser)
if err != nil {
- return fmt.Errorf("failed to get user: %w", err)
+ return fmt.Errorf("failed to store user: %w", err)
}
- if user != nil {
- return dto.NewHTTPError(http.StatusConflict).SetInternal(errors.New(fmt.Sprintf("user with email %s already exists", user.Email)))
+ email, err := h.persister.GetEmailPersisterWithConnection(tx).FindByAddress(body.Email)
+ if err != nil {
+ return fmt.Errorf("failed to get email: %w", err)
}
- newUser := models.NewUser(body.Email)
- err = h.persister.GetUserPersisterWithConnection(tx).Create(newUser)
- if err != nil {
- return fmt.Errorf("failed to store user: %w", err)
+ if email != nil {
+ if email.UserID != nil {
+ // The email already exists and is assigned already.
+ return dto.NewHTTPError(http.StatusConflict).SetInternal(errors.New(fmt.Sprintf("user with email %s already exists", body.Email)))
+ }
+
+ if !h.cfg.Emails.RequireVerification {
+ // Assign the email address to the user because it's currently unassigned and email verification is turned off.
+ email.UserID = &newUser.ID
+ err = h.persister.GetEmailPersisterWithConnection(tx).Update(*email)
+ if err != nil {
+ return fmt.Errorf("failed to update email address: %w", err)
+ }
+ }
+ } else {
+ // The email address does not exist, create a new one.
+ if h.cfg.Emails.RequireVerification {
+ // The email can only be assigned to the user via passcode verification.
+ email = models.NewEmail(nil, body.Email)
+ } else {
+ email = models.NewEmail(&newUser.ID, body.Email)
+ }
+
+ err = h.persister.GetEmailPersisterWithConnection(tx).Create(*email)
+ if err != nil {
+ return fmt.Errorf("failed to store user: %w", err)
+ }
}
- _ = h.auditLogger.Create(c, models.AuditLogUserCreated, &newUser, nil) // TODO: what to do on error
+ if !h.cfg.Emails.RequireVerification {
+ primaryEmail := models.NewPrimaryEmail(email.ID, newUser.ID)
+ err = h.persister.GetPrimaryEmailPersisterWithConnection(tx).Create(*primaryEmail)
+ if err != nil {
+ return fmt.Errorf("failed to store primary email: %w", err)
+ }
+
+ token, err := h.sessionManager.GenerateJWT(newUser.ID)
+ if err != nil {
+ return fmt.Errorf("failed to generate jwt: %w", err)
+ }
+
+ cookie, err := h.sessionManager.GenerateCookie(token)
+ if err != nil {
+ return fmt.Errorf("failed to create session token: %w", err)
+ }
+
+ c.SetCookie(cookie)
+
+ if h.cfg.Session.EnableAuthTokenHeader {
+ c.Response().Header().Set("X-Auth-Token", token)
+ c.Response().Header().Set("Access-Control-Expose-Headers", "X-Auth-Token")
+ }
+ }
+
+ err = h.auditLogger.Create(c, models.AuditLogUserCreated, &newUser, nil)
+ if err != nil {
+ return fmt.Errorf("failed to write audit log: %w", err)
+ }
- return c.JSON(http.StatusOK, newUser)
+ // This cookie is a workaround for hanko element versions before 0.1.0-alpha,
+ // because else the backend would not know where to send the first passcode.
+ c.SetCookie(&http.Cookie{
+ Name: "hanko_email_id",
+ Value: email.ID.String(),
+ Domain: h.cfg.Session.Cookie.Domain,
+ Secure: h.cfg.Session.Cookie.Secure,
+ HttpOnly: h.cfg.Session.Cookie.HttpOnly,
+ })
+
+ return c.JSON(http.StatusOK, dto.CreateUserResponse{
+ ID: newUser.ID,
+ UserID: newUser.ID,
+ EmailID: email.ID,
+ })
})
}
@@ -86,7 +159,18 @@ func (h *UserHandler) Get(c echo.Context) error {
return dto.NewHTTPError(http.StatusNotFound).SetInternal(errors.New("user not found"))
}
- return c.JSON(http.StatusOK, user)
+ var emailAddress *string
+ if e := user.Emails.GetPrimary(); e != nil {
+ emailAddress = &e.Address
+ }
+
+ return c.JSON(http.StatusOK, dto.GetUserResponse{
+ ID: user.ID,
+ WebauthnCredentials: user.WebauthnCredentials,
+ Email: emailAddress,
+ CreatedAt: user.CreatedAt,
+ UpdatedAt: user.UpdatedAt,
+ })
}
type UserGetByEmailBody struct {
@@ -103,23 +187,26 @@ func (h *UserHandler) GetUserIdByEmail(c echo.Context) error {
return dto.ToHttpError(err)
}
- user, err := h.persister.GetUserPersister().GetByEmail(strings.ToLower(request.Email))
+ emailAddress := strings.ToLower(request.Email)
+ email, err := h.persister.GetEmailPersister().FindByAddress(emailAddress)
if err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
- if user == nil {
+ if email == nil || email.UserID == nil {
return dto.NewHTTPError(http.StatusNotFound).SetInternal(errors.New("user not found"))
}
- return c.JSON(http.StatusOK, struct {
- UserId string `json:"id"`
- Verified bool `json:"verified"`
- HasWebauthnCredential bool `json:"has_webauthn_credential"`
- }{
- UserId: user.ID.String(),
- Verified: user.Verified,
- HasWebauthnCredential: len(user.WebauthnCredentials) > 0,
+ credentials, err := h.persister.GetWebauthnCredentialPersister().GetFromUser(*email.UserID)
+ if err != nil {
+ return fmt.Errorf("failed to get webauthn credentials: %w", err)
+ }
+
+ return c.JSON(http.StatusOK, dto.UserInfoResponse{
+ ID: *email.UserID,
+ Verified: email.Verified,
+ EmailID: email.ID,
+ HasWebauthnCredential: len(credentials) > 0,
})
}
diff --git a/backend/handler/user_admin.go b/backend/handler/user_admin.go
index 8f4e13dba..f693235a4 100644
--- a/backend/handler/user_admin.go
+++ b/backend/handler/user_admin.go
@@ -10,7 +10,6 @@ import (
"net/http"
"net/url"
"strconv"
- "strings"
)
type UserHandlerAdmin struct {
@@ -51,52 +50,6 @@ type UserPatchRequest struct {
Verified *bool `json:"verified"`
}
-func (h *UserHandlerAdmin) Patch(c echo.Context) error {
- var patchRequest UserPatchRequest
- if err := c.Bind(&patchRequest); err != nil {
- return dto.ToHttpError(err)
- }
-
- if err := c.Validate(patchRequest); err != nil {
- return dto.ToHttpError(err)
- }
-
- patchRequest.Email = strings.ToLower(patchRequest.Email)
-
- p := h.persister.GetUserPersister()
- user, err := p.Get(uuid.FromStringOrNil(patchRequest.UserId))
- if err != nil {
- return fmt.Errorf("failed to get user: %w", err)
- }
-
- if user == nil {
- return dto.NewHTTPError(http.StatusNotFound, "user not found")
- }
-
- if patchRequest.Email != "" && patchRequest.Email != user.Email {
- maybeExistingUser, err := p.GetByEmail(patchRequest.Email)
- if err != nil {
- return fmt.Errorf("failed to get user: %w", err)
- }
-
- if maybeExistingUser != nil {
- return dto.NewHTTPError(http.StatusBadRequest, "email address not available")
- }
-
- user.Email = patchRequest.Email
- }
-
- if patchRequest.Verified != nil {
- user.Verified = *patchRequest.Verified
- }
-
- err = p.Update(*user)
- if err != nil {
- return fmt.Errorf("failed to update user: %w", err)
- }
- return c.JSON(http.StatusOK, nil) // TODO: mabye we should return the user object???
-}
-
type UserListRequest struct {
PerPage int `query:"per_page"`
Page int `query:"page"`
diff --git a/backend/handler/user_admin_test.go b/backend/handler/user_admin_test.go
index cc85914b3..82cffbcf8 100644
--- a/backend/handler/user_admin_test.go
+++ b/backend/handler/user_admin_test.go
@@ -11,7 +11,6 @@ import (
"net/http"
"net/http/httptest"
"net/url"
- "strings"
"testing"
"time"
)
@@ -21,7 +20,6 @@ func TestUserHandlerAdmin_Delete(t *testing.T) {
users := []models.User{
{
ID: userId,
- Email: "john.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
@@ -35,7 +33,7 @@ func TestUserHandlerAdmin_Delete(t *testing.T) {
c.SetParamNames("id")
c.SetParamValues(userId.String())
- p := test.NewPersister(users, nil, nil, nil, nil, nil, nil)
+ p := test.NewPersister(users, nil, nil, nil, nil, nil, nil, nil, nil)
handler := NewUserHandlerAdmin(p)
if assert.NoError(t, handler.Delete(c)) {
@@ -52,7 +50,7 @@ func TestUserHandlerAdmin_Delete_InvalidUserId(t *testing.T) {
c.SetParamNames("id")
c.SetParamValues("invalidId")
- p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil)
+ p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil, nil, nil)
handler := NewUserHandlerAdmin(p)
err := handler.Delete(c)
@@ -72,7 +70,7 @@ func TestUserHandlerAdmin_Delete_UnknownUserId(t *testing.T) {
c.SetParamNames("id")
c.SetParamValues(userId.String())
- p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil)
+ p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil, nil, nil)
handler := NewUserHandlerAdmin(p)
err := handler.Delete(c)
@@ -82,174 +80,12 @@ func TestUserHandlerAdmin_Delete_UnknownUserId(t *testing.T) {
}
}
-func TestUserHandlerAdmin_Patch(t *testing.T) {
- userId, _ := uuid.NewV4()
- users := []models.User{
- {
- ID: userId,
- Email: "john.doe@example.com",
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- },
- }
-
- e := echo.New()
- e.Validator = dto.NewCustomValidator()
-
- req := httptest.NewRequest(http.MethodPatch, "/", strings.NewReader(`{"email": "jane.doe@example.com", "verified": true}`))
- req.Header.Set("Content-Type", "application/json")
- rec := httptest.NewRecorder()
- c := e.NewContext(req, rec)
- c.SetPath("/users/:id")
- c.SetParamNames("id")
- c.SetParamValues(userId.String())
-
- p := test.NewPersister(users, nil, nil, nil, nil, nil, nil)
- handler := NewUserHandlerAdmin(p)
-
- if assert.NoError(t, handler.Patch(c)) {
- assert.Equal(t, http.StatusOK, rec.Code)
- }
-}
-
-func TestUserHandlerAdmin_Patch_InvalidUserIdAndEmail(t *testing.T) {
- e := echo.New()
- e.Validator = dto.NewCustomValidator()
-
- req := httptest.NewRequest(http.MethodPatch, "/", strings.NewReader(`{"email": "invalidEmail"}`))
- req.Header.Set("Content-Type", "application/json")
- rec := httptest.NewRecorder()
- c := e.NewContext(req, rec)
- c.SetPath("/users/:id")
- c.SetParamNames("id")
- c.SetParamValues("invalidUserId")
-
- p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil)
- handler := NewUserHandlerAdmin(p)
-
- err := handler.Patch(c)
- if assert.Error(t, err) {
- httpError := dto.ToHttpError(err)
- assert.Equal(t, http.StatusBadRequest, httpError.Code)
- }
-}
-
-func TestUserHandlerAdmin_Patch_EmailNotAvailable(t *testing.T) {
- users := []models.User{
- func() models.User {
- userId, _ := uuid.NewV4()
- return models.User{
- ID: userId,
- Email: "john.doe@example.com",
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- }
- }(),
- func() models.User {
- userId, _ := uuid.NewV4()
- return models.User{
- ID: userId,
- Email: "jane.doe@example.com",
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- }
- }(),
- }
-
- e := echo.New()
- e.Validator = dto.NewCustomValidator()
-
- req := httptest.NewRequest(http.MethodPatch, "/", strings.NewReader(`{"email": "jane.doe@example.com"}`))
- req.Header.Set("Content-Type", "application/json")
- rec := httptest.NewRecorder()
- c := e.NewContext(req, rec)
- c.SetPath("/users/:id")
- c.SetParamNames("id")
- c.SetParamValues(users[0].ID.String())
-
- p := test.NewPersister(users, nil, nil, nil, nil, nil, nil)
- handler := NewUserHandlerAdmin(p)
-
- err := handler.Patch(c)
- if assert.Error(t, err) {
- httpError := dto.ToHttpError(err)
- assert.Equal(t, http.StatusBadRequest, httpError.Code)
- }
-}
-
-func TestUserHandlerAdmin_Patch_UnknownUserId(t *testing.T) {
- userId, _ := uuid.NewV4()
- users := []models.User{
- {
- ID: userId,
- Email: "john.doe@example.com",
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- },
- }
-
- e := echo.New()
- e.Validator = dto.NewCustomValidator()
-
- req := httptest.NewRequest(http.MethodPatch, "/", strings.NewReader(`{"email": "jane.doe@example.com", "verified": true}`))
- req.Header.Set("Content-Type", "application/json")
- rec := httptest.NewRecorder()
- c := e.NewContext(req, rec)
- c.SetPath("/users/:id")
- c.SetParamNames("id")
- unknownUserId, _ := uuid.NewV4()
- c.SetParamValues(unknownUserId.String())
-
- p := test.NewPersister(users, nil, nil, nil, nil, nil, nil)
- handler := NewUserHandlerAdmin(p)
-
- err := handler.Patch(c)
- if assert.Error(t, err) {
- httpError := dto.ToHttpError(err)
- assert.Equal(t, http.StatusNotFound, httpError.Code)
- }
-}
-
-func TestUserHandlerAdmin_Patch_InvalidJson(t *testing.T) {
- userId, _ := uuid.NewV4()
- users := []models.User{
- {
- ID: userId,
- Email: "john.doe@example.com",
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- },
- }
-
- e := echo.New()
- e.Validator = dto.NewCustomValidator()
-
- req := httptest.NewRequest(http.MethodPatch, "/", strings.NewReader(`"email: "jane.doe@example.com"}`))
- req.Header.Set("Content-Type", "application/json")
- rec := httptest.NewRecorder()
- c := e.NewContext(req, rec)
- c.SetPath("/users/:id")
- c.SetParamNames("id")
- unknownUserId, _ := uuid.NewV4()
- c.SetParamValues(unknownUserId.String())
-
- p := test.NewPersister(users, nil, nil, nil, nil, nil, nil)
- handler := NewUserHandlerAdmin(p)
-
- err := handler.Patch(c)
- if assert.Error(t, err) {
- httpError := dto.ToHttpError(err)
- assert.Equal(t, http.StatusBadRequest, httpError.Code)
- }
-}
-
func TestUserHandlerAdmin_List(t *testing.T) {
users := []models.User{
func() models.User {
userId, _ := uuid.NewV4()
return models.User{
ID: userId,
- Email: "john.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -258,7 +94,6 @@ func TestUserHandlerAdmin_List(t *testing.T) {
userId, _ := uuid.NewV4()
return models.User{
ID: userId,
- Email: "jane.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -271,7 +106,7 @@ func TestUserHandlerAdmin_List(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
- p := test.NewPersister(users, nil, nil, nil, nil, nil, nil)
+ p := test.NewPersister(users, nil, nil, nil, nil, nil, nil, nil, nil)
handler := NewUserHandlerAdmin(p)
if assert.NoError(t, handler.List(c)) {
@@ -291,7 +126,6 @@ func TestUserHandlerAdmin_List_Pagination(t *testing.T) {
userId, _ := uuid.NewV4()
return models.User{
ID: userId,
- Email: "john.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -300,7 +134,6 @@ func TestUserHandlerAdmin_List_Pagination(t *testing.T) {
userId, _ := uuid.NewV4()
return models.User{
ID: userId,
- Email: "jane.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -316,7 +149,7 @@ func TestUserHandlerAdmin_List_Pagination(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
- p := test.NewPersister(users, nil, nil, nil, nil, nil, nil)
+ p := test.NewPersister(users, nil, nil, nil, nil, nil, nil, nil, nil)
handler := NewUserHandlerAdmin(p)
if assert.NoError(t, handler.List(c)) {
@@ -340,7 +173,7 @@ func TestUserHandlerAdmin_List_NoUsers(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
- p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil)
+ p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil, nil, nil)
handler := NewUserHandlerAdmin(p)
if assert.NoError(t, handler.List(c)) {
@@ -363,7 +196,7 @@ func TestUserHandlerAdmin_List_InvalidPaginationParam(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
- p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil)
+ p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil, nil, nil)
handler := NewUserHandlerAdmin(p)
err := handler.List(c)
diff --git a/backend/handler/user_test.go b/backend/handler/user_test.go
index d3301e0dc..5ad178c2b 100644
--- a/backend/handler/user_test.go
+++ b/backend/handler/user_test.go
@@ -24,7 +24,6 @@ func TestUserHandler_Create(t *testing.T) {
func() models.User {
return models.User{
ID: userId,
- Email: "john.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -42,15 +41,14 @@ func TestUserHandler_Create(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
- p := test.NewPersister(users, nil, nil, nil, nil, nil, nil)
- handler := NewUserHandler(p, test.NewAuditLogger())
+ p := test.NewPersister(users, nil, nil, nil, nil, nil, nil, nil, nil)
+ handler := NewUserHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogger())
if assert.NoError(t, handler.Create(c)) {
user := models.User{}
err := json.Unmarshal(rec.Body.Bytes(), &user)
assert.NoError(t, err)
assert.False(t, user.ID.IsNil())
- assert.Equal(t, body.Email, user.Email)
}
}
@@ -60,7 +58,6 @@ func TestUserHandler_Create_CaseInsensitive(t *testing.T) {
func() models.User {
return models.User{
ID: userId,
- Email: "john.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -78,15 +75,14 @@ func TestUserHandler_Create_CaseInsensitive(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
- p := test.NewPersister(users, nil, nil, nil, nil, nil, nil)
- handler := NewUserHandler(p, test.NewAuditLogger())
+ p := test.NewPersister(users, nil, nil, nil, nil, nil, nil, nil, nil)
+ handler := NewUserHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogger())
if assert.NoError(t, handler.Create(c)) {
user := models.User{}
err := json.Unmarshal(rec.Body.Bytes(), &user)
assert.NoError(t, err)
assert.False(t, user.ID.IsNil())
- assert.Equal(t, strings.ToLower(body.Email), user.Email)
}
}
@@ -96,12 +92,16 @@ func TestUserHandler_Create_UserExists(t *testing.T) {
func() models.User {
return models.User{
ID: userId,
- Email: "john.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}(),
}
+ emails := []models.Email{{
+ ID: uuid.UUID{},
+ Address: "john.doe@example.com",
+ UserID: &userId,
+ }}
e := echo.New()
e.Validator = dto.NewCustomValidator()
@@ -113,8 +113,8 @@ func TestUserHandler_Create_UserExists(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
- p := test.NewPersister(users, nil, nil, nil, nil, nil, nil)
- handler := NewUserHandler(p, test.NewAuditLogger())
+ p := test.NewPersister(users, nil, nil, nil, nil, nil, nil, emails, nil)
+ handler := NewUserHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogger())
err = handler.Create(c)
if assert.Error(t, err) {
@@ -129,12 +129,16 @@ func TestUserHandler_Create_UserExists_CaseInsensitive(t *testing.T) {
func() models.User {
return models.User{
ID: userId,
- Email: "john.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}(),
}
+ emails := []models.Email{{
+ ID: uuid.UUID{},
+ Address: "john.doe@example.com",
+ UserID: &userId,
+ }}
e := echo.New()
e.Validator = dto.NewCustomValidator()
@@ -146,8 +150,8 @@ func TestUserHandler_Create_UserExists_CaseInsensitive(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
- p := test.NewPersister(users, nil, nil, nil, nil, nil, nil)
- handler := NewUserHandler(p, test.NewAuditLogger())
+ p := test.NewPersister(users, nil, nil, nil, nil, nil, nil, emails, nil)
+ handler := NewUserHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogger())
err = handler.Create(c)
if assert.Error(t, err) {
@@ -165,8 +169,8 @@ func TestUserHandler_Create_InvalidEmail(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
- p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil)
- handler := NewUserHandler(p, test.NewAuditLogger())
+ p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ handler := NewUserHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogger())
err := handler.Create(c)
if assert.Error(t, err) {
@@ -184,8 +188,8 @@ func TestUserHandler_Create_EmailMissing(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
- p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil)
- handler := NewUserHandler(p, test.NewAuditLogger())
+ p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ handler := NewUserHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogger())
err := handler.Create(c)
if assert.Error(t, err) {
@@ -200,7 +204,6 @@ func TestUserHandler_Get(t *testing.T) {
func() models.User {
return models.User{
ID: userId,
- Email: "john.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -220,8 +223,8 @@ func TestUserHandler_Get(t *testing.T) {
require.NoError(t, err)
c.Set("session", token)
- p := test.NewPersister(users, nil, nil, nil, nil, nil, nil)
- handler := NewUserHandler(p, test.NewAuditLogger())
+ p := test.NewPersister(users, nil, nil, nil, nil, nil, nil, nil, nil)
+ handler := NewUserHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogger())
if assert.NoError(t, handler.Get(c)) {
assert.Equal(t, rec.Code, http.StatusOK)
@@ -239,7 +242,6 @@ func TestUserHandler_GetUserWithWebAuthnCredential(t *testing.T) {
users := []models.User{
{
ID: userId,
- Email: "john.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
WebauthnCredentials: []models.WebauthnCredential{
@@ -270,8 +272,8 @@ func TestUserHandler_GetUserWithWebAuthnCredential(t *testing.T) {
require.NoError(t, err)
c.Set("session", token)
- p := test.NewPersister(users, nil, nil, nil, nil, nil, nil)
- handler := NewUserHandler(p, test.NewAuditLogger())
+ p := test.NewPersister(users, nil, nil, nil, nil, nil, nil, nil, nil)
+ handler := NewUserHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogger())
if assert.NoError(t, handler.Get(c)) {
assert.Equal(t, rec.Code, http.StatusOK)
@@ -295,8 +297,8 @@ func TestUserHandler_Get_InvalidUserId(t *testing.T) {
require.NoError(t, err)
c.Set("session", token)
- p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil)
- handler := NewUserHandler(p, test.NewAuditLogger())
+ p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ handler := NewUserHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogger())
err = handler.Get(c)
if assert.Error(t, err) {
@@ -313,8 +315,8 @@ func TestUserHandler_GetUserIdByEmail_InvalidEmail(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
- p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil)
- handler := NewUserHandler(p, test.NewAuditLogger())
+ p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ handler := NewUserHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogger())
err := handler.GetUserIdByEmail(c)
if assert.Error(t, err) {
@@ -330,8 +332,8 @@ func TestUserHandler_GetUserIdByEmail_InvalidJson(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
- p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil)
- handler := NewUserHandler(p, test.NewAuditLogger())
+ p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ handler := NewUserHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogger())
assert.Error(t, handler.GetUserIdByEmail(c))
}
@@ -344,8 +346,8 @@ func TestUserHandler_GetUserIdByEmail_UserNotFound(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
- p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil)
- handler := NewUserHandler(p, test.NewAuditLogger())
+ p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ handler := NewUserHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogger())
err := handler.GetUserIdByEmail(c)
if assert.Error(t, err) {
@@ -359,10 +361,15 @@ func TestUserHandler_GetUserIdByEmail(t *testing.T) {
users := []models.User{
{
ID: userId,
- Email: "john.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
- Verified: true,
+ },
+ }
+ emails := []models.Email{
+ {
+ UserID: &userId,
+ Address: "john.doe@example.com",
+ User: &users[0],
},
}
e := echo.New()
@@ -372,8 +379,8 @@ func TestUserHandler_GetUserIdByEmail(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
- p := test.NewPersister(users, nil, nil, nil, nil, nil, nil)
- handler := NewUserHandler(p, test.NewAuditLogger())
+ p := test.NewPersister(users, nil, nil, nil, nil, nil, nil, emails, nil)
+ handler := NewUserHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogger())
if assert.NoError(t, handler.GetUserIdByEmail(c)) {
assert.Equal(t, http.StatusOK, rec.Code)
@@ -384,7 +391,7 @@ func TestUserHandler_GetUserIdByEmail(t *testing.T) {
err := json.Unmarshal(rec.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, users[0].ID.String(), response.UserId)
- assert.Equal(t, users[0].Verified, response.Verified)
+ assert.Equal(t, emails[0].Verified, response.Verified)
}
}
@@ -393,10 +400,16 @@ func TestUserHandler_GetUserIdByEmail_CaseInsensitive(t *testing.T) {
users := []models.User{
{
ID: userId,
- Email: "john.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
- Verified: true,
+ },
+ }
+ emails := []models.Email{
+ {
+ UserID: &userId,
+ Address: "john.doe@example.com",
+ User: &users[0],
+ Verified: true,
},
}
e := echo.New()
@@ -406,8 +419,8 @@ func TestUserHandler_GetUserIdByEmail_CaseInsensitive(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
- p := test.NewPersister(users, nil, nil, nil, nil, nil, nil)
- handler := NewUserHandler(p, test.NewAuditLogger())
+ p := test.NewPersister(users, nil, nil, nil, nil, nil, nil, emails, nil)
+ handler := NewUserHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogger())
if assert.NoError(t, handler.GetUserIdByEmail(c)) {
assert.Equal(t, http.StatusOK, rec.Code)
@@ -418,7 +431,7 @@ func TestUserHandler_GetUserIdByEmail_CaseInsensitive(t *testing.T) {
err := json.Unmarshal(rec.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, users[0].ID.String(), response.UserId)
- assert.Equal(t, users[0].Verified, response.Verified)
+ assert.Equal(t, emails[0].Verified, response.Verified)
}
}
@@ -436,8 +449,8 @@ func TestUserHandler_Me(t *testing.T) {
require.NoError(t, err)
c.Set("session", token)
- p := test.NewPersister(users, nil, nil, nil, nil, nil, nil)
- handler := NewUserHandler(p, test.NewAuditLogger())
+ p := test.NewPersister(users, nil, nil, nil, nil, nil, nil, nil, nil)
+ handler := NewUserHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogger())
if assert.NoError(t, handler.Me(c)) {
assert.Equal(t, http.StatusOK, rec.Code)
diff --git a/backend/handler/webauthn.go b/backend/handler/webauthn.go
index deac9d169..fafbdfb24 100644
--- a/backend/handler/webauthn.go
+++ b/backend/handler/webauthn.go
@@ -392,6 +392,124 @@ func (h *WebauthnHandler) FinishAuthentication(c echo.Context) error {
})
}
+func (h *WebauthnHandler) ListCredentials(c echo.Context) error {
+ sessionToken, ok := c.Get("session").(jwt.Token)
+ if !ok {
+ return errors.New("failed to cast session object")
+ }
+
+ userId, err := uuid.FromString(sessionToken.Subject())
+ if err != nil {
+ return fmt.Errorf("failed to parse subject as uuid: %w", err)
+ }
+
+ credentials, err := h.persister.GetWebauthnCredentialPersister().GetFromUser(userId)
+ if err != nil {
+ return fmt.Errorf("failed to get webauthn credentials: %w", err)
+ }
+
+ response := make([]*dto.WebauthnCredentialResponse, len(credentials))
+
+ for i := range credentials {
+ response[i] = dto.FromWebauthnCredentialModel(&credentials[i])
+ }
+
+ return c.JSON(http.StatusOK, response)
+}
+
+func (h *WebauthnHandler) UpdateCredential(c echo.Context) error {
+ sessionToken, ok := c.Get("session").(jwt.Token)
+ if !ok {
+ return errors.New("failed to cast session object")
+ }
+
+ userId, err := uuid.FromString(sessionToken.Subject())
+ if err != nil {
+ return fmt.Errorf("failed to parse subject as uuid: %w", err)
+ }
+
+ credentialID := c.Param("id")
+
+ var body dto.WebauthnCredentialUpdateRequest
+
+ err = (&echo.DefaultBinder{}).BindBody(c, &body)
+ if err != nil {
+ return dto.ToHttpError(err)
+ }
+
+ user, err := h.persister.GetUserPersister().Get(userId)
+ if err != nil {
+ return fmt.Errorf("failed to get user: %w", err)
+ }
+
+ credential, err := h.persister.GetWebauthnCredentialPersister().Get(credentialID)
+ if err != nil {
+ return fmt.Errorf("failed to get webauthn credentials: %w", err)
+ }
+
+ if credential == nil || credential.UserId.String() != user.ID.String() {
+ return dto.NewHTTPError(http.StatusNotFound).SetInternal(errors.New("the user does not have a webauthn credential with the specified credentialId"))
+ }
+
+ if body.Name != nil {
+ credential.Name = body.Name
+ }
+
+ return h.persister.Transaction(func(tx *pop.Connection) error {
+ err = h.persister.GetWebauthnCredentialPersisterWithConnection(tx).Update(*credential)
+ if err != nil {
+ return fmt.Errorf("failed to update webauthn credential: %w", err)
+ }
+ err = h.auditLogger.Create(c, models.AuditLogWebAuthnCredentialUpdated, user, nil)
+ if err != nil {
+ return fmt.Errorf("failed to create audit log: %w", err)
+ }
+ return nil
+ })
+}
+
+func (h *WebauthnHandler) DeleteCredential(c echo.Context) error {
+ sessionToken, ok := c.Get("session").(jwt.Token)
+ if !ok {
+ return errors.New("failed to cast session object")
+ }
+
+ userId, err := uuid.FromString(sessionToken.Subject())
+ if err != nil {
+ return fmt.Errorf("failed to parse subject as uuid: %w", err)
+ }
+
+ user, err := h.persister.GetUserPersister().Get(userId)
+ if err != nil {
+ return fmt.Errorf("failed to fetch user from db: %w", err)
+ }
+
+ credentialId := c.Param("id")
+
+ credential, err := h.persister.GetWebauthnCredentialPersister().Get(credentialId)
+ if err != nil {
+ return fmt.Errorf("failed to get webauthn credential: %w", err)
+ }
+
+ if credential == nil || credential.UserId.String() != user.ID.String() {
+ return dto.NewHTTPError(http.StatusNotFound).SetInternal(errors.New("the user does not have a webauthn credential with the specified credentialId"))
+ }
+
+ return h.persister.Transaction(func(tx *pop.Connection) error {
+ err = h.persister.GetWebauthnCredentialPersisterWithConnection(tx).Delete(*credential)
+ if err != nil {
+ return fmt.Errorf("failed to delete credential from db: %w", err)
+ }
+
+ err = h.auditLogger.Create(c, models.AuditLogWebAuthnCredentialDeleted, user, nil)
+ if err != nil {
+ return fmt.Errorf("failed to create audit log: %w", err)
+ }
+
+ return c.NoContent(http.StatusNoContent)
+ })
+}
+
func (h WebauthnHandler) getWebauthnUser(connection *pop.Connection, userId uuid.UUID) (*intern.WebauthnUser, *models.User, error) {
user, err := h.persister.GetUserPersisterWithConnection(connection).Get(userId)
if err != nil {
@@ -407,5 +525,9 @@ func (h WebauthnHandler) getWebauthnUser(connection *pop.Connection, userId uuid
return nil, nil, fmt.Errorf("failed to get webauthn credentials: %w", err)
}
- return intern.NewWebauthnUser(*user, credentials), user, nil
+ webauthnUser, err := intern.NewWebauthnUser(*user, credentials)
+ if err != nil {
+ return nil, nil, err
+ }
+ return webauthnUser, user, nil
}
diff --git a/backend/handler/webauthn_test.go b/backend/handler/webauthn_test.go
index 49b7a9cdf..75c3ae5c5 100644
--- a/backend/handler/webauthn_test.go
+++ b/backend/handler/webauthn_test.go
@@ -23,7 +23,7 @@ var userId = "ec4ef049-5b88-4321-a173-21b0eff06a04"
var userIdBytes = []byte{0xec, 0x4e, 0xf0, 0x49, 0x5b, 0x88, 0x43, 0x21, 0xa1, 0x73, 0x21, 0xb0, 0xef, 0xf0, 0x6a, 0x4}
func TestNewWebauthnHandler(t *testing.T) {
- p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil)
+ p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil, nil, nil)
handler, err := NewWebauthnHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogger())
assert.NoError(t, err)
assert.NotEmpty(t, handler)
@@ -39,7 +39,7 @@ func TestWebauthnHandler_BeginRegistration(t *testing.T) {
require.NoError(t, err)
c.Set("session", token)
- p := test.NewPersister(users, nil, nil, credentials, sessionData, nil, nil)
+ p := test.NewPersister(users, nil, nil, credentials, sessionData, nil, nil, nil, nil)
handler, err := NewWebauthnHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogger())
require.NoError(t, err)
@@ -75,7 +75,7 @@ func TestWebauthnHandler_FinishRegistration(t *testing.T) {
require.NoError(t, err)
c.Set("session", token)
- p := test.NewPersister(users, nil, nil, nil, sessionData, nil, nil)
+ p := test.NewPersister(users, nil, nil, nil, sessionData, nil, nil, nil, nil)
handler, err := NewWebauthnHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogger())
require.NoError(t, err)
@@ -106,7 +106,7 @@ func TestWebauthnHandler_BeginAuthentication(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
- p := test.NewPersister(users, nil, nil, nil, sessionData, nil, nil)
+ p := test.NewPersister(users, nil, nil, nil, sessionData, nil, nil, nil, nil)
handler, err := NewWebauthnHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogger())
require.NoError(t, err)
@@ -138,7 +138,7 @@ func TestWebauthnHandler_FinishAuthentication(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
- p := test.NewPersister(users, nil, nil, credentials, sessionData, nil, nil)
+ p := test.NewPersister(users, nil, nil, credentials, sessionData, nil, nil, nil, nil)
handler, err := NewWebauthnHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogger())
require.NoError(t, err)
@@ -260,14 +260,23 @@ var sessionData = []models.WebauthnSessionData{
}(),
}
+var uId, _ = uuid.FromString(userId)
+
+var emails = []models.Email{
+ {
+ ID: uId,
+ Address: "john.doe@example.com",
+ PrimaryEmail: &models.PrimaryEmail{ID: uId},
+ },
+}
+
var users = []models.User{
func() models.User {
- uId, _ := uuid.FromString(userId)
return models.User{
ID: uId,
- Email: "john.doe@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
+ Emails: emails,
}
}(),
}
diff --git a/backend/persistence/email_persister.go b/backend/persistence/email_persister.go
new file mode 100644
index 000000000..1ac776933
--- /dev/null
+++ b/backend/persistence/email_persister.go
@@ -0,0 +1,126 @@
+package persistence
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "github.com/gobuffalo/pop/v6"
+ "github.com/gofrs/uuid"
+ "github.com/teamhanko/hanko/backend/persistence/models"
+)
+
+type EmailPersister interface {
+ Get(emailId uuid.UUID) (*models.Email, error)
+ CountByUserId(uuid.UUID) (int, error)
+ FindByUserId(uuid.UUID) (models.Emails, error)
+ FindByAddress(string) (*models.Email, error)
+ Create(models.Email) error
+ Update(models.Email) error
+ Delete(models.Email) error
+}
+
+type emailPersister struct {
+ db *pop.Connection
+}
+
+func NewEmailPersister(db *pop.Connection) EmailPersister {
+ return &emailPersister{db: db}
+}
+
+func (e *emailPersister) Get(emailId uuid.UUID) (*models.Email, error) {
+ email := models.Email{}
+ err := e.db.Find(&email, emailId.String())
+ if err != nil && errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ return &email, nil
+}
+
+func (e *emailPersister) FindByUserId(userId uuid.UUID) (models.Emails, error) {
+ var emails models.Emails
+
+ err := e.db.EagerPreload().
+ Where("user_id = ?", userId.String()).
+ Order("created_at asc").
+ All(&emails)
+ if err != nil && errors.Is(err, sql.ErrNoRows) {
+ return emails, nil
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ return emails, nil
+}
+
+func (e *emailPersister) CountByUserId(userId uuid.UUID) (int, error) {
+ var emails []models.Email
+
+ count, err := e.db.
+ Where("user_id = ?", userId.String()).
+ Count(&emails)
+
+ if err != nil {
+ return 0, err
+ }
+
+ return count, nil
+}
+
+func (e *emailPersister) FindByAddress(address string) (*models.Email, error) {
+ var email models.Email
+
+ query := e.db.EagerPreload().Where("address = ?", address)
+ err := query.First(&email)
+
+ if err != nil && errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ return &email, nil
+}
+
+func (e *emailPersister) Create(email models.Email) error {
+ vErr, err := e.db.ValidateAndCreate(&email)
+ if err != nil {
+ return err
+ }
+
+ if vErr != nil && vErr.HasAny() {
+ return fmt.Errorf("email object validation failed: %w", vErr)
+ }
+
+ return nil
+}
+
+func (e *emailPersister) Update(email models.Email) error {
+ vErr, err := e.db.ValidateAndUpdate(&email)
+ if err != nil {
+ return err
+ }
+
+ if vErr != nil && vErr.HasAny() {
+ return fmt.Errorf("email object validation failed: %w", vErr)
+ }
+
+ return nil
+}
+
+func (e *emailPersister) Delete(email models.Email) error {
+ err := e.db.Destroy(&email)
+ if err != nil {
+ return fmt.Errorf("failed to delete email: %w", err)
+ }
+
+ return nil
+}
diff --git a/backend/persistence/migrations/20221027104800_create_emails.down.fizz b/backend/persistence/migrations/20221027104800_create_emails.down.fizz
new file mode 100644
index 000000000..38a435132
--- /dev/null
+++ b/backend/persistence/migrations/20221027104800_create_emails.down.fizz
@@ -0,0 +1,2 @@
+drop_table("primary_emails")
+drop_table("emails")
diff --git a/backend/persistence/migrations/20221027104800_create_emails.up.fizz b/backend/persistence/migrations/20221027104800_create_emails.up.fizz
new file mode 100644
index 000000000..5700b8f22
--- /dev/null
+++ b/backend/persistence/migrations/20221027104800_create_emails.up.fizz
@@ -0,0 +1,27 @@
+create_table("emails") {
+ t.Column("id", "uuid")
+ t.Column("user_id", "uuid", { "null": true })
+ t.Column("address", "string")
+ t.Column("verified", "bool")
+ t.PrimaryKey("id")
+ t.Index("address", { "unique": true })
+ t.ForeignKey("user_id", {"users": ["id"]}, {"on_delete": "cascade", "on_update": "cascade"})
+}
+
+create_table("primary_emails") {
+ t.Column("id", "uuid")
+ t.Column("email_id", "uuid")
+ t.Column("user_id", "uuid")
+ t.PrimaryKey("id")
+ t.Index("email_id", { "unique": true })
+ t.Index("user_id", { "unique": true })
+ t.ForeignKey("email_id", {"emails": ["id"]}, {"on_delete": "restrict", "on_update": "cascade"})
+ t.ForeignKey("user_id", {"users": ["id"]}, {"on_delete": "cascade", "on_update": "cascade"})
+}
+
+sql("INSERT INTO emails (id, user_id, address, verified, created_at, updated_at)
+SELECT id, id, email, verified, created_at, updated_at
+FROM users")
+
+sql("INSERT INTO primary_emails (id, email_id, user_id, created_at, updated_at)
+SELECT id, id, user_id, created_at, updated_at FROM emails")
diff --git a/backend/persistence/migrations/20221027104900_change_users.down.fizz b/backend/persistence/migrations/20221027104900_change_users.down.fizz
new file mode 100644
index 000000000..c02256cef
--- /dev/null
+++ b/backend/persistence/migrations/20221027104900_change_users.down.fizz
@@ -0,0 +1,22 @@
+add_column("users", "email", "string", { "null": true })
+add_column("users", "verified", "bool", { "null": true })
+
+sql("
+UPDATE users u
+SET email = (
+ SELECT e.address
+ FROM emails e
+ JOIN primary_emails pe
+ ON e.id = pe.email_id AND e.user_id = u.id
+ LIMIT 1
+),
+ verified = (
+ SELECT e.verified
+ FROM emails e
+ JOIN primary_emails pe
+ ON e.id = pe.email_id AND e.user_id = u.id
+ LIMIT 1
+)")
+
+change_column("users", "email", "string", { "null": false, "unique": true })
+change_column("users", "verified", "bool", { "null": false })
diff --git a/backend/persistence/migrations/20221027104900_change_users.up.fizz b/backend/persistence/migrations/20221027104900_change_users.up.fizz
new file mode 100644
index 000000000..3f16ef472
--- /dev/null
+++ b/backend/persistence/migrations/20221027104900_change_users.up.fizz
@@ -0,0 +1,3 @@
+drop_column("users", "email")
+drop_column("users", "verified")
+
diff --git a/backend/persistence/migrations/20221027123530_change_passcodes.down.fizz b/backend/persistence/migrations/20221027123530_change_passcodes.down.fizz
new file mode 100644
index 000000000..c12e7456d
--- /dev/null
+++ b/backend/persistence/migrations/20221027123530_change_passcodes.down.fizz
@@ -0,0 +1 @@
+drop_column("passcodes", "email_id")
diff --git a/backend/persistence/migrations/20221027123530_change_passcodes.up.fizz b/backend/persistence/migrations/20221027123530_change_passcodes.up.fizz
new file mode 100644
index 000000000..1b020ad4c
--- /dev/null
+++ b/backend/persistence/migrations/20221027123530_change_passcodes.up.fizz
@@ -0,0 +1,5 @@
+add_column("passcodes", "email_id", "uuid", { "null": true })
+add_foreign_key("passcodes", "email_id", {"emails": ["id"]}, {
+ "on_delete": "cascade",
+ "on_update": "cascade",
+})
diff --git a/backend/persistence/migrations/20221222134900_change_webauthn_credentials.down.fizz b/backend/persistence/migrations/20221222134900_change_webauthn_credentials.down.fizz
new file mode 100644
index 000000000..cde417f5a
--- /dev/null
+++ b/backend/persistence/migrations/20221222134900_change_webauthn_credentials.down.fizz
@@ -0,0 +1 @@
+drop_column("webauthn_credentials", "name")
diff --git a/backend/persistence/migrations/20221222134900_change_webauthn_credentials.up.fizz b/backend/persistence/migrations/20221222134900_change_webauthn_credentials.up.fizz
new file mode 100644
index 000000000..4123dcd2f
--- /dev/null
+++ b/backend/persistence/migrations/20221222134900_change_webauthn_credentials.up.fizz
@@ -0,0 +1 @@
+add_column("webauthn_credentials", "name", "string", { "null": true })
diff --git a/backend/persistence/models/audit_log.go b/backend/persistence/models/audit_log.go
index 86905a8ca..4c9e7ce1c 100644
--- a/backend/persistence/models/audit_log.go
+++ b/backend/persistence/models/audit_log.go
@@ -43,4 +43,11 @@ var (
AuditLogWebAuthnAuthenticationInitFailed AuditLogType = "webauthn_authentication_init_failed"
AuditLogWebAuthnAuthenticationFinalSucceeded AuditLogType = "webauthn_authentication_final_succeeded"
AuditLogWebAuthnAuthenticationFinalFailed AuditLogType = "webauthn_authentication_final_failed"
+ AuditLogWebAuthnCredentialUpdated AuditLogType = "webauthn_credential_updated"
+ AuditLogWebAuthnCredentialDeleted AuditLogType = "webauthn_credential_deleted"
+
+ AuditLogEmailCreated AuditLogType = "email_created"
+ AuditLogEmailDeleted AuditLogType = "email_deleted"
+ AuditLogEmailVerified AuditLogType = "email_verified"
+ AuditLogPrimaryEmailChanged AuditLogType = "primary_email_changed"
)
diff --git a/backend/persistence/models/email.go b/backend/persistence/models/email.go
new file mode 100644
index 000000000..e844bdf86
--- /dev/null
+++ b/backend/persistence/models/email.go
@@ -0,0 +1,83 @@
+package models
+
+import (
+ "github.com/gobuffalo/pop/v6"
+ "github.com/gobuffalo/validate/v3"
+ "github.com/gobuffalo/validate/v3/validators"
+ "github.com/gofrs/uuid"
+ "time"
+)
+
+// Email is used by pop to map your users database table to your go code.
+type Email struct {
+ ID uuid.UUID `db:"id" json:"id"`
+ UserID *uuid.UUID `db:"user_id" json:"user_id,omitempty"`
+ Address string `db:"address" json:"address"`
+ Verified bool `db:"verified" json:"verified"`
+ PrimaryEmail *PrimaryEmail `has_one:"primary_emails" json:"primary_emails,omitempty"`
+ User *User `belongs_to:"user" json:"user,omitempty"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+}
+
+type Emails []Email
+
+func NewEmail(userId *uuid.UUID, address string) *Email {
+ id, _ := uuid.NewV4()
+ return &Email{
+ ID: id,
+ Address: address,
+ UserID: userId,
+ Verified: false,
+ PrimaryEmail: nil,
+ User: nil,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+}
+
+func (email *Email) IsPrimary() bool {
+ if email.PrimaryEmail != nil && !email.PrimaryEmail.ID.IsNil() {
+ return true
+ }
+ return false
+}
+
+func (emails Emails) GetVerified() Emails {
+ var list Emails
+ for _, email := range emails {
+ if email.Verified {
+ list = append(list, email)
+ }
+ }
+ return list
+}
+
+func (emails Emails) GetPrimary() *Email {
+ for _, email := range emails {
+ if email.IsPrimary() {
+ return &email
+ }
+ }
+ return nil
+}
+
+func (emails Emails) SetPrimary(primary *PrimaryEmail) {
+ for i := range emails {
+ if emails[i].ID.String() == primary.EmailID.String() {
+ emails[i].PrimaryEmail = primary
+ return
+ }
+ }
+ return
+}
+
+// Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method.
+func (email *Email) Validate(tx *pop.Connection) (*validate.Errors, error) {
+ return validate.Validate(
+ &validators.UUIDIsPresent{Name: "ID", Field: email.ID},
+ &validators.EmailLike{Name: "Address", Field: email.Address},
+ &validators.TimeIsPresent{Name: "UpdatedAt", Field: email.UpdatedAt},
+ &validators.TimeIsPresent{Name: "CreatedAt", Field: email.CreatedAt},
+ ), nil
+}
diff --git a/backend/persistence/models/passcode.go b/backend/persistence/models/passcode.go
index ebe9d2b75..a4e29f4c9 100644
--- a/backend/persistence/models/passcode.go
+++ b/backend/persistence/models/passcode.go
@@ -12,11 +12,13 @@ import (
type Passcode struct {
ID uuid.UUID `db:"id"`
UserId uuid.UUID `db:"user_id"`
+ EmailID uuid.UUID `db:"email_id"`
Ttl int `db:"ttl"` // in seconds
Code string `db:"code"`
TryCount int `db:"try_count"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
+ Email Email `belongs_to:"email"`
}
// Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method.
diff --git a/backend/persistence/models/primary_email.go b/backend/persistence/models/primary_email.go
new file mode 100644
index 000000000..78f9db5d1
--- /dev/null
+++ b/backend/persistence/models/primary_email.go
@@ -0,0 +1,42 @@
+package models
+
+import (
+ "github.com/gobuffalo/pop/v6"
+ "github.com/gobuffalo/validate/v3"
+ "github.com/gobuffalo/validate/v3/validators"
+ "github.com/gofrs/uuid"
+ "time"
+)
+
+type PrimaryEmail struct {
+ ID uuid.UUID `db:"id" json:"id"`
+ EmailID uuid.UUID `db:"email_id" json:"email_id"`
+ UserID uuid.UUID `db:"user_id" json:"-"`
+ Email *Email `belongs_to:"email" json:"email"`
+ User *User `belongs_to:"user" json:"-"`
+ CreatedAt time.Time `db:"created_at" json:"-"`
+ UpdatedAt time.Time `db:"updated_at" json:"-"`
+}
+
+func NewPrimaryEmail(emailId uuid.UUID, userId uuid.UUID) *PrimaryEmail {
+ id, _ := uuid.NewV4()
+
+ return &PrimaryEmail{
+ ID: id,
+ EmailID: emailId,
+ UserID: userId,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+}
+
+// Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method.
+func (primaryEmail *PrimaryEmail) Validate(tx *pop.Connection) (*validate.Errors, error) {
+ return validate.Validate(
+ &validators.UUIDIsPresent{Name: "ID", Field: primaryEmail.ID},
+ &validators.UUIDIsPresent{Name: "EmailID", Field: primaryEmail.EmailID},
+ &validators.UUIDIsPresent{Name: "UserID", Field: primaryEmail.UserID},
+ &validators.TimeIsPresent{Name: "UpdatedAt", Field: primaryEmail.UpdatedAt},
+ &validators.TimeIsPresent{Name: "CreatedAt", Field: primaryEmail.CreatedAt},
+ ), nil
+}
diff --git a/backend/persistence/models/user.go b/backend/persistence/models/user.go
index 33f99aa9c..01c07454a 100644
--- a/backend/persistence/models/user.go
+++ b/backend/persistence/models/user.go
@@ -11,29 +11,34 @@ import (
// User is used by pop to map your users database table to your go code.
type User struct {
ID uuid.UUID `db:"id" json:"id"`
- Email string `db:"email" json:"email"`
- Verified bool `db:"verified" json:"verified"`
WebauthnCredentials []WebauthnCredential `has_many:"webauthn_credentials" json:"webauthn_credentials,omitempty"`
+ Emails Emails `has_many:"emails" json:"-"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
-func NewUser(email string) User {
+func NewUser() User {
id, _ := uuid.NewV4()
return User{
ID: id,
- Email: email,
- Verified: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}
+func (user *User) GetEmailById(emailId uuid.UUID) *Email {
+ for _, email := range user.Emails {
+ if email.ID.String() == emailId.String() {
+ return &email
+ }
+ }
+ return nil
+}
+
// Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method.
func (user *User) Validate(tx *pop.Connection) (*validate.Errors, error) {
return validate.Validate(
&validators.UUIDIsPresent{Name: "ID", Field: user.ID},
- &validators.EmailLike{Name: "Email", Field: user.Email},
&validators.TimeIsPresent{Name: "UpdatedAt", Field: user.UpdatedAt},
&validators.TimeIsPresent{Name: "CreatedAt", Field: user.CreatedAt},
), nil
diff --git a/backend/persistence/models/webauthn_credential.go b/backend/persistence/models/webauthn_credential.go
index 00aa53497..e0184b8c9 100644
--- a/backend/persistence/models/webauthn_credential.go
+++ b/backend/persistence/models/webauthn_credential.go
@@ -10,15 +10,16 @@ import (
// WebauthnCredential is used by pop to map your webauthn_credentials database table to your go code.
type WebauthnCredential struct {
- ID string `db:"id" json:"id"`
- UserId uuid.UUID `db:"user_id" json:"-"`
- PublicKey string `db:"public_key" json:"-"`
- AttestationType string `db:"attestation_type" json:"-"`
- AAGUID uuid.UUID `db:"aaguid" json:"-"`
- SignCount int `db:"sign_count" json:"-"`
- CreatedAt time.Time `db:"created_at" json:"-"`
- UpdatedAt time.Time `db:"updated_at" json:"-"`
- Transports []WebauthnCredentialTransport `has_many:"webauthn_credential_transports" json:"-"`
+ ID string `db:"id" json:"id"`
+ Name *string `db:"name" json:"-"`
+ UserId uuid.UUID `db:"user_id" json:"-"`
+ PublicKey string `db:"public_key" json:"-"`
+ AttestationType string `db:"attestation_type" json:"-"`
+ AAGUID uuid.UUID `db:"aaguid" json:"-"`
+ SignCount int `db:"sign_count" json:"-"`
+ CreatedAt time.Time `db:"created_at" json:"-"`
+ UpdatedAt time.Time `db:"updated_at" json:"-"`
+ Transports Transports `has_many:"webauthn_credential_transports" json:"-"`
}
// Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method.
diff --git a/backend/persistence/models/webauthn_credential_transport.go b/backend/persistence/models/webauthn_credential_transport.go
index c9c311006..1a931173d 100644
--- a/backend/persistence/models/webauthn_credential_transport.go
+++ b/backend/persistence/models/webauthn_credential_transport.go
@@ -15,6 +15,16 @@ type WebauthnCredentialTransport struct {
WebauthnCredential *WebauthnCredential `belongs_to:"webauthn_credential"`
}
+type Transports []WebauthnCredentialTransport
+
+func (transports Transports) GetNames() []string {
+ names := make([]string, len(transports))
+ for i, t := range transports {
+ names[i] = t.Name
+ }
+ return names
+}
+
// Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method.
func (transport *WebauthnCredentialTransport) Validate(tx *pop.Connection) (*validate.Errors, error) {
return validate.Validate(
diff --git a/backend/persistence/passcode_persister.go b/backend/persistence/passcode_persister.go
index a3dfeb313..80014d57e 100644
--- a/backend/persistence/passcode_persister.go
+++ b/backend/persistence/passcode_persister.go
@@ -26,7 +26,7 @@ func NewPasscodePersister(db *pop.Connection) PasscodePersister {
func (p *passcodePersister) Get(id uuid.UUID) (*models.Passcode, error) {
passcode := models.Passcode{}
- err := p.db.Find(&passcode, id)
+ err := p.db.EagerPreload().Find(&passcode, id)
if err != nil && errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
diff --git a/backend/persistence/persister.go b/backend/persistence/persister.go
index c4f0e8f33..9f2c63c80 100644
--- a/backend/persistence/persister.go
+++ b/backend/persistence/persister.go
@@ -31,6 +31,10 @@ type Persister interface {
GetJwkPersisterWithConnection(tx *pop.Connection) JwkPersister
GetAuditLogPersister() AuditLogPersister
GetAuditLogPersisterWithConnection(tx *pop.Connection) AuditLogPersister
+ GetEmailPersister() EmailPersister
+ GetEmailPersisterWithConnection(tx *pop.Connection) EmailPersister
+ GetPrimaryEmailPersister() PrimaryEmailPersister
+ GetPrimaryEmailPersisterWithConnection(tx *pop.Connection) PrimaryEmailPersister
}
type Migrator interface {
@@ -161,6 +165,22 @@ func (p *persister) GetAuditLogPersisterWithConnection(tx *pop.Connection) Audit
return NewAuditLogPersister(tx)
}
+func (p *persister) GetEmailPersister() EmailPersister {
+ return NewEmailPersister(p.DB)
+}
+
+func (p *persister) GetEmailPersisterWithConnection(tx *pop.Connection) EmailPersister {
+ return NewEmailPersister(tx)
+}
+
+func (p *persister) GetPrimaryEmailPersister() PrimaryEmailPersister {
+ return NewPrimaryEmailPersister(p.DB)
+}
+
+func (p *persister) GetPrimaryEmailPersisterWithConnection(tx *pop.Connection) PrimaryEmailPersister {
+ return NewPrimaryEmailPersister(tx)
+}
+
func (p *persister) Transaction(fn func(tx *pop.Connection) error) error {
return p.DB.Transaction(fn)
}
diff --git a/backend/persistence/primary_email_persister.go b/backend/persistence/primary_email_persister.go
new file mode 100644
index 000000000..766b7819a
--- /dev/null
+++ b/backend/persistence/primary_email_persister.go
@@ -0,0 +1,46 @@
+package persistence
+
+import (
+ "fmt"
+ "github.com/gobuffalo/pop/v6"
+ "github.com/teamhanko/hanko/backend/persistence/models"
+)
+
+type PrimaryEmailPersister interface {
+ Create(models.PrimaryEmail) error
+ Update(models.PrimaryEmail) error
+}
+
+type primaryEmailPersister struct {
+ db *pop.Connection
+}
+
+func NewPrimaryEmailPersister(db *pop.Connection) PrimaryEmailPersister {
+ return &primaryEmailPersister{db: db}
+}
+
+func (p *primaryEmailPersister) Create(primaryEmail models.PrimaryEmail) error {
+ vErr, err := p.db.ValidateAndCreate(&primaryEmail)
+ if err != nil {
+ return err
+ }
+
+ if vErr != nil && vErr.HasAny() {
+ return fmt.Errorf("primary email object validation failed: %w", vErr)
+ }
+
+ return nil
+}
+
+func (p *primaryEmailPersister) Update(primaryEmail models.PrimaryEmail) error {
+ vErr, err := p.db.ValidateAndSave(&primaryEmail)
+ if err != nil {
+ return err
+ }
+
+ if vErr != nil && vErr.HasAny() {
+ return fmt.Errorf("primary email object validation failed: %w", vErr)
+ }
+
+ return nil
+}
diff --git a/backend/persistence/user_persister.go b/backend/persistence/user_persister.go
index cf3fbc415..d586e829f 100644
--- a/backend/persistence/user_persister.go
+++ b/backend/persistence/user_persister.go
@@ -11,7 +11,6 @@ import (
type UserPersister interface {
Get(uuid.UUID) (*models.User, error)
- GetByEmail(email string) (*models.User, error)
Create(models.User) error
Update(models.User) error
Delete(models.User) error
@@ -29,7 +28,7 @@ func NewUserPersister(db *pop.Connection) UserPersister {
func (p *userPersister) Get(id uuid.UUID) (*models.User, error) {
user := models.User{}
- err := p.db.Eager().Find(&user, id)
+ err := p.db.EagerPreload("Emails", "Emails.PrimaryEmail", "WebauthnCredentials").Find(&user, id)
if err != nil && errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -42,8 +41,7 @@ func (p *userPersister) Get(id uuid.UUID) (*models.User, error) {
func (p *userPersister) GetByEmail(email string) (*models.User, error) {
user := models.User{}
- query := p.db.Eager().Where("email = (?)", email)
- err := query.First(&user)
+ err := p.db.Eager().Where("email = (?)", email).First(&user)
if err != nil && errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
diff --git a/backend/persistence/webauthn_credential_persister.go b/backend/persistence/webauthn_credential_persister.go
index 1fbd517e1..fac9663e0 100644
--- a/backend/persistence/webauthn_credential_persister.go
+++ b/backend/persistence/webauthn_credential_persister.go
@@ -88,9 +88,9 @@ func (p *webauthnCredentialPersister) Delete(credential models.WebauthnCredentia
func (p *webauthnCredentialPersister) GetFromUser(userId uuid.UUID) ([]models.WebauthnCredential, error) {
var credentials []models.WebauthnCredential
- err := p.db.Eager().Where("user_id = ?", &userId).All(&credentials)
+ err := p.db.Eager().Where("user_id = ?", &userId).Order("created_at asc").All(&credentials)
if err != nil && errors.Is(err, sql.ErrNoRows) {
- return nil, nil
+ return credentials, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get credentials: %w", err)
diff --git a/backend/server/admin_router.go b/backend/server/admin_router.go
index 8520542d8..f5baab655 100644
--- a/backend/server/admin_router.go
+++ b/backend/server/admin_router.go
@@ -28,7 +28,6 @@ func NewAdminRouter(persister persistence.Persister) *echo.Echo {
user := e.Group("/users")
user.DELETE("/:id", userHandler.Delete)
- user.PATCH("/:id", userHandler.Patch)
user.GET("", userHandler.List)
auditLogHandler := handler.NewAuditLogHandler(persister)
diff --git a/backend/server/middleware/session.go b/backend/server/middleware/session.go
index 7ef665088..a80f93ad7 100644
--- a/backend/server/middleware/session.go
+++ b/backend/server/middleware/session.go
@@ -3,7 +3,9 @@ package middleware
import (
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
+ "github.com/teamhanko/hanko/backend/dto"
"github.com/teamhanko/hanko/backend/session"
+ "net/http"
)
// Session is a convenience function to create a middleware.JWT with custom JWT verification
@@ -13,6 +15,9 @@ func Session(generator session.Manager) echo.MiddlewareFunc {
TokenLookup: "header:Authorization,cookie:hanko",
AuthScheme: "Bearer",
ParseTokenFunc: parseToken(generator),
+ ErrorHandler: func(err error) error {
+ return dto.NewHTTPError(http.StatusUnauthorized).SetInternal(err)
+ },
}
return middleware.JWTWithConfig(c)
}
diff --git a/backend/server/public_router.go b/backend/server/public_router.go
index 9e5d72179..56b959143 100644
--- a/backend/server/public_router.go
+++ b/backend/server/public_router.go
@@ -60,7 +60,7 @@ func NewPublicRouter(cfg *config.Config, persister persistence.Persister) *echo.
password.POST("/login", passwordHandler.Login)
}
- userHandler := handler.NewUserHandler(persister, auditLogger)
+ userHandler := handler.NewUserHandler(cfg, persister, sessionManager, auditLogger)
e.GET("/me", userHandler.Me, hankoMiddleware.Session(sessionManager))
@@ -92,6 +92,11 @@ func NewPublicRouter(cfg *config.Config, persister persistence.Persister) *echo.
wellKnown.GET("/jwks.json", wellKnownHandler.GetPublicKeys)
wellKnown.GET("/config", wellKnownHandler.GetConfig)
+ emailHandler, err := handler.NewEmailHandler(cfg, persister, sessionManager, auditLogger)
+ if err != nil {
+ panic(fmt.Errorf("failed to create public email handler: %w", err))
+ }
+
webauthn := e.Group("/webauthn")
webauthnRegistration := webauthn.Group("/registration", hankoMiddleware.Session(sessionManager))
webauthnRegistration.POST("/initialize", webauthnHandler.BeginRegistration)
@@ -101,10 +106,21 @@ func NewPublicRouter(cfg *config.Config, persister persistence.Persister) *echo.
webauthnLogin.POST("/initialize", webauthnHandler.BeginAuthentication)
webauthnLogin.POST("/finalize", webauthnHandler.FinishAuthentication)
+ webauthnCredentials := webauthn.Group("/credentials", hankoMiddleware.Session(sessionManager))
+ webauthnCredentials.GET("", webauthnHandler.ListCredentials)
+ webauthnCredentials.PATCH("/:id", webauthnHandler.UpdateCredential)
+ webauthnCredentials.DELETE("/:id", webauthnHandler.DeleteCredential)
+
passcode := e.Group("/passcode")
passcodeLogin := passcode.Group("/login")
passcodeLogin.POST("/initialize", passcodeHandler.Init)
passcodeLogin.POST("/finalize", passcodeHandler.Finish)
+ email := e.Group("/emails", hankoMiddleware.Session(sessionManager))
+ email.GET("", emailHandler.List)
+ email.POST("", emailHandler.Create)
+ email.DELETE("/:id", emailHandler.Delete)
+ email.POST("/:id/set_primary", emailHandler.SetPrimaryEmail)
+
return e
}
diff --git a/backend/test/email_persister.go b/backend/test/email_persister.go
new file mode 100644
index 000000000..174516bda
--- /dev/null
+++ b/backend/test/email_persister.go
@@ -0,0 +1,81 @@
+package test
+
+import (
+ "github.com/gofrs/uuid"
+ "github.com/teamhanko/hanko/backend/persistence"
+ "github.com/teamhanko/hanko/backend/persistence/models"
+)
+
+func NewEmailPersister(init []models.Email) persistence.EmailPersister {
+ return &emailPersister{append([]models.Email{}, init...)}
+}
+
+type emailPersister struct {
+ emails []models.Email
+}
+
+func (e *emailPersister) Get(emailId uuid.UUID) (*models.Email, error) {
+ for _, email := range e.emails {
+ if email.ID.String() == emailId.String() {
+ return &email, nil
+ }
+ }
+ return nil, nil
+}
+
+func (e *emailPersister) FindByUserId(userId uuid.UUID) (models.Emails, error) {
+ var emails []models.Email
+ for _, email := range e.emails {
+ if email.UserID.String() == userId.String() {
+ emails = append(emails, email)
+ }
+ }
+ return emails, nil
+}
+
+func (e *emailPersister) FindByAddress(address string) (*models.Email, error) {
+ for _, email := range e.emails {
+ if email.Address == address {
+ return &email, nil
+ }
+ }
+ return nil, nil
+}
+
+func (e *emailPersister) Create(email models.Email) error {
+ e.emails = append(e.emails, email)
+ return nil
+}
+
+func (e *emailPersister) Update(email models.Email) error {
+ for i, data := range e.emails {
+ if data.ID == email.ID {
+ e.emails[i] = email
+ }
+ }
+ return nil
+}
+
+func (e *emailPersister) Delete(email models.Email) error {
+ index := -1
+ for i, data := range e.emails {
+ if data.ID == email.ID {
+ index = i
+ }
+ }
+ if index > -1 {
+ e.emails = append(e.emails[:index], e.emails[index+1:]...)
+ }
+
+ return nil
+}
+
+func (e *emailPersister) CountByUserId(userId uuid.UUID) (int, error) {
+ count := 0
+ for _, email := range e.emails {
+ if email.UserID.String() == userId.String() {
+ count++
+ }
+ }
+ return count, nil
+}
diff --git a/backend/test/persister.go b/backend/test/persister.go
index 9760799df..0d7153ea4 100644
--- a/backend/test/persister.go
+++ b/backend/test/persister.go
@@ -6,7 +6,7 @@ import (
"github.com/teamhanko/hanko/backend/persistence/models"
)
-func NewPersister(user []models.User, passcodes []models.Passcode, jwks []models.Jwk, credentials []models.WebauthnCredential, sessionData []models.WebauthnSessionData, passwords []models.PasswordCredential, auditLogs []models.AuditLog) persistence.Persister {
+func NewPersister(user []models.User, passcodes []models.Passcode, jwks []models.Jwk, credentials []models.WebauthnCredential, sessionData []models.WebauthnSessionData, passwords []models.PasswordCredential, auditLogs []models.AuditLog, emails []models.Email, primaryEmails []models.PrimaryEmail) persistence.Persister {
return &persister{
userPersister: NewUserPersister(user),
passcodePersister: NewPasscodePersister(passcodes),
@@ -15,6 +15,8 @@ func NewPersister(user []models.User, passcodes []models.Passcode, jwks []models
webauthnSessionDataPersister: NewWebauthnSessionDataPersister(sessionData),
passwordCredentialPersister: NewPasswordCredentialPersister(passwords),
auditLogPersister: NewAuditLogPersister(auditLogs),
+ emailPersister: NewEmailPersister(emails),
+ primaryEmailPersister: NewPrimaryEmailPersister(primaryEmails),
}
}
@@ -26,6 +28,8 @@ type persister struct {
webauthnSessionDataPersister persistence.WebauthnSessionDataPersister
passwordCredentialPersister persistence.PasswordCredentialPersister
auditLogPersister persistence.AuditLogPersister
+ emailPersister persistence.EmailPersister
+ primaryEmailPersister persistence.PrimaryEmailPersister
}
func (p *persister) GetPasswordCredentialPersister() persistence.PasswordCredentialPersister {
@@ -91,3 +95,19 @@ func (p *persister) GetAuditLogPersister() persistence.AuditLogPersister {
func (p *persister) GetAuditLogPersisterWithConnection(tx *pop.Connection) persistence.AuditLogPersister {
return p.auditLogPersister
}
+
+func (p *persister) GetEmailPersister() persistence.EmailPersister {
+ return p.emailPersister
+}
+
+func (p *persister) GetEmailPersisterWithConnection(tx *pop.Connection) persistence.EmailPersister {
+ return p.emailPersister
+}
+
+func (p *persister) GetPrimaryEmailPersister() persistence.PrimaryEmailPersister {
+ return p.primaryEmailPersister
+}
+
+func (p *persister) GetPrimaryEmailPersisterWithConnection(tx *pop.Connection) persistence.PrimaryEmailPersister {
+ return p.primaryEmailPersister
+}
diff --git a/backend/test/primary_email_persister.go b/backend/test/primary_email_persister.go
new file mode 100644
index 000000000..8c41ce822
--- /dev/null
+++ b/backend/test/primary_email_persister.go
@@ -0,0 +1,28 @@
+package test
+
+import (
+ "github.com/teamhanko/hanko/backend/persistence"
+ "github.com/teamhanko/hanko/backend/persistence/models"
+)
+
+func NewPrimaryEmailPersister(init []models.PrimaryEmail) persistence.PrimaryEmailPersister {
+ return &primaryEmailPersister{append([]models.PrimaryEmail{}, init...)}
+}
+
+type primaryEmailPersister struct {
+ primaryEmails []models.PrimaryEmail
+}
+
+func (p *primaryEmailPersister) Create(primaryEmail models.PrimaryEmail) error {
+ p.primaryEmails = append(p.primaryEmails, primaryEmail)
+ return nil
+}
+
+func (p *primaryEmailPersister) Update(primaryEmail models.PrimaryEmail) error {
+ for i, data := range p.primaryEmails {
+ if data.ID == primaryEmail.ID {
+ p.primaryEmails[i] = primaryEmail
+ }
+ }
+ return nil
+}
diff --git a/backend/test/user_persister.go b/backend/test/user_persister.go
index 317cac2d0..c9c8fb49e 100644
--- a/backend/test/user_persister.go
+++ b/backend/test/user_persister.go
@@ -25,17 +25,6 @@ func (p *userPersister) Get(id uuid.UUID) (*models.User, error) {
return found, nil
}
-func (p *userPersister) GetByEmail(email string) (*models.User, error) {
- var found *models.User
- for _, data := range p.users {
- if data.Email == email {
- d := data
- found = &d
- }
- }
- return found, nil
-}
-
func (p *userPersister) Create(user models.User) error {
p.users = append(p.users, user)
return nil
diff --git a/deploy/docker-compose/quickstart.debug.yaml b/deploy/docker-compose/quickstart.debug.yaml
index 167e6a82a..30afa0fef 100644
--- a/deploy/docker-compose/quickstart.debug.yaml
+++ b/deploy/docker-compose/quickstart.debug.yaml
@@ -68,7 +68,7 @@ services:
environment:
- HANKO_URL=http://localhost:8000
- HANKO_URL_INTERNAL=http://hanko:8000
- - HANKO_ELEMENT_URL=http://localhost:9500/element.hanko-auth.js
+ - HANKO_ELEMENT_URL=http://localhost:9500/elements.js
- HANKO_FRONTEND_SDK_URL=http://localhost:9500/sdk.umd.js
networks:
- intranet
diff --git a/deploy/docker-compose/quickstart.yaml b/deploy/docker-compose/quickstart.yaml
index 6d38ccafc..2ae092f3a 100644
--- a/deploy/docker-compose/quickstart.yaml
+++ b/deploy/docker-compose/quickstart.yaml
@@ -59,7 +59,7 @@ services:
environment:
- HANKO_URL=http://localhost:8000
- HANKO_URL_INTERNAL=http://hanko:8000
- - HANKO_ELEMENT_URL=http://localhost:9500/element.hanko-auth.js
+ - HANKO_ELEMENT_URL=http://localhost:9500/elements.js
- HANKO_FRONTEND_SDK_URL=http://localhost:9500/sdk.umd.js
networks:
- intranet
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Client.html b/docs/static/jsdoc/hanko-frontend-sdk/Client.html
index 96ede5e84..06603f7dc 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/Client.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/Client.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Config.html b/docs/static/jsdoc/hanko-frontend-sdk/Config.html
index 56d62e887..919e977ca 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/Config.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/Config.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -185,7 +185,7 @@
Properties:
View Source
- lib/Dto.ts , line 12
+ lib/Dto.ts , line 21
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/ConfigClient.html b/docs/static/jsdoc/hanko-frontend-sdk/ConfigClient.html
index d73e82cc6..b8e1d0ba0 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/ConfigClient.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/ConfigClient.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/ConflictError.html b/docs/static/jsdoc/hanko-frontend-sdk/ConflictError.html
index b410d4d6f..87e6b923b 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/ConflictError.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/ConflictError.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Credential.html b/docs/static/jsdoc/hanko-frontend-sdk/Credential.html
index 752c3af71..b7e32a18f 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/Credential.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/Credential.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -185,7 +185,7 @@ Properties:
View Source
- lib/Dto.ts , line 44
+ lib/Dto.ts , line 54
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Email.html b/docs/static/jsdoc/hanko-frontend-sdk/Email.html
new file mode 100644
index 000000000..374e12b05
--- /dev/null
+++ b/docs/static/jsdoc/hanko-frontend-sdk/Email.html
@@ -0,0 +1,316 @@
+
+
+
+
+
+
+
+ Email
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Properties:
+
+
+
+
+
+
+
+ Name
+
+
+ Type
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+ id
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+ The UUID of the email address.
+
+
+
+
+
+
+ address
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+ The email address.
+
+
+
+
+
+
+ is_verified
+
+
+
+
+
+boolean
+
+
+
+
+
+
+
+
+
+ Indicates whether the email address is verified.
+
+
+
+
+
+
+ is_primary
+
+
+
+
+
+boolean
+
+
+
+
+
+
+
+
+
+ Indicates it's the primary email address.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/Dto.ts , line 93
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/EmailAddressAlreadyExistsError.html b/docs/static/jsdoc/hanko-frontend-sdk/EmailAddressAlreadyExistsError.html
new file mode 100644
index 000000000..f5e192c85
--- /dev/null
+++ b/docs/static/jsdoc/hanko-frontend-sdk/EmailAddressAlreadyExistsError.html
@@ -0,0 +1,428 @@
+
+
+
+
+
+
+
+ EmailAddressAlreadyExistsError
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Class
+ EmailAddressAlreadyExistsError
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Constructor
+
+
+
+
+
+
+ #
+
+
+
+ new EmailAddressAlreadyExistsError()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/Errors.ts , line 240
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Extends
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Members
+
+
+
+
+
+ Type:
+
+Error
+
+
+
+
+
+ #
+
+
+ cause
+
+
+ Optional
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Overrides:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/Errors.ts , line 27
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Type:
+
+string
+
+
+
+
+
+ #
+
+
+ code
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Overrides:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/Errors.ts , line 22
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/EmailClient.html b/docs/static/jsdoc/hanko-frontend-sdk/EmailClient.html
new file mode 100644
index 000000000..8ae896fc6
--- /dev/null
+++ b/docs/static/jsdoc/hanko-frontend-sdk/EmailClient.html
@@ -0,0 +1,1270 @@
+
+
+
+
+
+
+
+ EmailClient
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Constructor
+
+
+
+
+
+
+ #
+
+
+
+ new EmailClient()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/client/EmailClient.ts , line 12
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Extends
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Methods
+
+
+
+
+
+
+
+ #
+
+
+ async
+
+
+
+
+ create(address) → {Promise.<Email >}
+
+
+
+
+
+
+
+
+ Adds a new email address to the current user.
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+ Name
+
+
+ Type
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+
+ address
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+ The email address to be added.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ See:
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/client/EmailClient.ts , line 133
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Throws:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Returns:
+
+
+
+
+
+
+
+
+
Type:
+
+
Promise.<Email >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #
+
+
+ async
+
+
+
+
+ delete(emailID) → {Promise.<void>}
+
+
+
+
+
+
+
+
+ Deletes the specified email address.
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+ Name
+
+
+ Type
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+
+ emailID
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+ The ID of the email address to be deleted
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ See:
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/client/EmailClient.ts , line 159
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Throws:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Returns:
+
+
+
+
+
+
+
+
+ Type:
+
+Promise.<void>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #
+
+
+ async
+
+
+
+
+ list() → {Promise.<Emails >}
+
+
+
+
+
+
+
+
+ Returns a list of all email addresses assigned to the current user.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ See:
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/client/EmailClient.ts , line 118
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Throws:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Returns:
+
+
+
+
+
+
+
+
+
Type:
+
+
Promise.<Emails >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #
+
+
+ async
+
+
+
+
+ setPrimaryEmail(emailID) → {Promise.<void>}
+
+
+
+
+
+
+
+
+ Marks the specified email address as primary.
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+ Name
+
+
+ Type
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+
+ emailID
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+ The ID of the email address to be updated
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ See:
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/client/EmailClient.ts , line 146
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Throws:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Returns:
+
+
+
+
+
+
+
+
+ Type:
+
+Promise.<void>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/EmailConfig.html b/docs/static/jsdoc/hanko-frontend-sdk/EmailConfig.html
new file mode 100644
index 000000000..303ef2c68
--- /dev/null
+++ b/docs/static/jsdoc/hanko-frontend-sdk/EmailConfig.html
@@ -0,0 +1,270 @@
+
+
+
+
+
+
+
+ EmailConfig
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Interface
+ EmailConfig
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Properties:
+
+
+
+
+
+
+
+ Name
+
+
+ Type
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+ require_verification
+
+
+
+
+
+boolean
+
+
+
+
+
+
+
+
+
+ Indicates that email addresses must be verified.
+
+
+
+
+
+
+ max_num_of_addresses
+
+
+
+
+
+number
+
+
+
+
+
+
+
+
+
+ The maximum number of email addresses a user can have.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/Dto.ts , line 13
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Emails.html b/docs/static/jsdoc/hanko-frontend-sdk/Emails.html
new file mode 100644
index 000000000..1a2c2e928
--- /dev/null
+++ b/docs/static/jsdoc/hanko-frontend-sdk/Emails.html
@@ -0,0 +1,243 @@
+
+
+
+
+
+
+
+ Emails
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Properties:
+
+
+
+
+
+
+
+
+ Type
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+
+
+
+
+Array.<Email >
+
+
+
+
+
+
+
+
+
+ A list of emails assigned to the current user.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/Dto.ts , line 103
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Hanko.html b/docs/static/jsdoc/hanko-frontend-sdk/Hanko.html
index 67a62cc1c..87dd0c4b9 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/Hanko.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/Hanko.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -263,7 +263,7 @@ Parameters:
View Source
- Hanko.ts , line 13
+ Hanko.ts , line 14
@@ -375,7 +375,80 @@
View Source
- Hanko.ts , line 25
+ Hanko.ts , line 27
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Type:
+
+EmailClient
+
+
+
+
+
+ #
+
+
+ email
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Source
+
+ Hanko.ts , line 52
@@ -448,7 +521,7 @@
View Source
- Hanko.ts , line 45
+ Hanko.ts , line 47
@@ -521,7 +594,7 @@
View Source
- Hanko.ts , line 40
+ Hanko.ts , line 42
@@ -594,7 +667,7 @@
View Source
- Hanko.ts , line 30
+ Hanko.ts , line 32
@@ -667,7 +740,7 @@
View Source
- Hanko.ts , line 35
+ Hanko.ts , line 37
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Hanko.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/Hanko.ts.html
index 659946b95..78b12d96b 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/Hanko.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/Hanko.ts.html
@@ -68,7 +68,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -90,6 +90,7 @@ Hanko.ts
import { PasswordClient } from "./lib/client/PasswordClient";
import { UserClient } from "./lib/client/UserClient";
import { WebauthnClient } from "./lib/client/WebauthnClient";
+import { EmailClient } from "./lib/client/EmailClient";
/**
* A class that bundles all available SDK functions.
@@ -103,6 +104,7 @@ Hanko.ts
webauthn: WebauthnClient;
password: PasswordClient;
passcode: PasscodeClient;
+ email: EmailClient;
// eslint-disable-next-line require-jsdoc
constructor(api: string, timeout = 13000) {
@@ -131,6 +133,11 @@ Hanko.ts
* @type {PasscodeClient}
*/
this.passcode = new PasscodeClient(api, timeout);
+ /**
+ * @public
+ * @type {EmailClient}
+ */
+ this.email = new EmailClient(api, timeout);
}
}
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/HankoError.html b/docs/static/jsdoc/hanko-frontend-sdk/HankoError.html
index 5a0f4b71e..927067d25 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/HankoError.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/HankoError.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Headers.html b/docs/static/jsdoc/hanko-frontend-sdk/Headers.html
index b2460afa4..5bcdae012 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/Headers.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/Headers.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -398,7 +398,7 @@ Parameters:
View Source
- lib/client/HttpClient.ts , line 196
+ lib/client/HttpClient.ts , line 241
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/HttpClient.html b/docs/static/jsdoc/hanko-frontend-sdk/HttpClient.html
index b4cc6da46..f11f38a56 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/HttpClient.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/HttpClient.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -269,7 +269,7 @@ Parameters:
View Source
- lib/client/HttpClient.ts , line 95
+ lib/client/HttpClient.ts , line 111
@@ -326,6 +326,259 @@ Methods
+
+ #
+
+
+
+ delete(path, bodyopt ) → {Promise.<Response >}
+
+
+
+
+
+
+
+
+ Performs a DELETE request.
+
+
+
+
+
+
+
+
+
+
+
+ Parameters:
+
+
+
+
+
+
+ Name
+
+
+ Type
+
+
+ Attributes
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+
+ path
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The path to the requested resource.
+
+
+
+
+
+
+
+
+ body
+
+
+
+
+
+any
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+ The request body.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/client/HttpClient.ts , line 311
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Returns:
+
+
+
+
+
+
+
+
+
Type:
+
+
Promise.<Response >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #
+
+
+
+ patch(path, bodyopt ) → {Promise.<Response >}
+
+
+
+
+
+
+
+
+ Performs a PATCH request.
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+ Name
+
+
+ Type
+
+
+ Attributes
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+
+ path
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The path to the requested resource.
+
+
+
+
+
+
+
+
+ body
+
+
+
+
+
+any
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+ The request body.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/client/HttpClient.ts , line 300
@@ -695,7 +1201,7 @@ Parameters:
View Source
- lib/client/HttpClient.ts , line 226
+ lib/client/HttpClient.ts , line 278
@@ -948,7 +1454,7 @@ Parameters:
View Source
- lib/client/HttpClient.ts , line 237
+ lib/client/HttpClient.ts , line 289
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/InvalidPasscodeError.html b/docs/static/jsdoc/hanko-frontend-sdk/InvalidPasscodeError.html
index 0c4dfd737..ba75b8ebf 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/InvalidPasscodeError.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/InvalidPasscodeError.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/InvalidPasswordError.html b/docs/static/jsdoc/hanko-frontend-sdk/InvalidPasswordError.html
index c42a590f6..bdc07acbf 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/InvalidPasswordError.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/InvalidPasswordError.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/InvalidWebauthnCredentialError.html b/docs/static/jsdoc/hanko-frontend-sdk/InvalidWebauthnCredentialError.html
index 738d11628..6635a2dd6 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/InvalidWebauthnCredentialError.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/InvalidWebauthnCredentialError.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/LocalStorage.html b/docs/static/jsdoc/hanko-frontend-sdk/LocalStorage.html
index f19d032ba..eef92e11b 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/LocalStorage.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/LocalStorage.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/LocalStoragePasscode.html b/docs/static/jsdoc/hanko-frontend-sdk/LocalStoragePasscode.html
index ae836a2ac..6f7b0b550 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/LocalStoragePasscode.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/LocalStoragePasscode.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -216,6 +216,37 @@ Properties:
+
+
+
+ emailID
+
+
+
+
+
+emailID
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+ The email address ID.
+
+
+
@@ -257,7 +288,7 @@ Properties:
View Source
- lib/state/PasscodeState.ts , line 110
+ lib/state/PasscodeState.ts , line 131
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/LocalStoragePassword.html b/docs/static/jsdoc/hanko-frontend-sdk/LocalStoragePassword.html
index 8a315cadd..bd79e0cf7 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/LocalStoragePassword.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/LocalStoragePassword.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageUser.html b/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageUser.html
index 192fa7ed6..d456cdb61 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageUser.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageUser.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageUsers.html b/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageUsers.html
index c2a4744a7..20b732485 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageUsers.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageUsers.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageWebauthn.html b/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageWebauthn.html
index 81ae29a0c..69a47a7cb 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageWebauthn.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageWebauthn.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/MaxNumOfEmailAddressesReachedError.html b/docs/static/jsdoc/hanko-frontend-sdk/MaxNumOfEmailAddressesReachedError.html
new file mode 100644
index 000000000..84a4fb824
--- /dev/null
+++ b/docs/static/jsdoc/hanko-frontend-sdk/MaxNumOfEmailAddressesReachedError.html
@@ -0,0 +1,429 @@
+
+
+
+
+
+
+
+ MaxNumOfEmailAddressesReachedError
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Class
+ MaxNumOfEmailAddressesReachedError
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Constructor
+
+
+
+
+
+
+ #
+
+
+
+ new MaxNumOfEmailAddressesReachedError()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/Errors.ts , line 226
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Extends
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Members
+
+
+
+
+
+ Type:
+
+Error
+
+
+
+
+
+ #
+
+
+ cause
+
+
+ Optional
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Overrides:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/Errors.ts , line 27
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Type:
+
+string
+
+
+
+
+
+ #
+
+
+ code
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Overrides:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/Errors.ts , line 22
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/MaxNumOfPasscodeAttemptsReachedError.html b/docs/static/jsdoc/hanko-frontend-sdk/MaxNumOfPasscodeAttemptsReachedError.html
index 4903b31ed..84fd61b36 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/MaxNumOfPasscodeAttemptsReachedError.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/MaxNumOfPasscodeAttemptsReachedError.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/NotFoundError.html b/docs/static/jsdoc/hanko-frontend-sdk/NotFoundError.html
index eabc6bb74..b50367148 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/NotFoundError.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/NotFoundError.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Passcode.html b/docs/static/jsdoc/hanko-frontend-sdk/Passcode.html
index c2aa32081..9737512ee 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/Passcode.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/Passcode.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -208,7 +208,7 @@ Properties:
View Source
- lib/Dto.ts , line 60
+ lib/Dto.ts , line 70
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/PasscodeClient.html b/docs/static/jsdoc/hanko-frontend-sdk/PasscodeClient.html
index 5fb48ae79..6845f30ec 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/PasscodeClient.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/PasscodeClient.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -545,7 +545,7 @@ Parameters:
View Source
- lib/client/PasscodeClient.ts , line 138
+ lib/client/PasscodeClient.ts , line 167
@@ -783,7 +783,7 @@ Parameters:
View Source
- lib/client/PasscodeClient.ts , line 154
+ lib/client/PasscodeClient.ts , line 183
@@ -954,7 +954,7 @@ Parameters:
View Source
- lib/client/PasscodeClient.ts , line 146
+ lib/client/PasscodeClient.ts , line 175
@@ -1018,7 +1018,7 @@
- initialize(userID) → {Promise.<Passcode >}
+ initialize(userID, emailIDopt , forceopt ) → {Promise.<Passcode >}
@@ -1052,6 +1052,8 @@ Parameters:
Type
+ Attributes
+
@@ -1078,6 +1080,14 @@ Parameters:
+
+
+
+
+
+
+
+
@@ -1086,6 +1096,76 @@ Parameters:
+
+
+
+
+ emailID
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+ The UUID of the email address. If unspecified, the email will be sent to the primary email address.
+
+
+
+
+
+
+
+
+ force
+
+
+
+
+
+boolean
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+ Indicates the passcode should be sent, even if there is another active passcode.
+
+
+
+
@@ -1136,7 +1216,7 @@ Parameters:
View Source
- lib/client/PasscodeClient.ts , line 123
+ lib/client/PasscodeClient.ts , line 152
@@ -1192,6 +1272,21 @@ Parameters:
+
+
+
+
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/PasscodeExpiredError.html b/docs/static/jsdoc/hanko-frontend-sdk/PasscodeExpiredError.html
index 6f990d410..cbcd6c1a5 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/PasscodeExpiredError.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/PasscodeExpiredError.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/PasscodeState.html b/docs/static/jsdoc/hanko-frontend-sdk/PasscodeState.html
index 440690c2f..aaa0b1b19 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/PasscodeState.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/PasscodeState.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -436,7 +436,178 @@ Parameters:
View Source
- lib/state/PasscodeState.ts , line 145
+ lib/state/PasscodeState.ts , line 167
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Returns:
+
+
+
+
+
+
+
+
+ Type:
+
+string
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #
+
+
+
+ getEmailID(userID) → {string}
+
+
+
+
+
+
+
+
+ Gets the UUID of the email address.
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+ Name
+
+
+ Type
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+
+ userID
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+ The UUID of the user.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/state/PasscodeState.ts , line 184
@@ -607,7 +778,7 @@ Parameters:
View Source
- lib/state/PasscodeState.ts , line 187
+ lib/state/PasscodeState.ts , line 226
@@ -778,7 +949,7 @@ Parameters:
View Source
- lib/state/PasscodeState.ts , line 170
+ lib/state/PasscodeState.ts , line 209
@@ -1079,7 +1250,7 @@
View Source
- lib/state/PasscodeState.ts , line 137
+ lib/state/PasscodeState.ts , line 159
@@ -1250,7 +1421,7 @@ Parameters:
View Source
- lib/state/PasscodeState.ts , line 162
+ lib/state/PasscodeState.ts , line 201
@@ -1446,7 +1617,203 @@ Parameters:
View Source
- lib/state/PasscodeState.ts , line 154
+ lib/state/PasscodeState.ts , line 176
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #
+
+
+
+ setEmailID(userID, emailID) → {PasscodeState }
+
+
+
+
+
+
+
+
+ Sets the UUID of the email address.
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+ Name
+
+
+ Type
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+
+ userID
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+ The UUID of the user.
+
+
+
+
+
+
+
+
+ emailID
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+ The UUID of the email address.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/state/PasscodeState.ts , line 193
@@ -1642,7 +2009,7 @@ Parameters:
View Source
- lib/state/PasscodeState.ts , line 196
+ lib/state/PasscodeState.ts , line 235
@@ -1838,7 +2205,7 @@ Parameters:
View Source
- lib/state/PasscodeState.ts , line 179
+ lib/state/PasscodeState.ts , line 218
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/PasswordClient.html b/docs/static/jsdoc/hanko-frontend-sdk/PasswordClient.html
index c3eed11e7..d1e3ac903 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/PasswordClient.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/PasswordClient.html
@@ -66,7 +66,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -167,7 +167,7 @@
View Source
- lib/client/PasswordClient.ts , line 13
+ lib/client/PasswordClient.ts , line 14
@@ -305,6 +305,79 @@
View Source
- lib/client/HttpClient.ts , line 49
+ lib/client/HttpClient.ts , line 50
@@ -410,7 +410,7 @@
View Source
- lib/client/HttpClient.ts , line 54
+ lib/client/HttpClient.ts , line 55
@@ -483,7 +483,7 @@
View Source
- lib/client/HttpClient.ts , line 59
+ lib/client/HttpClient.ts , line 60
@@ -556,7 +556,7 @@
View Source
- lib/client/HttpClient.ts , line 64
+ lib/client/HttpClient.ts , line 65
@@ -629,7 +629,7 @@
View Source
- lib/client/HttpClient.ts , line 69
+ lib/client/HttpClient.ts , line 70
@@ -719,7 +719,7 @@
View Source
- lib/client/HttpClient.ts , line 204
+ lib/client/HttpClient.ts , line 249
@@ -768,6 +768,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Returns the value for X-Retry-After contained in the response header.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/client/HttpClient.ts , line 256
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Returns:
+
+
+
+
+
+
+
+
+ Type:
+
+number
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/State.html b/docs/static/jsdoc/hanko-frontend-sdk/State.html
index 7c43caeae..8b79c5790 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/State.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/State.html
@@ -66,7 +66,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/TechnicalError.html b/docs/static/jsdoc/hanko-frontend-sdk/TechnicalError.html
index 05d652c43..f26b421ea 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/TechnicalError.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/TechnicalError.html
@@ -66,7 +66,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/TooManyRequestsError.html b/docs/static/jsdoc/hanko-frontend-sdk/TooManyRequestsError.html
index 0c0604eb1..4d66eeca8 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/TooManyRequestsError.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/TooManyRequestsError.html
@@ -66,7 +66,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/UnauthorizedError.html b/docs/static/jsdoc/hanko-frontend-sdk/UnauthorizedError.html
index d0efbf742..c47466abb 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/UnauthorizedError.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/UnauthorizedError.html
@@ -66,7 +66,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/User.html b/docs/static/jsdoc/hanko-frontend-sdk/User.html
index cf28030dc..74c79015d 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/User.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/User.html
@@ -66,7 +66,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -231,7 +231,7 @@ Properties:
View Source
- lib/Dto.ts , line 51
+ lib/Dto.ts , line 61
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/UserClient.html b/docs/static/jsdoc/hanko-frontend-sdk/UserClient.html
index e2593e9eb..f1039020f 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/UserClient.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/UserClient.html
@@ -66,7 +66,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/UserInfo.html b/docs/static/jsdoc/hanko-frontend-sdk/UserInfo.html
index 118040321..aed03a6cc 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/UserInfo.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/UserInfo.html
@@ -66,7 +66,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -168,6 +168,29 @@ Properties:
+
+
+ email_id
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+ The UUID of the email address.
+
+
+
+
has_webauthn_credential
@@ -231,7 +254,7 @@ Properties:
View Source
- lib/Dto.ts , line 27
+ lib/Dto.ts , line 36
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/UserState.html b/docs/static/jsdoc/hanko-frontend-sdk/UserState.html
index 1da790d8c..c3803ab93 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/UserState.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/UserState.html
@@ -66,7 +66,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/UserVerificationError.html b/docs/static/jsdoc/hanko-frontend-sdk/UserVerificationError.html
index bd00d2a1e..2f33b8169 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/UserVerificationError.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/UserVerificationError.html
@@ -66,7 +66,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnClient.html b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnClient.html
index 635eba5b8..4343d102c 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnClient.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnClient.html
@@ -66,7 +66,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -167,7 +167,7 @@
View Source
- lib/client/WebauthnClient.ts , line 15
+ lib/client/WebauthnClient.ts , line 16
@@ -305,6 +305,79 @@
+
+
+
@@ -317,11 +390,11 @@
-
- #
+
+ #
- state
+ webauthnState
@@ -368,7 +441,7 @@
View Source
- lib/client/WebauthnClient.ts , line 27
+ lib/client/WebauthnClient.ts , line 29
@@ -393,8 +466,8 @@
Methods
-
- #
+
+ #
async
@@ -402,7 +475,7 @@
- login(userIDopt , useConditionalMediationopt ) → {Promise.<void>}
+ deleteCredential(credentialIDopt ) → {Promise.<void>}
@@ -411,8 +484,7 @@
- Performs a WebAuthn authentication ceremony. When 'userID' is specified, the API provides a list of
-allowed credentials and the browser is able to present a list of suitable credentials to the user.
+ Deletes the WebAuthn credential.
@@ -452,7 +524,7 @@ Parameters:
- userID
+ credentialID
@@ -478,42 +550,7 @@ Parameters:
- The user's UUID.
-
-
-
-
-
-
-
-
- useConditionalMediation
-
-
-
-
-
-boolean
-
-
-
-
-
-
-
-
- <optional>
-
-
-
-
-
-
-
-
-
-
- Enables autofill assisted login.
+ The credential's UUID.
@@ -557,11 +594,7 @@ Parameters:
See:
@@ -572,7 +605,7 @@ Parameters:
View Source
- lib/client/WebauthnClient.ts , line 180
+ lib/client/WebauthnClient.ts , line 310
@@ -603,7 +636,7 @@
Parameters:
@@ -618,7 +651,7 @@
Parameters:
@@ -694,8 +727,8 @@
Parameters:
-
- #
+
+ #
async
@@ -703,7 +736,7 @@
- register() → {Promise.<void>}
+ listCredentials() → {Promise.<WebauthnCredentials >}
@@ -712,7 +745,7 @@
- Performs a WebAuthn registration ceremony.
+ Returns a list of all WebAuthn credentials assigned to the current user.
@@ -759,11 +792,7 @@
See:
@@ -774,7 +803,7 @@
View Source
- lib/client/WebauthnClient.ts , line 196
+ lib/client/WebauthnClient.ts , line 281
@@ -805,7 +834,7 @@
@@ -830,21 +859,6 @@
-
-
-
-
@@ -856,21 +870,6 @@
-
-
-
-
-
@@ -891,7 +890,7 @@
Type:
-
Promise.<void>
+
Promise.<WebauthnCredentials >
@@ -911,8 +910,8 @@
-
- #
+
+ #
async
@@ -920,7 +919,7 @@
- shouldRegister(user) → {Promise.<boolean>}
+ login(userIDopt , useConditionalMediationopt ) → {Promise.<void>}
@@ -929,9 +928,8 @@
- Determines whether a credential registration ceremony should be performed. Returns 'true' when WebAuthn
-is supported and the user's credentials do not intersect with the credentials already known on the
-current browser/device.
+ Performs a WebAuthn authentication ceremony. When 'userID' is specified, the API provides a list of
+allowed credentials and the browser is able to present a list of suitable credentials to the user.
@@ -956,6 +954,8 @@ Parameters:
Type
+ Attributes
+
@@ -969,23 +969,68 @@ Parameters:
- user
+ userID
-User
+string
+
+
+ <optional>
+
+
+
+
+
+
+
- The user object.
+ The user's UUID.
+
+
+
+
+
+
+
+
+ useConditionalMediation
+
+
+
+
+
+boolean
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+ Enables autofill assisted login.
@@ -1025,6 +1070,17 @@ Parameters:
+
+ See:
+
+
+
@@ -1033,7 +1089,7 @@ Parameters:
View Source
- lib/client/WebauthnClient.ts , line 207
+ lib/client/WebauthnClient.ts , line 253
@@ -1054,19 +1110,480 @@ Parameters:
-
-
-
Returns:
+
Throws:
+
+
+
+
+
Returns:
+
+
+
+
+
+
+
+
+ Type:
+
+Promise.<void>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #
+
+
+ async
+
+
+
+
+ register() → {Promise.<void>}
+
+
+
+
+
+
+
+
+ Performs a WebAuthn registration ceremony.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ See:
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/client/WebauthnClient.ts , line 269
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Throws:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Returns:
+
+
+
+
+
+
+
+
+ Type:
+
+Promise.<void>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #
+
+
+ async
+
+
+
+
+ shouldRegister(user) → {Promise.<boolean>}
+
+
+
+
+
+
+
+
+ Determines whether a credential registration ceremony should be performed. Returns 'true' when WebAuthn
+is supported and the user's credentials do not intersect with the credentials already known on the
+current browser/device.
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+ Name
+
+
+ Type
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+
+ user
+
+
+
+
+
+User
+
+
+
+
+
+
+
+
+
+ The user object.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/client/WebauthnClient.ts , line 321
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Returns:
+
+
+
+
+
+
+
+
+ Type:
Promise.<boolean>
@@ -1082,6 +1599,300 @@
Parameters:
+
+
+
+
+
+
+
+ #
+
+
+ async
+
+
+
+
+ updateCredential(credentialIDopt , name) → {Promise.<void>}
+
+
+
+
+
+
+
+
+ Updates the WebAuthn credential.
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+ Name
+
+
+ Type
+
+
+ Attributes
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+
+ credentialID
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+ The credential's UUID.
+
+
+
+
+
+
+
+
+ name
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The new credential name.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ See:
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/client/WebauthnClient.ts , line 296
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Throws:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Returns:
+
+
+
+
+
+
+
+
+ Type:
+
+Promise.<void>
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnCredential.html b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnCredential.html
new file mode 100644
index 000000000..6d9b7ae11
--- /dev/null
+++ b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnCredential.html
@@ -0,0 +1,431 @@
+
+
+
+
+
+
+
+
WebauthnCredential
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Interface
+ WebauthnCredential
+
+
+
+
+
+
+
+
+
+
+ WebauthnCredential
+
+
+
+
+
+
+
+
+
+
+
+
+
Properties:
+
+
+
+
+
+
+
+ Name
+
+
+ Type
+
+
+ Attributes
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+ id
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The credential id.
+
+
+
+
+
+
+ name
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+ The credential name.
+
+
+
+
+
+
+ public_key
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The public key.
+
+
+
+
+
+
+ attestation_type
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The attestation type.
+
+
+
+
+
+
+ aaguid
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The AAGUID of the authenticator.
+
+
+
+
+
+
+ created_at
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Time of credential creation.
+
+
+
+
+
+
+ transports
+
+
+
+
+
+WebauthnTransports
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/Dto.ts , line 110
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnCredentials.html b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnCredentials.html
new file mode 100644
index 000000000..9b64401bd
--- /dev/null
+++ b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnCredentials.html
@@ -0,0 +1,243 @@
+
+
+
+
+
+
+
+
WebauthnCredentials
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Interface
+ WebauthnCredentials
+
+
+
+
+
+
+
+
+
+
+ WebauthnCredentials
+
+
+
+
+
+
+
+
+
+
+
+
+
Properties:
+
+
+
+
+
+
+
+
+ Type
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+
+
+
+
+Array.<WebauthnCredential >
+
+
+
+
+
+
+
+
+
+ A list of WebAuthn credential assigned to the current user.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/Dto.ts , line 123
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnFinalized.html b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnFinalized.html
index 1c8092c57..df08e86d7 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnFinalized.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnFinalized.html
@@ -66,7 +66,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -208,7 +208,7 @@
Properties:
View Source
- lib/Dto.ts , line 19
+ lib/Dto.ts , line 28
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnRequestCancelledError.html b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnRequestCancelledError.html
index 7d54b39dd..72a807b40 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnRequestCancelledError.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnRequestCancelledError.html
@@ -66,7 +66,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnState.html b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnState.html
index a0b24fb4c..8ecbbc58b 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnState.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnState.html
@@ -66,7 +66,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnSupport.html b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnSupport.html
index 4cbef4dbf..8c34996ef 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnSupport.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnSupport.html
@@ -66,7 +66,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnTransports.html b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnTransports.html
new file mode 100644
index 000000000..beb117f44
--- /dev/null
+++ b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnTransports.html
@@ -0,0 +1,243 @@
+
+
+
+
+
+
+
+ WebauthnTransports
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Interface
+ WebauthnTransports
+
+
+
+
+
+
+
+
+
+
+ WebauthnTransports
+
+
+
+
+
+
+
+
+
+
+
+
+
Properties:
+
+
+
+
+
+
+
+
+ Type
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+
+
+
+
+Array.<string>
+
+
+
+
+
+
+
+
+
+ Transports which may be used by the authenticator. E.g. "internal", "ble",...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Source
+
+ lib/Dto.ts , line 78
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/index.html b/docs/static/jsdoc/hanko-frontend-sdk/index.html
index 12d63c849..50474ba8c 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/index.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/index.html
@@ -66,7 +66,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -154,6 +154,8 @@ DTOs
Credential
UserInfo
User
+Email
+Emails
Passcode
Errors
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_Dto.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_Dto.ts.html
index 1d209a8e9..9dd00dffb 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/lib_Dto.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_Dto.ts.html
@@ -68,7 +68,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -92,9 +92,23 @@ lib/Dto.ts
* @category SDK
* @subcategory DTO
* @property {boolean} enabled - Indicates passwords are enabled, so the API accepts login attempts using passwords.
+ * @property {number} min_password_length - The minimum length of a password. To be used for password validation.
*/
interface PasswordConfig {
enabled: boolean;
+ min_password_length: number;
+}
+
+/**
+ * @interface
+ * @category SDK
+ * @subcategory DTO
+ * @property {boolean} require_verification - Indicates that email addresses must be verified.
+ * @property {number} max_num_of_addresses - The maximum number of email addresses a user can have.
+ */
+interface EmailConfig {
+ require_verification: boolean;
+ max_num_of_addresses: number;
}
/**
@@ -105,6 +119,7 @@ lib/Dto.ts
*/
interface Config {
password: PasswordConfig;
+ emails: EmailConfig;
}
/**
@@ -125,11 +140,13 @@ lib/Dto.ts
* @subcategory DTO
* @property {string} id - The UUID of the user.
* @property {boolean} verified - Indicates whether the user's email address is verified.
+ * @property {string} email_id - The UUID of the email address.
* @property {boolean} has_webauthn_credential - Indicates that the user has registered a WebAuthn credential in the past.
*/
interface UserInfo {
id: string;
verified: boolean;
+ email_id: string;
has_webauthn_credential: boolean;
}
@@ -164,7 +181,7 @@ lib/Dto.ts
*/
interface User {
id: string;
- email: string;
+ email_id: string;
webauthn_credentials: Credential[];
}
@@ -184,13 +201,75 @@ lib/Dto.ts
* @interface
* @category SDK
* @subcategory DTO
- * @property {string[]} transports - A list of WebAuthn AuthenticatorTransport, e.g.: "usb", "internal",...
+ * @property {string[]} - Transports which may be used by the authenticator. E.g. "internal", "ble",...
+ */
+interface WebauthnTransports extends Array<string> {}
+
+/**
+ * @interface
+ * @category SDK
+ * @subcategory DTO
+ * @property {WebauthnTransports} transports
* @ignore
*/
interface Attestation extends PublicKeyCredentialWithAttestationJSON {
- transports: string[];
+ transports: WebauthnTransports;
+}
+
+/**
+ * @interface
+ * @category SDK
+ * @subcategory DTO
+ * @property {string} id - The UUID of the email address.
+ * @property {string} address - The email address.
+ * @property {boolean} is_verified - Indicates whether the email address is verified.
+ * @property {boolean} is_primary - Indicates it's the primary email address.
+ */
+interface Email {
+ id: string;
+ address: string;
+ is_verified: boolean;
+ is_primary: boolean;
}
+/**
+ * @interface
+ * @category SDK
+ * @subcategory DTO
+ * @property {Email[]} - A list of emails assigned to the current user.
+ */
+interface Emails extends Array<Email> {}
+
+/**
+ * @interface
+ * @category SDK
+ * @subcategory DTO
+ * @property {string} id - The credential id.
+ * @property {string=} name - The credential name.
+ * @property {string} public_key - The public key.
+ * @property {string} attestation_type - The attestation type.
+ * @property {string} aaguid - The AAGUID of the authenticator.
+ * @property {string} created_at - Time of credential creation.
+ * @property {WebauthnTransports} transports
+ */
+interface WebauthnCredential {
+ id: string;
+ name?: string;
+ public_key: string;
+ attestation_type: string;
+ aaguid: string;
+ created_at: string;
+ transports: WebauthnTransports;
+}
+
+/**
+ * @interface
+ * @category SDK
+ * @subcategory DTO
+ * @property {WebauthnCredential[]} - A list of WebAuthn credential assigned to the current user.
+ */
+interface WebauthnCredentials extends Array<WebauthnCredential> {}
+
export type {
PasswordConfig,
Config,
@@ -199,8 +278,12 @@ lib/Dto.ts
UserInfo,
Me,
User,
+ Email,
+ Emails,
Passcode,
Attestation,
+ WebauthnCredential,
+ WebauthnCredentials,
};
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_Errors.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_Errors.ts.html
index c5e4c2c00..31ab307cf 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/lib_Errors.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_Errors.ts.html
@@ -68,7 +68,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -324,6 +324,45 @@ lib/Errors.ts
}
}
+/**
+ * A 'MaxNumOfEmailAddressesReachedError' occurs when the user tries to add a new email address while the maximum number
+ * of email addresses (see backend configuration) equals the number of email addresses already registered.
+ *
+ * @category SDK
+ * @subcategory Errors
+ * @extends {HankoError}
+ */
+class MaxNumOfEmailAddressesReachedError extends HankoError {
+ // eslint-disable-next-line require-jsdoc
+ constructor(cause?: Error) {
+ super(
+ "Maximum number of email addresses reached error",
+ "maxNumOfEmailAddressesReached",
+ cause
+ );
+ Object.setPrototypeOf(this, MaxNumOfEmailAddressesReachedError.prototype);
+ }
+}
+
+/**
+ * An 'EmailAddressAlreadyExistsError' occurs when the user tries to add a new email address which already exists.
+ *
+ * @category SDK
+ * @subcategory Errors
+ * @extends {HankoError}
+ */
+class EmailAddressAlreadyExistsError extends HankoError {
+ // eslint-disable-next-line require-jsdoc
+ constructor(cause?: Error) {
+ super(
+ "The email address already exists",
+ "emailAddressAlreadyExistsError",
+ cause
+ );
+ Object.setPrototypeOf(this, EmailAddressAlreadyExistsError.prototype);
+ }
+}
+
export {
HankoError,
TechnicalError,
@@ -338,7 +377,9 @@ lib/Errors.ts
NotFoundError,
TooManyRequestsError,
UnauthorizedError,
- UserVerificationError
+ UserVerificationError,
+ MaxNumOfEmailAddressesReachedError,
+ EmailAddressAlreadyExistsError,
};
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_WebauthnSupport.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_WebauthnSupport.ts.html
index 8bdd0f248..701af7e09 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/lib_WebauthnSupport.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_WebauthnSupport.ts.html
@@ -68,7 +68,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_Client.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_Client.ts.html
index cab9c909d..1bc7539f1 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_Client.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_Client.ts.html
@@ -68,7 +68,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_ConfigClient.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_ConfigClient.ts.html
index a0cec3f07..9e32d90bf 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_ConfigClient.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_ConfigClient.ts.html
@@ -68,7 +68,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_EmailClient.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_EmailClient.ts.html
new file mode 100644
index 000000000..df8fd55ba
--- /dev/null
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_EmailClient.ts.html
@@ -0,0 +1,232 @@
+
+
+
+
+
+
+
+
+
+ lib/client/EmailClient.ts
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Source
+ lib/client/EmailClient.ts
+
+
+
+
+
+
+
+
+ import { Client } from "./Client";
+import {
+ EmailAddressAlreadyExistsError,
+ MaxNumOfEmailAddressesReachedError,
+ TechnicalError,
+ UnauthorizedError,
+} from "../Errors";
+import { Email, Emails } from "../Dto";
+
+/**
+ * Manages email addresses of the current user.
+ *
+ * @constructor
+ * @category SDK
+ * @subcategory Clients
+ * @extends {Client}
+ */
+class EmailClient extends Client {
+ /**
+ * Returns a list of all email addresses assigned to the current user.
+ *
+ * @return {Promise<Emails>}
+ * @throws {UnauthorizedError}
+ * @throws {RequestTimeoutError}
+ * @throws {TechnicalError}
+ * @see https://docs.hanko.io/api/public#tag/Email-Management/operation/listEmails
+ */
+ async list(): Promise<Emails> {
+ const response = await this.client.get("/emails");
+
+ if (response.status === 401) {
+ throw new UnauthorizedError();
+ } else if (!response.ok) {
+ throw new TechnicalError();
+ }
+
+ return response.json();
+ }
+
+ /**
+ * Adds a new email address to the current user.
+ *
+ * @param {string} address - The email address to be added.
+ * @return {Promise<Email>}
+ * @throws {EmailAddressAlreadyExistsError}
+ * @throws {MaxNumOfEmailAddressesReachedError}
+ * @throws {UnauthorizedError}
+ * @throws {RequestTimeoutError}
+ * @throws {TechnicalError}
+ * @see https://docs.hanko.io/api/public#tag/Email-Management/operation/createEmail
+ */
+ async create(address: string): Promise<Email> {
+ const response = await this.client.post("/emails", { address });
+
+ if (response.ok) {
+ return response.json();
+ }
+
+ if (response.status === 400) {
+ throw new EmailAddressAlreadyExistsError();
+ } else if (response.status === 401) {
+ throw new UnauthorizedError();
+ } else if (response.status === 409) {
+ throw new MaxNumOfEmailAddressesReachedError();
+ }
+
+ throw new TechnicalError();
+ }
+
+ /**
+ * Marks the specified email address as primary.
+ *
+ * @param {string} emailID - The ID of the email address to be updated
+ * @return {Promise<void>}
+ * @throws {UnauthorizedError}
+ * @throws {RequestTimeoutError}
+ * @throws {TechnicalError}
+ * @see https://docs.hanko.io/api/public#tag/Email-Management/operation/setPrimaryEmail
+ */
+ async setPrimaryEmail(emailID: string): Promise<void> {
+ const response = await this.client.post(`/emails/${emailID}/set_primary`);
+
+ if (response.status === 401) {
+ throw new UnauthorizedError();
+ } else if (!response.ok) {
+ throw new TechnicalError();
+ }
+
+ return;
+ }
+
+ /**
+ * Deletes the specified email address.
+ *
+ * @param {string} emailID - The ID of the email address to be deleted
+ * @return {Promise<void>}
+ * @throws {UnauthorizedError}
+ * @throws {RequestTimeoutError}
+ * @throws {TechnicalError}
+ * @see https://docs.hanko.io/api/public#tag/Email-Management/operation/deleteEmail
+ */
+ async delete(emailID: string): Promise<void> {
+ const response = await this.client.delete(`/emails/${emailID}`);
+
+ if (response.status === 401) {
+ throw new UnauthorizedError();
+ } else if (!response.ok) {
+ throw new TechnicalError();
+ }
+
+ return;
+ }
+}
+
+export { EmailClient };
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_HttpClient.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_HttpClient.ts.html
index fa6cd035c..b8e9452e0 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_HttpClient.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_HttpClient.ts.html
@@ -68,7 +68,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -130,6 +130,7 @@ lib/client/HttpClient.ts
statusText: string;
url: string;
_decodedJSON: any;
+ private xhr: XMLHttpRequest;
// eslint-disable-next-line require-jsdoc
constructor(xhr: XMLHttpRequest) {
@@ -158,7 +159,11 @@ lib/client/HttpClient.ts
* @type {string}
*/
this.url = xhr.responseURL;
- this._decodedJSON = JSON.parse(xhr.response);
+ /**
+ * @private
+ * @type {XMLHttpRequest}
+ */
+ this.xhr = xhr;
}
/**
@@ -167,8 +172,20 @@ lib/client/HttpClient.ts
* @return {any}
*/
json() {
+ if (!this._decodedJSON) {
+ this._decodedJSON = JSON.parse(this.xhr.response);
+ }
return this._decodedJSON;
}
+
+ /**
+ * Returns the value for X-Retry-After contained in the response header.
+ *
+ * @return {number}
+ */
+ parseXRetryAfterHeader(): number {
+ return parseInt(this.headers.get("X-Retry-After") || "0", 10);
+ }
}
/**
@@ -287,6 +304,37 @@ lib/client/HttpClient.ts
body: JSON.stringify(body),
});
}
+
+ /**
+ * Performs a PATCH request.
+ *
+ * @param {string} path - The path to the requested resource.
+ * @param {any=} body - The request body.
+ * @return {Promise<Response>}
+ * @throws {RequestTimeoutError}
+ * @throws {TechnicalError}
+ */
+ patch(path: string, body?: any) {
+ return this._fetch(path, {
+ method: "PATCH",
+ body: JSON.stringify(body),
+ });
+ }
+
+ /**
+ * Performs a DELETE request.
+ *
+ * @param {string} path - The path to the requested resource.
+ * @param {any=} body - The request body.
+ * @return {Promise<Response>}
+ * @throws {RequestTimeoutError}
+ * @throws {TechnicalError}
+ */
+ delete(path: string) {
+ return this._fetch(path, {
+ method: "DELETE",
+ });
+ }
}
export { Headers, Response, HttpClient };
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_PasscodeClient.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_PasscodeClient.ts.html
index 147caf35b..dab59b47d 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_PasscodeClient.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_PasscodeClient.ts.html
@@ -68,7 +68,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -90,8 +90,10 @@ lib/client/PasscodeClient.ts
import {
InvalidPasscodeError,
MaxNumOfPasscodeAttemptsReachedError,
+ PasscodeExpiredError,
TechnicalError,
TooManyRequestsError,
+ UnauthorizedError,
} from "../Errors";
import { Client } from "./Client";
@@ -120,36 +122,65 @@ lib/client/PasscodeClient.ts
* Causes the API to send a new passcode to the user's email address.
*
* @param {string} userID - The UUID of the user.
+ * @param {string=} emailID - The UUID of the email address. If unspecified, the email will be sent to the primary email address.
+ * @param {boolean=} force - Indicates the passcode should be sent, even if there is another active passcode.
* @return {Promise<Passcode>}
* @throws {TooManyRequestsError}
* @throws {RequestTimeoutError}
+ * @throws {UnauthorizedError}
* @throws {TechnicalError}
* @see https://docs.hanko.io/api/public#tag/Passcode/operation/passcodeInit
*/
- async initialize(userID: string): Promise<Passcode> {
- const response = await this.client.post("/passcode/login/initialize", {
- user_id: userID,
- });
+ async initialize(
+ userID: string,
+ emailID?: string,
+ force?: boolean
+ ): Promise<Passcode> {
+ this.state.read();
+
+ const lastPasscodeTTL = this.state.getTTL(userID);
+ const lastPasscodeID = this.state.getActiveID(userID);
+ const lastEmailID = this.state.getEmailID(userID);
+ let retryAfter = this.state.getResendAfter(userID);
+
+ if (!force && lastPasscodeTTL > 0 && emailID === lastEmailID) {
+ return {
+ id: lastPasscodeID,
+ ttl: lastPasscodeTTL,
+ };
+ }
- if (response.status === 429) {
- const retryAfter = parseInt(
- response.headers.get("X-Retry-After") || "0",
- 10
- );
+ if (retryAfter > 0) {
+ throw new TooManyRequestsError(retryAfter);
+ }
+
+ const body: any = { user_id: userID };
+
+ if (emailID) {
+ body.email_id = emailID;
+ }
- this.state.read().setResendAfter(userID, retryAfter).write();
+ const response = await this.client.post(`/passcode/login/initialize`, body);
+
+ if (response.status === 429) {
+ retryAfter = response.parseXRetryAfterHeader();
+ this.state.setResendAfter(userID, retryAfter).write();
throw new TooManyRequestsError(retryAfter);
+ } else if (response.status === 401) {
+ throw new UnauthorizedError();
} else if (!response.ok) {
throw new TechnicalError();
}
- const passcode = response.json();
+ const passcode: Passcode = response.json();
+
+ this.state.setActiveID(userID, passcode.id).setTTL(userID, passcode.ttl);
+
+ if (emailID) {
+ this.state.setEmailID(userID, emailID);
+ }
- this.state
- .read()
- .setActiveID(userID, passcode.id)
- .setTTL(userID, passcode.ttl)
- .write();
+ this.state.write();
return passcode;
}
@@ -168,6 +199,12 @@ lib/client/PasscodeClient.ts
*/
async finalize(userID: string, code: string): Promise<void> {
const passcodeID = this.state.read().getActiveID(userID);
+ const ttl = this.state.getTTL(userID);
+
+ if (ttl <= 0) {
+ throw new PasscodeExpiredError();
+ }
+
const response = await this.client.post("/passcode/login/finalize", {
id: passcodeID,
code,
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_PasswordClient.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_PasswordClient.ts.html
index ff92b9234..629052b98 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_PasswordClient.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_PasswordClient.ts.html
@@ -68,7 +68,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -86,10 +86,12 @@ lib/client/PasswordClient.ts
import { PasswordState } from "../state/PasswordState";
+import { PasscodeState } from "../state/PasscodeState";
import {
InvalidPasswordError,
TechnicalError,
TooManyRequestsError,
+ UnauthorizedError,
} from "../Errors";
import { Client } from "./Client";
@@ -102,7 +104,8 @@ lib/client/PasswordClient.ts
* @extends {Client}
*/
class PasswordClient extends Client {
- state: PasswordState;
+ passwordState: PasswordState;
+ passcodeState: PasscodeState;
// eslint-disable-next-line require-jsdoc
constructor(api: string, timeout = 13000) {
@@ -111,7 +114,12 @@ lib/client/PasswordClient.ts
* @public
* @type {PasswordState}
*/
- this.state = new PasswordState();
+ this.passwordState = new PasswordState();
+ /**
+ * @public
+ * @type {PasscodeState}
+ */
+ this.passcodeState = new PasscodeState();
}
/**
@@ -134,18 +142,15 @@ lib/client/PasswordClient.ts
if (response.status === 401) {
throw new InvalidPasswordError();
} else if (response.status === 429) {
- const retryAfter = parseInt(
- response.headers.get("X-Retry-After") || "0",
- 10
- );
-
- this.state.read().setRetryAfter(userID, retryAfter).write();
-
+ const retryAfter = response.parseXRetryAfterHeader();
+ this.passwordState.read().setRetryAfter(userID, retryAfter).write();
throw new TooManyRequestsError(retryAfter);
} else if (!response.ok) {
throw new TechnicalError();
}
+ this.passcodeState.read().reset(userID).write();
+
return;
}
@@ -166,7 +171,9 @@ lib/client/PasswordClient.ts
password,
});
- if (!response.ok) {
+ if (response.status === 401) {
+ throw new UnauthorizedError();
+ } else if (!response.ok) {
throw new TechnicalError();
}
@@ -180,7 +187,7 @@ lib/client/PasswordClient.ts
* @return {number}
*/
getRetryAfter(userID: string) {
- return this.state.read().getRetryAfter(userID);
+ return this.passwordState.read().getRetryAfter(userID);
}
}
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_UserClient.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_UserClient.ts.html
index 3573fe73c..2c34551cc 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_UserClient.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_UserClient.ts.html
@@ -68,7 +68,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_WebauthnClient.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_WebauthnClient.ts.html
index 7bba37ddc..517afc536 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_WebauthnClient.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_WebauthnClient.ts.html
@@ -68,7 +68,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -85,7 +85,17 @@ lib/client/WebauthnClient.ts
- import { WebauthnState } from "../state/WebauthnState";
+ import {
+ create as createWebauthnCredential,
+ get as getWebauthnCredential,
+} from "@github/webauthn-json";
+
+import { WebauthnSupport } from "../WebauthnSupport";
+import { Client } from "./Client";
+import { PasscodeState } from "../state/PasscodeState";
+
+import { WebauthnState } from "../state/WebauthnState";
+
import {
InvalidWebauthnCredentialError,
TechnicalError,
@@ -93,13 +103,13 @@ lib/client/WebauthnClient.ts
WebauthnRequestCancelledError,
UserVerificationError,
} from "../Errors";
+
import {
- create as createWebauthnCredential,
- get as getWebauthnCredential,
-} from "@github/webauthn-json";
-import { Attestation, User, WebauthnFinalized } from "../Dto";
-import { WebauthnSupport } from "../WebauthnSupport";
-import { Client } from "./Client";
+ Attestation,
+ User,
+ WebauthnFinalized,
+ WebauthnCredentials,
+} from "../Dto";
/**
* A class that handles WebAuthn authentication and registration.
@@ -110,7 +120,8 @@ lib/client/WebauthnClient.ts
* @extends {Client}
*/
class WebauthnClient extends Client {
- state: WebauthnState;
+ webauthnState: WebauthnState;
+ passcodeState: PasscodeState;
controller: AbortController;
_getCredential = getWebauthnCredential;
@@ -123,7 +134,12 @@ lib/client/WebauthnClient.ts
* @public
* @type {WebauthnState}
*/
- this.state = new WebauthnState();
+ this.webauthnState = new WebauthnState();
+ /**
+ * @public
+ * @type {PasscodeState}
+ */
+ this.passcodeState = new PasscodeState();
}
/**
@@ -182,11 +198,13 @@ lib/client/WebauthnClient.ts
const finalizeResponse: WebauthnFinalized = assertionResponse.json();
- this.state
+ this.webauthnState
.read()
.addCredential(finalizeResponse.user_id, finalizeResponse.credential_id)
.write();
+ this.passcodeState.read().reset(userID).write();
+
return;
}
@@ -247,7 +265,7 @@ lib/client/WebauthnClient.ts
}
const finalizeResponse: WebauthnFinalized = attestationResponse.json();
- this.state
+ this.webauthnState
.read()
.addCredential(finalizeResponse.user_id, finalizeResponse.credential_id)
.write();
@@ -255,6 +273,81 @@ lib/client/WebauthnClient.ts
return;
}
+ /**
+ * Returns a list of all WebAuthn credentials assigned to the current user.
+ *
+ * @return {Promise<WebauthnCredentials>}
+ * @throws {UnauthorizedError}
+ * @throws {RequestTimeoutError}
+ * @throws {TechnicalError}
+ * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/listCredentials
+ */
+ async listCredentials(): Promise<WebauthnCredentials> {
+ const response = await this.client.get("/webauthn/credentials");
+
+ if (response.status === 401) {
+ throw new UnauthorizedError();
+ } else if (!response.ok) {
+ throw new TechnicalError();
+ }
+
+ return response.json();
+ }
+
+ /**
+ * Updates the WebAuthn credential.
+ *
+ * @param {string=} credentialID - The credential's UUID.
+ * @param {string} name - The new credential name.
+ * @return {Promise<void>}
+ * @throws {NotFoundError}
+ * @throws {UnauthorizedError}
+ * @throws {RequestTimeoutError}
+ * @throws {TechnicalError}
+ * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/updateCredential
+ */
+ async updateCredential(credentialID: string, name: string): Promise<void> {
+ const response = await this.client.patch(
+ `/webauthn/credentials/${credentialID}`,
+ {
+ name,
+ }
+ );
+
+ if (response.status === 401) {
+ throw new UnauthorizedError();
+ } else if (!response.ok) {
+ throw new TechnicalError();
+ }
+
+ return;
+ }
+
+ /**
+ * Deletes the WebAuthn credential.
+ *
+ * @param {string=} credentialID - The credential's UUID.
+ * @return {Promise<void>}
+ * @throws {NotFoundError}
+ * @throws {UnauthorizedError}
+ * @throws {RequestTimeoutError}
+ * @throws {TechnicalError}
+ * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/deleteCredential
+ */
+ async deleteCredential(credentialID: string): Promise<void> {
+ const response = await this.client.delete(
+ `/webauthn/credentials/${credentialID}`
+ );
+
+ if (response.status === 401) {
+ throw new UnauthorizedError();
+ } else if (!response.ok) {
+ throw new TechnicalError();
+ }
+
+ return;
+ }
+
/**
* Determines whether a credential registration ceremony should be performed. Returns 'true' when WebAuthn
* is supported and the user's credentials do not intersect with the credentials already known on the
@@ -270,7 +363,7 @@ lib/client/WebauthnClient.ts
return supported;
}
- const matches = this.state
+ const matches = this.webauthnState
.read()
.matchCredentials(user.id, user.webauthn_credentials);
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_PasscodeState.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_PasscodeState.ts.html
index 0f1724691..2ec323a87 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_PasscodeState.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_PasscodeState.ts.html
@@ -68,7 +68,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -95,11 +95,13 @@ lib/state/PasscodeState.ts
* @property {string=} id - The UUID of the active passcode.
* @property {number=} ttl - Timestamp until when the passcode is valid in seconds (since January 1, 1970 00:00:00 UTC).
* @property {number=} resendAfter - Seconds until a passcode can be resent.
+ * @property {emailID=} emailID - The email address ID.
*/
export interface LocalStoragePasscode {
id?: string;
ttl?: number;
resendAfter?: number;
+ emailID?: string;
}
/**
@@ -156,6 +158,29 @@ lib/state/PasscodeState.ts
return this;
}
+ /**
+ * Gets the UUID of the email address.
+ *
+ * @param {string} userID - The UUID of the user.
+ * @return {string}
+ */
+ getEmailID(userID: string): string {
+ return this.getState(userID).emailID;
+ }
+
+ /**
+ * Sets the UUID of the email address.
+ *
+ * @param {string} userID - The UUID of the user.
+ * @param {string} emailID - The UUID of the email address.
+ * @return {PasscodeState}
+ */
+ setEmailID(userID: string, emailID: string): PasscodeState {
+ this.getState(userID).emailID = emailID;
+
+ return this;
+ }
+
/**
* Removes the active passcode.
*
@@ -168,6 +193,7 @@ lib/state/PasscodeState.ts
delete passcode.id;
delete passcode.ttl;
delete passcode.resendAfter;
+ delete passcode.emailID;
return this;
}
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_PasswordState.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_PasswordState.ts.html
index 145a0e57d..a32145f76 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_PasswordState.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_PasswordState.ts.html
@@ -68,7 +68,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_State.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_State.ts.html
index ec38f817c..d8ca91080 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_State.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_State.ts.html
@@ -68,7 +68,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_UserState.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_UserState.ts.html
index 3dafebb40..f1fcbbf0e 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_UserState.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_UserState.ts.html
@@ -68,7 +68,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_WebauthnState.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_WebauthnState.ts.html
index fb89a8b87..7d88dfd44 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_WebauthnState.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_WebauthnState.ts.html
@@ -68,7 +68,7 @@
- Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ Documentation SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/spec/admin.yaml b/docs/static/spec/admin.yaml
index b20c7e925..3ad9e964f 100644
--- a/docs/static/spec/admin.yaml
+++ b/docs/static/spec/admin.yaml
@@ -50,43 +50,6 @@ paths:
'500':
$ref: '#/components/responses/InternalServerError'
/users/{id}:
- patch:
- summary: 'Update a user by ID'
- operationId: updateUser
- tags:
- - User Management
- parameters:
- - name: id
- in: path
- description: ID of the user
- required: true
- schema:
- $ref: '#/components/schemas/UUID4'
- requestBody:
- content:
- application/json:
- schema:
- type: object
- properties:
- email:
- type: string
- format: email
- status:
- type: string
- enum: [active, inactive]
- responses:
- '200':
- description: 'Updated user details'
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/User'
- '400':
- $ref: '#/components/responses/BadRequest'
- '404':
- $ref: '#/components/responses/NotFound'
- '500':
- $ref: '#/components/responses/InternalServerError'
delete:
summary: 'Delete a user by ID'
operationId: deleteUser
diff --git a/docs/static/spec/public.yaml b/docs/static/spec/public.yaml
index 71f01cc15..b3da9e06a 100644
--- a/docs/static/spec/public.yaml
+++ b/docs/static/spec/public.yaml
@@ -1,7 +1,7 @@
openapi: 3.0.0
info:
- version: '0.3.0'
+ version: '0.4.0'
title: 'Hanko Public API'
description: |
## Introduction
@@ -60,7 +60,8 @@ paths:
summary: 'Initialize passcode login'
description: |
Initialize a passcode login for the user identified by `user_id`. Sends an email
- containing the actual passcode to the user. Returns a representation of the passcode.
+ containing the actual passcode to the user's primary email address or to the address specified
+ through `email_id`. Returns a representation of the passcode.
operationId: passcodeInit
tags:
- Passcode
@@ -74,6 +75,11 @@ paths:
description: The ID of the user
allOf:
- $ref: '#/components/schemas/UUID4'
+ email_id:
+ description: The ID of the email address
+ allOf:
+ - $ref: '#/components/schemas/UUID4'
+ required: false
responses:
'200':
description: 'Successful passcode login initialization'
@@ -138,6 +144,8 @@ paths:
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
'408':
$ref: '#/components/responses/RequestTimeOut'
'410':
@@ -409,6 +417,95 @@ paths:
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalServerError'
+ /webauthn/credentials:
+ get:
+ summary: 'Get a list of WebAuthn credentials'
+ description: |
+ Returns a list of WebAuthn credentials assigned to the current user.
+ operationId: listCredentials
+ tags:
+ - WebAuthn
+ security:
+ - CookieAuth: [ ]
+ - BearerTokenAuth: [ ]
+ responses:
+ '200':
+ description: 'A list of WebAuthn credentials assigned to the current user'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WebauthnCredentials'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalServerError'
+ /webauthn/credentials/{id}:
+ patch:
+ summary: 'Updates a WebAuthn credential'
+ description: |
+ Updates the specified WebAuthn credential. Only credentials assigned to the current user can be updated.
+ operationId: updateCredential
+ tags:
+ - WebAuthn
+ security:
+ - CookieAuth: [ ]
+ - BearerTokenAuth: [ ]
+ parameters:
+ - name: id
+ in: path
+ description: ID of the WebAuthn credential
+ required: true
+ schema:
+ $ref: '#/components/schemas/UUID4'
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ name:
+ description: "A new credential name. Has no technical meaning, only serves as an identification aid for the user."
+ type: string
+ required: false
+ responses:
+ '201':
+ description: 'Credential updated successfully'
+ '400':
+ $ref: '#/components/responses/BadRequest'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '500':
+ $ref: '#/components/responses/InternalServerError'
+ delete:
+ summary: 'Deletes a WebAuthn credential'
+ description: |
+ Deletes the specified WebAuthn credential.
+ operationId: deleteCredential
+ tags:
+ - WebAuthn
+ security:
+ - CookieAuth: [ ]
+ - BearerTokenAuth: [ ]
+ parameters:
+ - name: id
+ in: path
+ description: ID of the WebAuthn credential
+ required: true
+ schema:
+ $ref: '#/components/schemas/UUID4'
+ responses:
+ '201':
+ description: 'Credential updated successfully'
+ '400':
+ $ref: '#/components/responses/BadRequest'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '500':
+ $ref: '#/components/responses/InternalServerError'
/.well-known/jwks.json:
get:
summary: 'Get JSON Web Key Set'
@@ -471,6 +568,8 @@ paths:
properties:
id:
$ref: '#/components/schemas/UUID4'
+ email_id:
+ $ref: '#/components/schemas/UUID4'
verified:
type: boolean
has_webauthn_credential:
@@ -532,7 +631,7 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/User'
+ $ref: '#/components/schemas/CreateUserResponse'
'400':
$ref: '#/components/responses/BadRequest'
'409':
@@ -567,6 +666,104 @@ paths:
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalServerError'
+ /emails:
+ get:
+ summary: 'Get a list of emails of the current user.'
+ operationId: listEmails
+ tags:
+ - Email Management
+ security:
+ - CookieAuth: [ ]
+ - BearerTokenAuth: [ ]
+ responses:
+ '200':
+ description: 'A list of emails assigned to the current user'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Emails'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalServerError'
+ post:
+ summary: 'Add a new email address to the current user.'
+ operationId: createEmail
+ tags:
+ - Email Management
+ security:
+ - CookieAuth: [ ]
+ - BearerTokenAuth: [ ]
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ address:
+ type: string
+ format: email
+ required:
+ - address
+ responses:
+ '201':
+ description: 'Email successfully added'
+ '400':
+ $ref: '#/components/responses/BadRequest'
+ '409':
+ $ref: '#/components/responses/Conflict'
+ '500':
+ $ref: '#/components/responses/InternalServerError'
+ /emails/{id}/set_primary:
+ post:
+ summary: 'Marks the email address as primary email'
+ operationId: setPrimaryEmail
+ tags:
+ - Email Management
+ security:
+ - CookieAuth: [ ]
+ - BearerTokenAuth: [ ]
+ parameters:
+ - name: id
+ in: path
+ description: ID of the email address
+ required: true
+ schema:
+ $ref: '#/components/schemas/UUID4'
+ responses:
+ '201':
+ description: 'Email has been set as primary'
+ '400':
+ $ref: '#/components/responses/BadRequest'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalServerError'
+ /emails/{id}:
+ delete:
+ summary: 'Delete an email address'
+ operationId: deleteEmail
+ tags:
+ - Email Management
+ security:
+ - CookieAuth: [ ]
+ - BearerTokenAuth: [ ]
+ parameters:
+ - name: id
+ in: path
+ description: ID of the email address
+ required: true
+ schema:
+ $ref: '#/components/schemas/UUID4'
+ responses:
+ '201':
+ description: 'Email has been deleted'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '409':
+ $ref: '#/components/responses/Conflict'
+ '500':
+ $ref: '#/components/responses/InternalServerError'
components:
responses:
BadRequest:
@@ -658,6 +855,13 @@ components:
description: Hanko Configuration
url: https://github.com/teamhanko/hanko/blob/main/backend/docs/Config.md
properties:
+ emails:
+ description: Controls the behavior regarding email addresses.
+ type: object
+ properties:
+ require_verification:
+ description: Require email verification after account registration and prevent signing in with unverified email addresses. Also, email addresses can only be marked as primary when they have been verified before.
+ type: boolean
password:
description: Configuration options concerning passwords
type: object
@@ -950,7 +1154,7 @@ components:
- ble
- internal
example: internal
- User:
+ GetUserResponse:
type: object
properties:
id:
@@ -983,6 +1187,114 @@ components:
type: string
format: base64url
example: Meprtysj5ZZrTlg0qiLbsZ168OtQMeGVAikVy2n1hvvG...
+ CreateUserResponse:
+ type: object
+ properties:
+ user_id:
+ description: "The ID of the newly created user"
+ allOf:
+ - $ref: '#/components/schemas/UUID4'
+ email_id:
+ description: "The ID of the newly created email address"
+ allOf:
+ - $ref: '#/components/schemas/UUID4'
+ User:
+ type: object
+ properties:
+ id:
+ description: The ID of the user
+ allOf:
+ - $ref: '#/components/schemas/UUID4'
+ email:
+ description: The email address of the user
+ type: string
+ format: email
+ created_at:
+ description: Time of creation of the the user
+ type: string
+ format: date-time
+ updated_at:
+ description: Time of last update of the user
+ type: string
+ format: date-time
+ webauthn_credentials:
+ description: List of registered Webauthn credentials
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ description: The ID of the Webauthn credential
+ type: string
+ format: base64url
+ example: Meprtysj5ZZrTlg0qiLbsZ168OtQMeGVAikVy2n1hvvG...
+ Emails:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ description: The ID of the email address
+ allOf:
+ - $ref: '#/components/schemas/UUID4'
+ address:
+ description: The email address
+ type: string
+ format: email
+ is_verified:
+ description: Indicated the email has been verified.
+ type: boolean
+ is_primary:
+ description: Indicates it's the primary email address.
+ type: boolean
+ example:
+ - id: 5333cc5b-c7c4-48cf-8248-9c184ac72b65
+ address: john.doe@example.com
+ is_verified: true
+ is_primary: false
+ WebauthnCredentials:
+ description: 'A list of WebAuthn credentials'
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ description: The ID of the Webauthn credential
+ type: string
+ format: base64url
+ example: Meprtysj5ZZrTlg0qiLbsZ168OtQMeGVAikVy2n1hvvG...
+ name:
+ description: The name of the credential. Can be updated by the user.
+ type: string
+ required: false
+ public_key:
+ description: The public key assigned to the credential.
+ type: boolean
+ aaguid:
+ description: The AAGUID of the authenticator.
+ type: boolean
+ transports:
+ description: Transports which may be used by the authenticator.
+ type: array
+ items:
+ type: string
+ enum:
+ - usb
+ - nfc
+ - ble
+ - internal
+ created_at:
+ description: Time of creation of the credential
+ type: string
+ format: date-time
+ example:
+ - id: 5333cc5b-c7c4-48cf-8248-9c184ac72b65
+ name: "iCloud"
+ public_key: "pQECYyagASFYIBblARCP_at3cmprjzQN1lJ..."
+ aaguid: "adce0002-35bc-c60a-648b-0b25f1f05503"
+ transports:
+ - internal
+ created_at: "022-12-06T21:26:06.535106Z"
WebauthnLoginResponse:
description: 'Response after a successful login with webauthn'
type: object
diff --git a/e2e/pages/LoginPasscode.ts b/e2e/pages/LoginPasscode.ts
index c1e3e7584..5969a1f8b 100644
--- a/e2e/pages/LoginPasscode.ts
+++ b/e2e/pages/LoginPasscode.ts
@@ -17,7 +17,7 @@ export class LoginPasscode extends BasePage {
this.signInButton = page.locator("button[type=submit]", {
hasText: "Sign in",
});
- this.sendNewCodeLink = page.locator("a", {
+ this.sendNewCodeLink = page.locator("button", {
hasText: "Send new code",
});
this.headline = page.locator("h1", { hasText: "Enter passcode" });
diff --git a/e2e/pages/LoginPassword.ts b/e2e/pages/LoginPassword.ts
index 1dae39d7b..ee23dda2a 100644
--- a/e2e/pages/LoginPassword.ts
+++ b/e2e/pages/LoginPassword.ts
@@ -15,8 +15,8 @@ export class LoginPassword extends BasePage {
this.signInButton = page.locator("button[type=submit]", {
hasText: "Sign in",
});
- this.backLink = page.locator("a", { hasText: "Back" });
- this.forgotPasswordLink = page.locator("a", {
+ this.backLink = page.locator("button", { hasText: "Back" });
+ this.forgotPasswordLink = page.locator("button", {
hasText: "Forgot your password?",
});
this.headline = page.locator("h1", { hasText: "Enter password" });
diff --git a/e2e/pages/RegisterAuthenticator.ts b/e2e/pages/RegisterAuthenticator.ts
index 18ab19623..0a297060a 100644
--- a/e2e/pages/RegisterAuthenticator.ts
+++ b/e2e/pages/RegisterAuthenticator.ts
@@ -13,7 +13,7 @@ export class RegisterAuthenticator extends BasePage {
this.setUpPasskeyButton = page.locator("button[type=submit]", {
hasText: "Save a passkey",
});
- this.skipLink = page.locator("a", {
+ this.skipLink = page.locator("button", {
hasText: "Skip",
});
this.headline = page.locator("h1", { hasText: "Save a passkey" });
diff --git a/e2e/pages/RegisterConfirm.ts b/e2e/pages/RegisterConfirm.ts
index 363ac27d0..634383358 100644
--- a/e2e/pages/RegisterConfirm.ts
+++ b/e2e/pages/RegisterConfirm.ts
@@ -10,7 +10,7 @@ export class RegisterConfirm extends BasePage {
constructor(page: Page) {
super(page);
- this.backLink = page.locator("a", { hasText: "Back" });
+ this.backLink = page.locator("button", { hasText: "Back" });
this.signUpButton = page.locator("button[type=submit]", {
hasText: "Sign up",
});
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index b6f51fc7e..dc548c9ff 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -23,7 +23,7 @@ COPY ./elements ./
RUN npm run build
FROM nginx:stable-alpine
-COPY --from=build /app/elements/dist/element.hanko-auth.js /usr/share/nginx/html
+COPY --from=build /app/elements/dist/elements.js /usr/share/nginx/html
COPY --from=build /app/frontend-sdk/dist/sdk.* /usr/share/nginx/html
COPY elements/nginx/default.conf /etc/nginx/conf.d/default.conf
diff --git a/frontend/elements/README.md b/frontend/elements/README.md
index b78396055..ee9442b9f 100644
--- a/frontend/elements/README.md
+++ b/frontend/elements/README.md
@@ -1,6 +1,6 @@
-# <hanko-auth> element
+# Hanko elements
-The `` element offers a complete user interface that will bring a modern login and registration experience
+Provides web components that will bring a modern login and registration experience
to your users. It integrates the [Hanko API](https://github.com/teamhanko/hanko/blob/main/backend/README.md), a backend
that provides the underlying functionalities.
@@ -9,6 +9,7 @@ that provides the underlying functionalities.
* Registration and login flows with and without passwords
* Passkey authentication
* Passcodes, a convenient way to recover passwords and verify email addresses
+* Email, Password and Passkey management
* Customizable UI
## Installation
@@ -28,19 +29,14 @@ pnpm install @teamhanko/hanko-elements
### Script
-The web component needs to be registered first. You can control whether it should be attached to the shadow DOM or not
-using the `shadow` property. It's set to true by default, and you will be able to use the CSS parts
-to change the appearance of the component.
-
-There is currently an issue with Safari browsers, which breaks the autocompletion feature of
-input fields when the component is shadow DOM attached. So if you want to make use of the conditional UI or other
-autocompletion features you must set `shadow` to false. The disadvantage is that the CSS parts are not working anymore, and you must
-style the component by providing your own CSS properties. CSS variables will work in both cases.
+The web components need to be registered first. You can control whether they should be attached to the shadow DOM or not
+using the `shadow` property. It's set to true by default, and it's possible to make use of the [CSS shadow parts](#css-shadow-parts)
+to change the appearance of the component. [CSS variables](#css-variables) will work in both cases.
Use as a module:
```typescript
-import { register } from "@teamhanko/hanko-elements/hanko-auth"
+import { register } from "@teamhanko/hanko-elements"
register({
shadow: true, // Set to false if you don't want the web component to be attached to the shadow DOM.
@@ -51,31 +47,30 @@ register({
With a script tag via CDN:
```html
-
+
```
-### Markup
+### <hanko-auth>
+
+A web component that handles user login and user registration.
+
+#### Markup
```html
```
-Please take a look at the [Hanko API](https://github.com/teamhanko/hanko/blob/main/backend/README.md) to see how to spin up the backend.
-
-Note that we're working on Hanko Cloud, so that you don't need to run the Hanko API by yourself and all you need is to
-do is adding the `` element to your page.
-
-## Attributes
+#### Attributes
- `api` the location where the Hanko API is running.
- `lang` Currently supported values are "en" for English and "de" for German. If the value is omitted, "en" is used.
- `experimental` A space-seperated list of experimental features to be enabled. See [experimental features](#experimental-features).
-## Events
+#### Events
These events bubble up through the DOM tree.
@@ -87,66 +82,84 @@ document.addEventListener('hankoAuthSuccess', () => {
})
```
-## Demo
-
-The animation below demonstrates how user registration with passwords enabled looks like. You can set up the flow you
-like using the [Hanko API](https://github.com/teamhanko/hanko/blob/main/backend/README.md) configuration file. The registration flow also includes email
-verification via passcodes and the registration of a passkey so that the user can log in without passwords or passcodes.
-
-
-
-## UI Customization
-
-### CSS Variables
-
-CSS variables can be used to style the `hanko-auth` element to your needs. Based on preset values and provided CSS
-variables, individual elements will be styled, including color shading for different UI states (e.g. hover, focus,..).
+### <hanko-profile>
-Note that colors must be provided as individual HSL values. We'll have to be patient, unfortunately, until
-broader browser support for relative colors arrives, which would allow native CSS colors to be used.
+A web component that allows to manage emails, passwords and passkeys.
-A list of all CSS variables including default values can be found below:
+#### Markup
-```css
-hanko-auth {
- --background-color-h: 0;
- --background-color-s: 0%;
- --background-color-l: 100%;
-
- --border-radius: 3px;
- --border-style: solid;
- --border-width: 1.5px;
-
- --brand-color-h: 351;
- --brand-color-s: 100%;
- --brand-color-l: 59%;
-
- --color-h: 0;
- --color-s: 0%;
- --color-l: 0%;
-
- --container-padding: 20px;
- --container-max-width: 600px;
+```html
+
+```
- --error-color-h: 351;
- --error-color-s: 100%;
- --error-color-l: 59%;
+#### Attributes
- --font-family: sans-serif;
- --font-size: 16px;
- --font-weight: 400;
+- `api` the location where the Hanko API is running.
+- `lang` Currently supported values are "en" for English and "de" for German. If the value is omitted, "en" is used.
- --headline-font-size: 30px;
- --headline-font-weight: 700;
+## UI Customization
- --input-height: 50px;
+### CSS Variables
- --item-margin: 15px 0;
+CSS variables can be used to style the `hanko-auth` and `hanko-profile` elements to your needs. A list of all CSS
+variables including default values can be found below:
- --lightness-adjust-dark: -30%;
- --lightness-adjust-dark-light: -10%;
- --lightness-adjust-light: 10%;
- --lightness-adjust-light-dark: 30%;
+```css
+hanko-auth, hanko-profile {
+ /* Color Scheme */
+ --color: #171717
+ --color-shade-1: #8f9095
+ --color-shade-2: #e5e6ef
+
+ --brand-color: #506cf0
+ --brand-color-shade-1: #6b84fb
+ --brand-contrast-color: white
+
+ --background-color: white
+ --error-color: #e82020
+ --link-color: #506cf0
+
+ /* Font Styles */
+ --font-weight: 400
+ --font-size: 14px
+ --font-family: sans-serif
+
+ /* Border Styles */
+ --border-radius: 4px
+ --border-style: solid
+ --border-width: 1px
+
+ /* Item Styles */
+ --item-height: 34px
+ --item-margin: .5rem 0
+
+ /* Container Styles */
+ --container-padding: 0
+ --container-max-width: 600px
+
+ /* Headline Styles */
+ --headline1-font-size: 24px
+ --headline1-font-weight: 600
+ --headline1-margin: 0 0 .5rem
+
+ --headline2-font-size: 14px
+ --headline2-font-weight: 600
+ --headline2-margin: 1rem 0 .25rem
+
+ /* Divider Styles */
+ --divider-padding: 0 42px
+ --divider-display: block
+ --divider-visibility: visible
+
+ /* Link Styles */
+ --link-text-decoration: none
+ --link-text-decoration-hover: underline
+
+ /* Input Styles */
+ --input-min-width: 12em
+
+ /* Button Styles */
+ --button-min-width: max-content
}
```
@@ -215,66 +228,6 @@ autocompletion of input elements while the web component is attached to the shad
attach the component to the shadow DOM and make use of CSS parts for UI customization when the CSS variables are not
sufficient.
-### Example
-
-The example below shows how you can use CSS variables in combination with styled shadow DOM parts:
-
-```css
-hanko-auth {
- --color-h: 188;
- --color-s: 99%;
- --color-l: 38%;
-
- --brand-color-h: 315;
- --brand-color-s: 100%;
- --brand-color-l: 59%;
-
- --background-color-h: 196;
- --background-color-s: 10%;
- --background-color-l: 21%;
-
- --border-width: 1px;
- --border-radius: 5px;
-
- --font-weight: 400;
- --font-size: 16px;
- --font-family: Helvetica;
-
- --input-height: 45px;
- --item-margin: 10px;
-
- --container-max-width: 450px;
- --container-padding: 10px 20px;
-
- --headline-font-weight: 800;
- --headline-font-size: 24px;
-
- --lightness-adjust-dark: 30%;
- --lightness-adjust-dark-light: 10%;
- --lightness-adjust-light: -10%;
- --lightness-adjust-light-dark: 30%;
-}
-
-hanko-auth::part(headline),
-hanko-auth::part(input),
-hanko-auth::part(link) {
- color: hsl(33, 93%, 55%);
-}
-
-hanko-auth::part(link):hover {
- text-decoration: underline;
-}
-
-hanko-auth::part(button):hover,
-hanko-auth::part(input):focus {
- border-width: 2px;
-}
-```
-
-Result:
-
-
-
## Experimental Features
### Conditional Mediation / Autofill assisted Requests
@@ -301,7 +254,7 @@ cause the following issues:
## Frontend framework integrations
-To learn more about how to integrate the `` element into frontend frameworks, see our
+To learn more about how to integrate the Hanko elements into frontend frameworks, see our
[guides](https://docs.hanko.io/guides/frontend) in the official documentation and our
[example applications](../../examples/README.md).
diff --git a/frontend/elements/demo-ui.png b/frontend/elements/demo-ui.png
deleted file mode 100644
index 4e90e89192f3ce6a26f3ead37ab718e8095d998f..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 51741
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