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 @@ 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 @@ @@ -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 @@ 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 @@ 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 @@ @@ -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 + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+
+
+ +
+
+
+

Interface

+

Email

+
+ + + + + +
+ +
+ +

Email

+ + +
+ +
+
+ + + + + + +
Properties:
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
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

+
+ + + + + +
+ +
+ +

EmailAddressAlreadyExistsError()

+ +
An 'EmailAddressAlreadyExistsError' occurs when the user tries to add a new email address which already exists.
+ + +
+ +
+
+ + +
+
+
+
+ Constructor +
+ + + + +

+ # + + + + new EmailAddressAlreadyExistsError() + + +

+ + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ View Source + + lib/Errors.ts, line 240 + +

+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ + +

Extends

+ + + + + + + + + + + + + + + + + + + + +
+

Members

+
+ +
+ + + + +Error + + + + +

+ # + + + cause + + + Optional + +

+ + + + + + + + +
+ + + + + + + + +
Overrides:
+
+ + + + + + + + + + + + + + + + + + + + + + + + +

+ View Source + + lib/Errors.ts, line 27 + +

+ +
+ + + + + +
+ +
+ + + + +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 + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+
+
+ +
+
+
+

Class

+

EmailClient

+
+ + + + + +
+ +
+ +

EmailClient()

+ +
Manages email addresses of the current user.
+ + +
+ +
+
+ + +
+
+
+
+ Constructor +
+ + + + +

+ # + + + + new EmailClient() + + +

+ + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ View Source + + lib/client/EmailClient.ts, line 12 + +

+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ + +

Extends

+ + + + + + + + + + + + + + + + + + + + +
+

Members

+
+ +
+ + + + +HttpClient + + + + +

+ # + + + client + + +

+ + + + + + + + +
+ + + + + + +
Inherited From:
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

+ View Source + + lib/client/Client.ts, line 20 + +

+ +
+ + + + + +
+ +
+
+ + + +
+

Methods

+
+ +
+ + + +

+ # + + + async + + + + + create(address) → {Promise.<Email>} + + +

+ + + + +
+ Adds a new email address to the current user. +
+ + + + + + + + + + +
Parameters:
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
address + + +string + + + + The email address to be added.
+
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
See:
+
+ +
+ + + + + +

+ View Source + + lib/client/EmailClient.ts, line 133 + +

+ +
+ + + + + + + + + + + + + + + + +
+
+
+ + +
+ + + + + +
+ + + +
+ + + + + +
+ + + +
+ + +
+ +UnauthorizedError + + +
+ + +
+ + + +
+ + +
+ +RequestTimeoutError + + +
+ + +
+ + + +
+ + +
+ +TechnicalError + + +
+ + +
+ + +
+
+ + + +
+
+
+ + + +
+ + +
+ + +Promise.<Email> + + +
+ +
+ + +
+
+ + + + +
+ +
+ + + +

+ # + + + async + + + + + delete(emailID) → {Promise.<void>} + + +

+ + + + +
+ Deletes the specified email address. +
+ + + + + + + + + + +
Parameters:
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
emailID + + +string + + + + The ID of the email address to be deleted
+
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
See:
+
+ +
+ + + + + +

+ View Source + + lib/client/EmailClient.ts, line 159 + +

+ +
+ + + + + + + + + + + + + + + + +
+
+
+ + +
+ + +
+ +UnauthorizedError + + +
+ + +
+ + + +
+ + +
+ +RequestTimeoutError + + +
+ + +
+ + + +
+ + +
+ +TechnicalError + + +
+ + +
+ + +
+
+ + + +
+
+
+ + + +
+ + +
+ + +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 + +

+ +
+ + + + + + + + + + + + + + + + +
+
+
+ + +
+ + +
+ +UnauthorizedError + + +
+ + +
+ + + +
+ + +
+ +RequestTimeoutError + + +
+ + +
+ + + +
+ + +
+ +TechnicalError + + +
+ + +
+ + +
+
+ + + +
+
+
+ + + +
+ + +
+ + +Promise.<Emails> + + +
+ +
+ + +
+
+ + + + +
+ +
+ + + +

+ # + + + async + + + + + setPrimaryEmail(emailID) → {Promise.<void>} + + +

+ + + + +
+ Marks the specified email address as primary. +
+ + + + + + + + + + +
Parameters:
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
emailID + + +string + + + + The ID of the email address to be updated
+
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
See:
+
+ +
+ + + + + +

+ View Source + + lib/client/EmailClient.ts, line 146 + +

+ +
+ + + + + + + + + + + + + + + + +
+
+
+ + +
+ + +
+ +UnauthorizedError + + +
+ + +
+ + + +
+ + +
+ +RequestTimeoutError + + +
+ + +
+ + + +
+ + +
+ +TechnicalError + + +
+ + +
+ + +
+
+ + + +
+
+
+ + + +
+ + +
+ + +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

+
+ + + + + +
+ +
+ +

EmailConfig

+ + +
+ +
+
+ + + + + + +
Properties:
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
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 + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+
+
+ +
+
+
+

Interface

+

Emails

+
+ + + + + +
+ +
+ +

Emails

+ + +
+ +
+
+ + + + + + +
Properties:
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypeDescription
+ + +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 @@ @@ -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 + +

+ + + + + + + + + +
+ + + + +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 @@

@@ -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 @@ 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 @@ @@ -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 @@ @@ -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:
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDescription
path + + +string + + + + + + + + + + The path to the requested resource.
body + + +any + + + + + + <optional>
+ + + + + +
The request body.
+
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ View Source + + lib/client/HttpClient.ts, line 311 + +

+ +
+ + + + + + + + + + + + + + + + +
+
+
+ + +
+ + +
+ +RequestTimeoutError + + +
+ + +
+ + + +
+ + +
+ +TechnicalError + + +
+ + +
+ + +
+
+ + + +
+
+
+ + + +
+ + +
+ + +Promise.<Response> + + +
+ +
+ + +
+
+ + + + + + +
+ + +

# @@ -442,7 +695,260 @@

Parameters:

View Source - lib/client/HttpClient.ts, line 215 + lib/client/HttpClient.ts, line 267 + +

+ + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ + +
+ +RequestTimeoutError + + +
+ + +
+ + + +
+ + +
+ +TechnicalError + + +
+ + +
+ + +
+
+ + + +
+
+
+ + + +
+ + +
+ + +Promise.<Response> + + +
+ +
+ + +
+
+ + + + +
+ +
+ + + +

+ # + + + + patch(path, bodyopt) → {Promise.<Response>} + + +

+ + + + +
+ Performs a PATCH request. +
+ + + + + + + + + + +
Parameters:
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDescription
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 @@
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 @@ 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 @@ 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 @@ 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 @@ @@ -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 @@ 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 @@ 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 @@ 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 @@ 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

+
+ + + + + +
+ +
+ +

MaxNumOfEmailAddressesReachedError()

+ +
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.
+ + +
+ +
+
+ + +
+
+
+
+ Constructor +
+ + + + +

+ # + + + + new MaxNumOfEmailAddressesReachedError() + + +

+ + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ View Source + + lib/Errors.ts, line 226 + +

+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ + +

Extends

+ + + + + + + + + + + + + + + + + + + + +
+

Members

+
+ +
+ + + + +Error + + + + +

+ # + + + cause + + + Optional + +

+ + + + + + + + +
+ + + + + + + + +
Overrides:
+
+ + + + + + + + + + + + + + + + + + + + + + + + +

+ View Source + + lib/Errors.ts, line 27 + +

+ +
+ + + + + +
+ +
+ + + + +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 @@ 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 @@ 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 @@ @@ -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 @@ @@ -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:
+
+ + +
+ +UnauthorizedError + + +
+ + +
+ + +
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 @@
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 @@ @@ -436,7 +436,178 @@
Parameters:

View Source - lib/state/PasscodeState.ts, line 145 + lib/state/PasscodeState.ts, line 167 + +

+ + + + + + + + + + + + + + + + + + + + +
+
+
+ + + +
+ + +
+ + +string + + +
+ +
+ + +
+
+ + + + + + +
+ + + +

+ # + + + + getEmailID(userID) → {string} + + +

+ + + + +
+ Gets the UUID of the email address. +
+ + + + + + + + + + +
Parameters:
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
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 + +

+ +
+ + + + + + + + + + + + + + + + + + +
+
+
+ + + +
+ + +
+ + +PasscodeState + + +
+ +
+ + +
+
+ + + + +
+ +
+ + + +

+ # + + + + setEmailID(userID, emailID) → {PasscodeState} + + +

+ + + + +
+ Sets the UUID of the email address. +
+ + + + + + + + + + +
Parameters:
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
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 @@
@@ -167,7 +167,7 @@

View Source - lib/client/PasswordClient.ts, line 13 + lib/client/PasswordClient.ts, line 14

@@ -305,6 +305,79 @@

+ + +
+ + + + +PasscodeState + + + + +

+ # + + + passcodeState + + +

+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ View Source + + lib/client/PasswordClient.ts, line 29 + +

+ +
+ + + + +
@@ -317,11 +390,11 @@

-

- # +

+ # - state + passwordState

@@ -368,7 +441,7 @@

View Source - lib/client/PasswordClient.ts, line 22 + lib/client/PasswordClient.ts, line 24

@@ -509,7 +582,7 @@

Parameters:

View Source - lib/client/PasswordClient.ts, line 125 + lib/client/PasswordClient.ts, line 137

@@ -716,7 +789,7 @@
Parameters:

View Source - lib/client/PasswordClient.ts, line 103 + lib/client/PasswordClient.ts, line 115

@@ -975,7 +1048,7 @@
Parameters:

View Source - lib/client/PasswordClient.ts, line 117 + lib/client/PasswordClient.ts, line 129

diff --git a/docs/static/jsdoc/hanko-frontend-sdk/PasswordConfig.html b/docs/static/jsdoc/hanko-frontend-sdk/PasswordConfig.html index 2054850c7..ee0f6cb83 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/PasswordConfig.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/PasswordConfig.html @@ -66,7 +66,7 @@
@@ -144,6 +144,29 @@

Properties:
+ + + + min_password_length + + + + + +number + + + + + + + + + + The minimum length of a password. To be used for password validation. + + + diff --git a/docs/static/jsdoc/hanko-frontend-sdk/PasswordState.html b/docs/static/jsdoc/hanko-frontend-sdk/PasswordState.html index 0af1d1722..252af2a1f 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/PasswordState.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/PasswordState.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/RequestTimeoutError.html b/docs/static/jsdoc/hanko-frontend-sdk/RequestTimeoutError.html index 6336e4d76..5cb56a19a 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/RequestTimeoutError.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/RequestTimeoutError.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Response.html b/docs/static/jsdoc/hanko-frontend-sdk/Response.html index 46ca044ab..27e05740f 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/Response.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/Response.html @@ -66,7 +66,7 @@ @@ -337,7 +337,7 @@

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 @@

+ + +
+ + + +

+ # + + + + parseXRetryAfterHeader() → {number} + + +

+ + + + +
+ Returns the value for X-Retry-After contained in the response header. +
+ + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ View Source + + lib/client/HttpClient.ts, line 256 + +

+ +
+ + + + + + + + + + + + + + + + + + +
+
+
+ + + +
+ + +
+ + +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 @@ 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 @@ 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 @@ 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 @@ 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 @@ @@ -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 @@ 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 @@ @@ -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 @@ 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 @@ 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 @@ @@ -167,7 +167,7 @@

View Source - lib/client/WebauthnClient.ts, line 15 + lib/client/WebauthnClient.ts, line 16

@@ -305,6 +305,79 @@

+ + +
+ + + + +PasscodeState + + + + +

+ # + + + passcodeState + + +

+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ View Source + + lib/client/WebauthnClient.ts, line 34 + +

+ +
+ + + + +
@@ -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 @@

-
- - -
- -UnauthorizedError - - -
- - -
- - -
@@ -856,21 +870,6 @@

-

- - - -
- - -
- -UserVerificationError - - -
- -
@@ -891,7 +890,7 @@

-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:
- -
-
+
-
+ + + + +
+ + + +
-
- + + + + +
+ + + +
+ + +
+ +RequestTimeoutError + + +
+ + +
+ + + +
+ + +
+ +TechnicalError + + +
+ + +
+ + +
+
+ + + +
+
+
+ + + +
+ + +
+ + +Promise.<void> + + +
+ +
+ + +
+
+ + + + +
+ +
+ + + +

+ # + + + async + + + + + register() → {Promise.<void>} + + +

+ + + + +
+ Performs a WebAuthn registration ceremony. +
+ + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
See:
+
+ +
+ + + + + +

+ View Source + + lib/client/WebauthnClient.ts, line 269 + +

+ +
+ + + + + + + + + + + + + + + + +
+
+
+ + +
+ + + + + +
+ + + +
+ + +
+ +RequestTimeoutError + + +
+ + +
+ + + +
+ + +
+ +UnauthorizedError + + +
+ + +
+ + + +
+ + +
+ +TechnicalError + + +
+ + +
+ + + +
+ + +
+ +UserVerificationError + + +
+ + +
+ + +
+
+ + + +
+
+
+ + + +
+ + +
+ + +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:
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
user + + +User + + + + The user object.
+
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ View Source + + lib/client/WebauthnClient.ts, line 321 + +

+ +
+ + + + + + + + + + + + + + + + + + +
+
+
+ + + +
+ + +
+ Promise.<boolean> @@ -1082,6 +1599,300 @@
Parameters:
+
+ +
+ + + +

+ # + + + async + + + + + updateCredential(credentialIDopt, name) → {Promise.<void>} + + +

+ + + + +
+ Updates the WebAuthn credential. +
+ + + + + + + + + + +
Parameters:
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDescription
credentialID + + +string + + + + + + <optional>
+ + + + + +
The credential's UUID.
name + + +string + + + + + + + + + + The new credential name.
+
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
See:
+
+ +
+ + + + + +

+ View Source + + lib/client/WebauthnClient.ts, line 296 + +

+ +
+ + + + + + + + + + + + + + + + +
+
+
+ + +
+ + +
+ +NotFoundError + + +
+ + +
+ + + +
+ + +
+ +UnauthorizedError + + +
+ + +
+ + + +
+ + +
+ +RequestTimeoutError + + +
+ + +
+ + + +
+ + +
+ +TechnicalError + + +
+ + +
+ + +
+
+ + + +
+
+
+ + + +
+ + +
+ + +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:
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDescription
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:
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypeDescription
+ + +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 @@
@@ -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 @@
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 @@
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 @@ 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:
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypeDescription
+ + +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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ 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 @@ 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 @@ 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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ 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 @@ @@ -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 @@ @@ -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 @@ 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 @@ 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 @@ 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 @@ 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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

    aFApX|~LowU{){VnpJq34G$I34udW~=wzAN@@z zGJSZ3bs!n2oi+z+qOyToyJA)o6h$|koyCv5DUDBxBktz8cW-1S0RU8FRe0iyW*7w{ zMa}`WJDgyI^~k=S7TDbN89+fgN-46sw;6*$DZEH6ft!?ee76@)Vc|;T<)pdNV<-U3 zSs^~-62Ji*#URQon2S>|PVn1du3-MB&X)LsGI#``SlN>8BU!63#-kMnfrc6^Lg(P| z@7n{fldvn@B4w^Q9oS^(g4QrX0&jhSyL-b0!9kPr8)hgwL0;i?=O?*QzTFL0^c%P@ zvF;_GlfU?!S3PZ}JlS+ICvs~5JJKivWs`}92fEVyXREL1y@~c7;boxjI_Ox&X7L#} zr`zKJwk|M+tuII~!}PqtuPkRcj~`-&wzdH}SpH<`S6|%wYW{}xg(ItCOF$z>25|BK z`HSR$PxIH8WE3{vc0&=KHYD)>VKyf=ke+6u33f27qN+_XLoXYJS7;{tAK}?D$?w;pU z`&9LPvWjRy=ku4bDv<(xKc;=pi`NCJh6$?v{vVhWdj_@7R84Bx{X`YSq|mA!02m$v zNm0P?YB7q&J8vOX7?RY@SQanIo}x4gQ9rbl^HN-;N=%%(g@CC{^Pvpk*y=~?X_e_; zONW+=)U8ge%8bL5VXJ<18&O`#Ix8Jsy`XL8i_Bh>T0fZ#Ezut2w+uZ zb19>rg=l^XmRIEnq@$bSG@W6X>b!Q!*cP@%dW2S8&?_Bt5ou&}RyAjwGVa!|k;llZ zZ>Xf>9t)Z-2BxMENuAh<*FH|8)!ej@O?ZHKH6<1Rr?m!kS6BPCwfZKdn4m*Sa4H-j(c{ z{=xOg3-k$kC%gJ>g`n>C?8|Sa*w2@iXI+{}co~20d&NRZv{-Zd<*QHKW3BGd47QL# zW8o|1rkle0&4r+V6SBuVFY^(JOg~<{u%7pveBML>HXLBXt@;2Y(J5ytUR-I1RMmb@ z+ra4*!B_Ca23r)TeiX!yt$QHYhpTv7N?Mu>$g1z*(&o!UmZpNc>Yoba`uU2urRgwJ zQC~alT@`z2=7_)OS+D$Et!QbMo+bJ#CPl-LMVGpbqa;p4CUnXP+U=Kohr3e%>!RR6^xyk0HwZ+e~EwKzu7YaVAcz2*8ZO@-*S z|BFeXU!3rNpzZC_y%Xt|=CU3vj{EDi{d%3c>kmu{A_Ec3FpOcK@)$-!hDjd-qh??& znK&ZT)I6BEJcenR$FveMpZ>S|fq9=-3;~BBcOMO0fIKc#oGd9RDK9S<2n0f*u(h>S zDwRI{fJ6J;t^Qu-LWp=Bes>y)(=w4h?6;8q@#!YI<8P_p_ZjX#bDhgqpbdjdE<3i_ zd*3uW!r41zSh(b}8T+~pzpZ|J{!Bs)ki>{5Bqk?CrLMl1o??@AIm0>&j5RmTxd1N9 zgCk)G6gw>@;%2NN$|w_m5)FeN2jIrcH75~BQAi6N9 z7!8z`1)8Al+~pSSuFbC;60QUiuAW-{LAbjng8}b_GqD)1I+Pv6xm%7sedP1oh$vLrS%E{%b~MUpn>RGp4rr_(7wF(Qv=6=}vF0E5 zAr0;JrMqiBt^ogGA9D5>vok#Qe%y!E&~wkR0vrS}fH}bUxacs`q^#SWv!=zInrBc0 JrVIc`{{aZf?7;v4 diff --git a/frontend/elements/example.css b/frontend/elements/example.css index 5199221b6..0e7875707 100644 --- a/frontend/elements/example.css +++ b/frontend/elements/example.css @@ -1,97 +1,83 @@ -.hanko_checkmark { - display: inline-block; - width: 16px; - height: 16px; - transform: rotate(45deg) +.hanko_container { + background-color: var(--background-color, white); + padding: var(--container-padding, 0); + max-width: var(--container-max-width, 600px); + display: flex; + flex-direction: column; + flex-wrap: nowrap; + justify-content: center; + align-items: center; + align-content: flex-start; + box-sizing: border-box } -.hanko_checkmark .hanko_circle { +.hanko_content { box-sizing: border-box; - display: inline-block; - border-width: 2px; - border-style: solid; - border-color: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + 0%)); - position: absolute; - width: 16px; - height: 16px; - border-radius: 11px; - left: 0; - top: 0 + flex: 0 1 auto; + width: 100%; + height: 100% } -.hanko_checkmark .hanko_circle.hanko_secondary { - border-color: hsl(var(---background-color-h, 0), var(---background-color-s, 0%), calc(var(---background-color-l, 100%) + 0%)) +.hanko_footer { + padding: var(--item-margin, 0.5rem 0); + box-sizing: border-box; + width: 100% } -.hanko_checkmark .hanko_stem { - position: absolute; - width: 2px; - height: 7px; - background-color: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + 0%)); - left: 8px; - top: 3px +.hanko_footer :nth-child(1) { + float: left } -.hanko_checkmark .hanko_stem.hanko_secondary { - background-color: hsl(var(---background-color-h, 0), var(---background-color-s, 0%), calc(var(---background-color-l, 100%) + 0%)) +.hanko_footer :nth-child(2) { + float: right } -.hanko_checkmark .hanko_kick { - position: absolute; - width: 5px; - height: 2px; - background-color: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + 0%)); - left: 5px; - top: 10px +.hanko_form .hanko_ul { + padding-inline-start: 0; + list-style-type: none; + margin: 0 } -.hanko_checkmark .hanko_kick.hanko_secondary { - background-color: hsl(var(---background-color-h, 0), var(---background-color-s, 0%), calc(var(---background-color-l, 100%) + 0%)) +@media screen and (min-width: 450px) { + .hanko_form .hanko_ul { + display: flex + } } -.hanko_checkmark.hanko_fadeOut { - animation: hanko_fadeOut ease-out 1.5s forwards !important +.hanko_form .hanko_li { + display: flex } -@keyframes hanko_fadeOut { - 0% { - opacity: 1 +@media screen and (min-width: 450px) { + .hanko_form .hanko_li { + display: inline-flex } - 100% { - opacity: 0 + .hanko_form .hanko_li:first-child { + flex-grow: 1 } -} -.hanko_loadingWheel { - box-sizing: border-box; - display: inline-block; - border-width: 2px; - border-style: solid; - border-color: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%)); - border-top: 2px solid hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + 0%)); - border-radius: 50%; - width: 16px; - height: 16px; - animation: hanko_spin 500ms ease-in-out infinite -} - -@keyframes hanko_spin { - 0% { - transform: rotate(0deg) + .hanko_form .hanko_li:last-child { + margin: 0 0 0 1rem; + flex-grow: 0; + min-width: 125px } - 100% { - transform: rotate(360deg) + .hanko_form .hanko_li:only-child { + margin: 0; + flex-grow: 1 } } -.hanko_loadingIndicator { - display: inline-block; - margin: 0 5px -} - .hanko_button { + font-weight: var(--font-weight, 400); + font-size: var(--font-size, 14px); + font-family: var(--font-family, sans-serif); + border-radius: var(--border-radius, 4px); + border-style: var(--border-style, solid); + border-width: var(--border-width, 1px); + height: var(--item-height, 34px); + margin: var(--item-margin, 0.5rem 0); flex-grow: 1; outline: none; cursor: pointer; @@ -103,143 +89,83 @@ } .hanko_button.hanko_primary { - font-weight: var(--font-weight, 400); - font-size: var(--font-size, 16px); - font-family: var(--font-family, sans-serif); - color: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 0%) + 0%)); - background: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + 0%)); - border-width: var(--border-width, 1.5px); - border-style: var(--border-style, solid); - border-color: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + 0%)); - border-radius: var(--border-radius, 3px); - height: var(--input-height, 50px); - margin: var(--item-margin, 15px 0) + color: var(--brand-contrast-color, white); + background: var(--brand-color, #506cf0); + border-color: var(--brand-color, #506cf0) } .hanko_button.hanko_primary:hover { - color: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 0%) + 0%)); - background: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + var(--lightness-adjust-light-dark, 2%))); - border-width: var(--border-width, 1.5px); - border-style: var(--border-style, solid); - border-color: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + 0%)) + color: var(--brand-contrast-color, white); + background: var(--brand-color-shade-1, #6b84fb); + border-color: var(--brand-color, #506cf0) } .hanko_button.hanko_primary:focus { - color: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 0%) + var(--lightness-adjust-light-dark, 2%))); - background: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + 0%)); - border-width: var(--border-width, 1.5px); - border-style: var(--border-style, solid); - border-color: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + var(--lightness-adjust-light-dark, 2%))) + color: var(--brand-contrast-color, white); + background: var(--brand-color, #506cf0); + border-color: var(--color, #171717) } .hanko_button.hanko_primary:disabled { - color: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 0%) + var(--lightness-adjust-light-dark, 2%))); - background: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + var(--lightness-adjust-light, 5%))); - border-width: var(--border-width, 1.5px); - border-style: var(--border-style, solid); - border-color: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + var(--lightness-adjust-light, 5%))) + color: var(--color-shade-1, #8f9095); + background: var(--color-shade-2, #e5e6ef); + border-color: var(--color-shade-2, #e5e6ef) } .hanko_button.hanko_secondary { - font-weight: var(--font-weight, 400); - font-size: var(--font-size, 16px); - font-family: var(--font-family, sans-serif); - color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + 0%)); - background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%)); - border-width: var(--border-width, 1.5px); - border-style: var(--border-style, solid); - border-color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light, 5%))); - border-radius: var(--border-radius, 3px); - height: var(--input-height, 50px); - margin: var(--item-margin, 15px 0) + color: var(--color, #171717); + background: var(--background-color, white); + border-color: var(--color, #171717) } .hanko_button.hanko_secondary:hover { - color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + 0%)); - background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + var(--lightness-adjust-dark-light, -10%))); - border-width: var(--border-width, 1.5px); - border-style: var(--border-style, solid); - border-color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + 0%)) + color: var(--color, #171717); + background: var(--color-shade-2, #e5e6ef); + border-color: var(--color, #171717) } .hanko_button.hanko_secondary:focus { - color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light, 5%))); - background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%)); - border-width: var(--border-width, 1.5px); - border-style: var(--border-style, solid); - border-color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light-dark, 2%))) + color: var(--color, #171717); + background: var(--background-color, white); + border-color: var(--brand-color, #506cf0) } .hanko_button.hanko_secondary:disabled { - color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light-dark, 2%))); - background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + var(--lightness-adjust-light-dark, 2%))); - border-width: var(--border-width, 1.5px); - border-style: var(--border-style, solid); - border-color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light, 5%))) + color: var(--color-shade-1, #8f9095); + background: var(--color-shade-2, #e5e6ef); + border-color: var(--color-shade-1, #8f9095) } .hanko_inputWrapper { position: relative; - margin: var(--item-margin, 15px 0); + margin: var(--item-margin, 0.5rem 0); display: flex; flex-grow: 1 } -.hanko_label { - font-weight: var(--font-weight, 400); - font-size: var(--font-size, 16px); - font-family: var(--font-family, sans-serif); - background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%)); - color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light, 5%))); - left: 0; - top: 50%; - position: absolute; - transform: translateY(-50%); - padding: 0 .3rem; - margin: 0 .5rem; - transition: .1s ease; - transform-origin: left top; - pointer-events: none -} - .hanko_input { font-weight: var(--font-weight, 400); - font-size: var(--font-size, 16px); + font-size: var(--font-size, 14px); font-family: var(--font-family, sans-serif); - height: var(--input-height, 50px); - color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light, 5%))); + border-radius: var(--border-radius, 4px); border-style: var(--border-style, solid); - border-width: var(--border-width, 1.5px); - border-color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light, 5%))); - border-radius: var(--border-radius, 3px); - background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%)); - padding: 0 .7rem; - width: 100%; + border-width: var(--border-width, 1px); + height: var(--item-height, 34px); + color: var(--color, #171717); + border-color: var(--color-shade-1, #8f9095); + background: var(--background-color, white); + padding: 0 .5rem; outline: none; + width: 100%; box-sizing: border-box; transition: .1s ease-out } -.hanko_input:focus+.hanko_label { - color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + 0%)); - top: 0; - transform: translateY(-50%) scale(0.9) !important; - opacity: 1; - transition: opacity 1s; - -webkit-transition: opacity 1s -} - -.hanko_input:not(:placeholder-shown)+.hanko_label { - top: 0; - transform: translateY(-50%) scale(0.9) !important -} - -.hanko_input:-webkit-autofill { - -webkit-box-shadow: 0 0 0 50px hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%)) inset -} - -.hanko_input:-webkit-autofill::first-line { - color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + 0%)) +.hanko_input:-webkit-autofill, +.hanko_input:-webkit-autofill:hover, +.hanko_input:-webkit-autofill:focus { + -webkit-text-fill-color: var(--color, #171717); + -webkit-box-shadow: 0 0 0 50px var(--background-color, white) inset } .hanko_input::-ms-reveal, @@ -247,107 +173,116 @@ display: none } +.hanko_input::placeholder { + color: var(--color-shade-1, #8f9095) +} + .hanko_input:focus { - color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + 0%)); - border-style: var(--border-style, solid); - border-width: var(--border-width, 1.5px); - border-color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + 0%)) + color: var(--color, #171717); + border-color: var(--color, #171717) } .hanko_input:disabled { - color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-dark, -30%))); - background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + var(--lightness-adjust-dark-light, -10%))); - border-style: var(--border-style, solid); - border-width: var(--border-width, 1.5px); - border-color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-dark, -30%))) + color: var(--color-shade-1, #8f9095); + background: var(--color-shade-2, #e5e6ef); + border-color: var(--color-shade-1, #8f9095) } .hanko_passcodeInputWrapper { display: flex; justify-content: space-between; - margin: var(--item-margin, 15px 0) + margin: var(--item-margin, 0.5rem 0) } -.hanko_passcodeDigitWrapper { +.hanko_passcodeInputWrapper .hanko_passcodeDigitWrapper { flex-grow: 1; - margin: 0 10px 0 0 + margin: 0 .5rem 0 0 } -.hanko_passcodeDigitWrapper:last-child { +.hanko_passcodeInputWrapper .hanko_passcodeDigitWrapper:last-child { margin: 0 } -.hanko_passcodeDigitWrapper input { +.hanko_passcodeInputWrapper .hanko_passcodeDigitWrapper .hanko_input { text-align: center } -.hanko_title { - color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + 0%)); - font-family: var(--font-family, sans-serif); - font-size: var(--headline-font-size, 30px); - font-weight: var(--headline-font-weight, 700); - display: block; - margin: var(--item-margin, 15px 0); - text-align: left; - letter-spacing: 0; - font-style: normal +.hanko_checkmark { + display: inline-block; + width: 16px; + height: 16px; + transform: rotate(45deg) } -.hanko_content { +.hanko_checkmark .hanko_circle { box-sizing: border-box; - flex: 0 1 auto; - width: 100%; - height: 100% + display: inline-block; + border-width: 2px; + border-style: solid; + border-color: var(--brand-color, #506cf0); + position: absolute; + width: 16px; + height: 16px; + border-radius: 11px; + left: 0; + top: 0 } -.hanko_ul { - padding-inline-start: 0; - list-style-type: none; - margin: 0 +.hanko_checkmark .hanko_circle.hanko_secondary { + border-color: var(--color-shade-1, #8f9095) } -.hanko_li { - display: flex +.hanko_checkmark .hanko_stem { + position: absolute; + width: 2px; + height: 7px; + background-color: var(--brand-color, #506cf0); + left: 8px; + top: 3px } -.hanko_dividerWrapper { - font-weight: var(--font-weight, 400); - font-size: var(--font-size, 16px); - font-family: var(--font-family, sans-serif); - display: block; - visibility: visible; - margin: var(--item-margin, 15px 0); - color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light, 5%))) +.hanko_checkmark .hanko_stem.hanko_secondary { + background-color: var(--color-shade-1, #8f9095) } -.hanko_divider { - border-bottom: var(--border-width, 1.5px) var(--border-style, solid) hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light, 5%))); - color: inherit; - font: inherit; - width: 100%; - text-align: center; - line-height: .1em; - margin: 0 auto +.hanko_checkmark .hanko_kick { + position: absolute; + width: 5px; + height: 2px; + background-color: var(--brand-color, #506cf0); + left: 5px; + top: 10px } -.hanko_divider span { - font: inherit; - color: inherit; - background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%)); - padding: 0 42px +.hanko_checkmark .hanko_kick.hanko_secondary { + background-color: var(--color-shade-1, #8f9095) +} + +.hanko_checkmark.hanko_fadeOut { + animation: hanko_fadeOut ease-out 1.5s forwards !important +} + +@keyframes hanko_fadeOut { + 0% { + opacity: 1 + } + + 100% { + opacity: 0 + } } .hanko_exclamationMark { width: 16px; height: 16px; position: relative; - margin: 10px + margin: 5px } .hanko_exclamationMark .hanko_circle { box-sizing: border-box; display: inline-block; - background-color: hsl(var(--error-color-h, 351), var(--error-color-s, 100%), calc(var(--error-color-l, 59%) + 0%)); + background-color: var(--error-color, #e82020); position: absolute; width: 16px; height: 16px; @@ -360,7 +295,7 @@ position: absolute; width: 2px; height: 6px; - background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%)); + background: var(--background-color, white); left: 7px; top: 3px } @@ -369,21 +304,77 @@ position: absolute; width: 2px; height: 2px; - background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%)); + background: var(--background-color, white); left: 7px; top: 10px } +.hanko_loadingSpinnerWrapper { + display: inline-block; + margin: 0 5px +} + +.hanko_loadingSpinnerWrapper .hanko_loadingSpinner { + box-sizing: border-box; + display: inline-block; + border-width: 2px; + border-style: solid; + border-color: var(--background-color, white); + border-top: 2px solid var(--brand-color, #506cf0); + border-radius: 50%; + width: 16px; + height: 16px; + animation: hanko_spin 500ms ease-in-out infinite +} + +.hanko_loadingSpinnerWrapper .hanko_loadingSpinner.hanko_secondary { + border-color: var(--color-shade-1, #8f9095); + border-top: 2px solid var(--color-shade-2, #e5e6ef) +} + +@keyframes hanko_spin { + 0% { + transform: rotate(0deg) + } + + 100% { + transform: rotate(360deg) + } +} + +.hanko_headline { + color: var(--color, #171717); + font-family: var(--font-family, sans-serif); + text-align: left; + letter-spacing: 0; + font-style: normal; + line-height: 1.1 +} + +.hanko_headline.hanko_grade1 { + font-size: var(--headline1-font-size, 24px); + font-weight: var(--headline1-font-weight, 600); + margin: var(--headline1-margin, 0 0 0.5rem) +} + +.hanko_headline.hanko_grade2 { + font-size: var(--headline2-font-size, 14px); + font-weight: var(--headline2-font-weight, 600); + margin: var(--headline2-margin, 1rem 0 0.25rem) +} + .hanko_errorMessage { font-weight: var(--font-weight, 400); - font-size: var(--font-size, 16px); + font-size: var(--font-size, 14px); font-family: var(--font-family, sans-serif); - color: hsl(var(--error-color-h, 351), var(--error-color-s, 100%), calc(var(--error-color-l, 59%) + 0%)); - background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%)); - border: var(--border-width, 1.5px) var(--border-style, solid) hsl(var(--error-color-h, 351), var(--error-color-s, 100%), calc(var(--error-color-l, 59%) + 0%)); - border-radius: var(--border-radius, 3px); - padding: 5px; - margin: var(--item-margin, 15px 0); + border-radius: var(--border-radius, 4px); + border-style: var(--border-style, solid); + border-width: var(--border-width, 1px); + color: var(--error-color, #e82020); + background: var(--background-color, white); + padding: .25rem; + margin: var(--item-margin, 0.5rem 0); + min-height: var(--item-height, 34px); display: flex; align-items: center; box-sizing: border-box @@ -393,50 +384,163 @@ display: none } -.hanko_footer { - padding: var(--item-margin, 15px 0); +.hanko_paragraph { + font-weight: var(--font-weight, 400); + font-size: var(--font-size, 14px); + font-family: var(--font-family, sans-serif); + color: var(--color, #171717); + margin: var(--item-margin, 0.5rem 0); + text-align: left; + word-break: break-word +} + +.hanko_accordion { + font-weight: var(--font-weight, 400); + font-size: var(--font-size, 14px); + font-family: var(--font-family, sans-serif); + width: 100%; + overflow: hidden +} + +.hanko_accordion .hanko_accordionItem { + color: var(--color, #171717); + margin: .25rem 0; + overflow: hidden +} + +.hanko_accordion .hanko_accordionItem .hanko_label { + border-radius: var(--border-radius, 4px); + border-style: var(--border-style, solid); + border-width: var(--border-width, 1px); + border-color: var(--background-color, white); + height: var(--item-height, 34px); + background: var(--background-color, white); box-sizing: border-box; - width: 100% + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 1rem; + margin: 0; + cursor: pointer; + transition: all .35s } -.hanko_footer :nth-child(1) { - float: left +.hanko_accordion .hanko_accordionItem .hanko_label .hanko_labelText { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis } -.hanko_footer :nth-child(2) { - float: right +.hanko_accordion .hanko_accordionItem .hanko_label .hanko_labelText .hanko_description { + color: var(--color-shade-1, #8f9095) } -.hanko_paragraph { - font-weight: var(--font-weight, 400); - font-size: var(--font-size, 16px); - font-family: var(--font-family, sans-serif); - text-align: left; - color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + 0%)); - margin: var(--item-margin, 15px 0) +.hanko_accordion .hanko_accordionItem .hanko_label.hanko_dropdown { + color: var(--link-color, #506cf0); + justify-content: flex-start; + width: fit-content +} + +.hanko_accordion .hanko_accordionItem .hanko_label:hover { + color: var(--brand-contrast-color, white); + background: var(--brand-color-shade-1, #6b84fb) +} + +.hanko_accordion .hanko_accordionItem .hanko_label:hover .hanko_description { + color: var(--brand-contrast-color, white) +} + +.hanko_accordion .hanko_accordionItem .hanko_label:hover.hanko_dropdown { + color: var(--link-color, #506cf0); + border-color: var(--background-color, white); + background: none +} + +.hanko_accordion .hanko_accordionItem .hanko_label:not(.hanko_dropdown)::after { + content: "❯"; + width: 1rem; + text-align: center; + transition: all .35s +} + +.hanko_accordion .hanko_accordionItem .hanko_label.hanko_dropdown::before { + content: "+"; + width: 1em; + text-align: center; + transition: all .35s +} + +.hanko_accordion .hanko_accordionItem .hanko_accordionInput { + position: absolute; + opacity: 0; + z-index: -1 +} + +.hanko_accordion .hanko_accordionItem .hanko_accordionInput:checked+.hanko_label { + color: var(--brand-contrast-color, white); + background: var(--brand-color, #506cf0) +} + +.hanko_accordion .hanko_accordionItem .hanko_accordionInput:checked+.hanko_label .hanko_description { + color: var(--brand-contrast-color, white) +} + +.hanko_accordion .hanko_accordionItem .hanko_accordionInput:checked+.hanko_label.hanko_dropdown { + color: var(--link-color, #506cf0); + border-color: var(--background-color, white); + background: none +} + +.hanko_accordion .hanko_accordionItem .hanko_accordionInput:checked+.hanko_label:not(.hanko_dropdown)::after { + transform: rotate(90deg) +} + +.hanko_accordion .hanko_accordionItem .hanko_accordionInput:checked+.hanko_label.hanko_dropdown::before { + content: "-" +} + +.hanko_accordion .hanko_accordionItem .hanko_accordionInput:checked+.hanko_label~.hanko_accordionContent { + margin: .25rem 1rem; + opacity: 1; + max-height: 100vh +} + +.hanko_accordion .hanko_accordionItem .hanko_accordionContent { + max-height: 0; + margin: 0 1rem; + opacity: 0; + overflow: hidden; + transition: all .35s +} + +.hanko_accordion .hanko_accordionItem .hanko_accordionContent.hanko_dropdownContent { + border-style: none } .hanko_link { font-weight: var(--font-weight, 400); - font-size: var(--font-size, 16px); + font-size: var(--font-size, 14px); font-family: var(--font-family, sans-serif); - color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light, 5%))); - text-decoration: none; - cursor: pointer + color: var(--link-color, #506cf0); + text-decoration: var(--link-text-decoration, none); + cursor: pointer; + background: none !important; + border: none; + padding: 0 !important } .hanko_link:hover { - color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + 0%)); - text-decoration: underline + color: var(--link-color, #506cf0); + text-decoration: var(--link-text-decoration-hover, underline) } .hanko_link.hanko_disabled { - color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light-dark, 2%))); + color: var(--color, #171717); pointer-events: none; cursor: default } -.hanko_linkWithLoadingIndicator { +.hanko_linkWrapper { display: inline-flex; flex-direction: row; justify-content: space-between; @@ -444,19 +548,34 @@ height: 20px } -.hanko_linkWithLoadingIndicator.hanko_swap { +.hanko_linkWrapper.hanko_reverse { flex-direction: row-reverse } -.hanko_container { - background-color: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%)); - padding: var(--container-padding, 0 15px); - max-width: var(--container-max-width, 600px); - display: flex; - flex-direction: column; - flex-wrap: nowrap; - justify-content: center; - align-items: center; - align-content: flex-start; - box-sizing: border-box +.hanko_dividerWrapper { + font-weight: var(--font-weight, 400); + font-size: var(--font-size, 14px); + font-family: var(--font-family, sans-serif); + display: var(--divider-display, block); + visibility: var(--divider-visibility, visible); + color: var(--color-shade-1, #8f9095); + margin: var(--item-margin, 0.5rem 0) +} + +.hanko_divider { + border-bottom-style: var(--border-style, solid); + border-bottom-width: var(--border-width, 1px); + color: inherit; + font: inherit; + width: 100%; + text-align: center; + line-height: .1em; + margin: 0 auto +} + +.hanko_divider .hanko_text { + font: inherit; + color: inherit; + background: var(--background-color, white); + padding: var(--divider-padding, 0 42px) } diff --git a/frontend/elements/package-lock.json b/frontend/elements/package-lock.json index efad9b65c..2e80e722e 100644 --- a/frontend/elements/package-lock.json +++ b/frontend/elements/package-lock.json @@ -1,12 +1,12 @@ { "name": "@teamhanko/hanko-elements", - "version": "0.0.17-alpha", + "version": "0.1.0-alpha", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@teamhanko/hanko-elements", - "version": "0.0.17-alpha", + "version": "0.1.0-alpha", "bundleDependencies": [ "@teamhanko/hanko-frontend-sdk" ], @@ -40,7 +40,7 @@ }, "../frontend-sdk": { "name": "@teamhanko/hanko-frontend-sdk", - "version": "0.0.9-alpha", + "version": "0.1.0-alpha", "license": "MIT", "dependencies": { "@types/js-cookie": "^3.0.2" diff --git a/frontend/elements/package.json b/frontend/elements/package.json index 696a175cd..c2a349089 100644 --- a/frontend/elements/package.json +++ b/frontend/elements/package.json @@ -1,6 +1,6 @@ { "name": "@teamhanko/hanko-elements", - "version": "0.0.17-alpha", + "version": "0.1.0-alpha", "private": false, "publishConfig": { "access": "public" @@ -13,19 +13,19 @@ "dist" ], "browser": { - "./hanko-auth": "./dist/element.hanko-auth.js" + "./": "./dist/elements.js" }, "typesVersions": { "*": { - "hanko-auth": [ - "dist/ui/HankoAuth.d.ts" + "elements": [ + "dist/ui/Elements.d.ts" ] } }, "exports": { - "./hanko-auth": { - "import": "./dist/element.hanko-auth.js", - "require": "./dist/element.hanko-auth.js", + ".": { + "import": "./dist/elements.js", + "require": "./dist/elements.js", "types": "./dist/index.d.ts" } }, diff --git a/frontend/elements/src/Elements.tsx b/frontend/elements/src/Elements.tsx new file mode 100644 index 000000000..2ba2e16c0 --- /dev/null +++ b/frontend/elements/src/Elements.tsx @@ -0,0 +1,97 @@ +import * as preact from "preact"; +import registerCustomElement from "@teamhanko/preact-custom-element"; + +import AppProvider from "./contexts/AppProvider"; + +interface AdditionalProps { + api: string; +} + +export interface HankoAuthAdditionalProps extends AdditionalProps { + experimental?: string; +} + +export interface HankoProfileAdditionalProps extends AdditionalProps {} + +declare interface HankoAuthElementProps + extends preact.JSX.HTMLAttributes, + HankoAuthAdditionalProps {} + +declare interface HankoProfileElementProps + extends preact.JSX.HTMLAttributes, + HankoProfileAdditionalProps {} + +declare global { + // eslint-disable-next-line no-unused-vars + namespace JSX { + // eslint-disable-next-line no-unused-vars + interface IntrinsicElements { + "hanko-auth": HankoAuthElementProps; + "hanko-profile": HankoProfileElementProps; + } + } +} + +export const HankoAuth = (props: HankoAuthElementProps) => ( + +); + +export const HankoProfile = (props: HankoProfileElementProps) => ( + +); + +export interface RegisterOptions { + shadow?: boolean; + injectStyles?: boolean; +} + +export const register = async (options: RegisterOptions) => + await Promise.all([ + _register({ + ...options, + tagName: "hanko-auth", + entryComponent: HankoAuth, + observedAttributes: ["api", "lang", "experimental"], + }), + _register({ + ...options, + tagName: "hanko-profile", + entryComponent: HankoProfile, + observedAttributes: ["api", "lang"], + }), + ]); + +interface InternalRegisterOptions extends RegisterOptions { + tagName: string; + entryComponent: preact.FunctionalComponent; + observedAttributes: string[]; +} + +const _register = async ({ + tagName, + entryComponent, + shadow = true, + injectStyles = true, + observedAttributes, +}: InternalRegisterOptions) => { + if (!customElements.get(tagName)) { + registerCustomElement(entryComponent, tagName, observedAttributes, { + shadow, + }); + } + + if (injectStyles) { + await customElements.whenDefined(tagName); + const elements = document.getElementsByTagName(tagName); + const styles = window._hankoStyle; + + Array.from(elements).forEach((element) => { + if (shadow) { + const clonedStyles = styles.cloneNode(true); + element.shadowRoot.appendChild(clonedStyles); + } else { + element.appendChild(styles); + } + }); + } +}; diff --git a/frontend/elements/src/Translations.ts b/frontend/elements/src/Translations.ts new file mode 100644 index 000000000..b6858eadf --- /dev/null +++ b/frontend/elements/src/Translations.ts @@ -0,0 +1,208 @@ +export const translations = { + en: { + headlines: { + error: "An error has occurred", + loginEmail: "Sign in or sign up", + loginFinished: "Login successful", + loginPasscode: "Enter passcode", + loginPassword: "Enter password", + registerAuthenticator: "Save a passkey", + registerConfirm: "Create account?", + registerPassword: "Set new password", + profileEmails: "Emails", + profilePassword: "Password", + profilePasskeys: "Passkeys", + isPrimaryEmail: "Primary email address", + setPrimaryEmail: "Set primary email address", + emailVerified: "Verified", + emailUnverified: "Unverified", + emailDelete: "Delete", + renamePasskey: "Rename passkey", + deletePasskey: "Delete passkey", + createdAt: "Created at", + }, + texts: { + enterPasscode: 'Enter the passcode that was sent to "{emailAddress}".', + setupPasskey: + "Sign in to your account easily and securely with a passkey. Note: Your biometric data is only stored on your devices and will never be shared with anyone.", + createAccount: + 'No account exists for "{emailAddress}". Do you want to create a new account?', + passwordFormatHint: + "Must be between {minLength} and {maxLength} characters long.", + manageEmails: + "Your email addresses are used for communication and authentication.", + changePassword: "Set a new password.", + managePasskeys: "Your passkeys allow you to sign in to this account.", + isPrimaryEmail: + "Used for communication, passcodes, and as username for passkeys. To change the primary email address, add another email address first and set it as primary.", + setPrimaryEmail: + "Set this email address primary so it will be used for communications, for passcodes, and as a username for passkeys.", + emailVerified: "This email address has been verified.", + emailUnverified: "This email address has not been verified.", + emailDelete: + "If you delete this email address, it can no longer be used for signing in to your account. Passkeys that may have been created with this email address will remain intact.", + emailDeletePrimary: + "The primary email address cannot be deleted. Add another email address first and make it your primary email address.", + renamePasskey: + "Set a name for the passkey that helps you identify where it is stored.", + deletePasskey: + "Delete this passkey from your account. Note that the passkey will still exist on your devices and needs to be deleted there as well.", + }, + labels: { + or: "or", + email: "Email", + continue: "Continue", + skip: "Skip", + save: "Save", + password: "Password", + signInPassword: "Sign in with a password", + signInPasscode: "Sign in with a passcode", + forgotYourPassword: "Forgot your password?", + back: "Back", + signInPasskey: "Sign in with a passkey", + registerAuthenticator: "Save a passkey", + signIn: "Sign in", + signUp: "Sign up", + sendNewPasscode: "Send new code", + passwordRetryAfter: "Retry in {passwordRetryAfter}", + passcodeResendAfter: "Request a new code in {passcodeResendAfter}", + unverifiedEmail: "unverified", + primaryEmail: "primary", + setAsPrimaryEmail: "Set as primary", + verify: "Verify", + delete: "Delete", + newEmailAddress: "New email address", + newPassword: "New password", + rename: "Rename", + newPasskeyName: "New passkey name", + addEmail: "Add email", + changePassword: "Change password", + addPasskey: "Add passkey", + webauthnUnsupported: "Passkeys are not supported by your browser", + }, + errors: { + somethingWentWrong: + "A technical error has occurred. Please try again later.", + requestTimeout: "The request timed out.", + invalidPassword: "Wrong email or password.", + invalidPasscode: "The passcode provided was not correct.", + passcodeAttemptsReached: + "The passcode was entered incorrectly too many times. Please request a new code.", + tooManyRequests: + "Too many requests have been made. Please wait to repeat the requested operation.", + unauthorized: "Your session has expired. Please log in again.", + invalidWebauthnCredential: "Invalid WebAuthn credentials.", + passcodeExpired: "The passcode has expired. Please request a new one.", + userVerification: + "User verification required. Please ensure your authenticator device is protected with a PIN or biometric.", + emailAddressAlreadyExistsError: "The email address already exists.", + maxNumOfEmailAddressesReached: "No further email addresses can be added.", + }, + }, + de: { + headlines: { + error: "Ein Fehler ist aufgetreten", + loginEmail: "Anmelden / Registrieren", + loginFinished: "Login erfolgreich", + loginPasscode: "Passcode eingeben", + loginPassword: "Passwort eingeben", + registerAuthenticator: "Passkey einrichten", + registerConfirm: "Konto erstellen?", + registerPassword: "Neues Passwort eingeben", + profileEmails: "E-Mails", + profilePassword: "Passwort", + profilePasskeys: "Passkeys", + isPrimaryEmail: "Primäre E-Mail-Adresse", + setPrimaryEmail: "Als primäre E-Mail-Adresse festlegen", + emailVerified: "Verifiziert", + emailUnverified: "Unverifiziert", + emailDelete: "Löschen", + renamePasskey: "Passkey umbenennen", + deletePasskey: "Passkey löschen", + createdAt: "Erstellt am", + }, + texts: { + enterPasscode: + 'Geben Sie den Passcode ein, der an die E-Mail-Adresse "{emailAddress}" gesendet wurde.', + setupPasskey: + "Ihr Gerät unterstützt die sichere Anmeldung mit Passkeys. Hinweis: Ihre biometrischen Daten verbleiben sicher auf Ihrem Gerät und werden niemals an unseren Server gesendet.", + createAccount: + 'Es existiert kein Konto für "{emailAddress}". Möchten Sie ein neues Konto erstellen?', + passwordFormatHint: + "Das Passwort muss zwischen {minLength} und {maxLength} Zeichen lang sein.", + manageEmails: + "Ihre E-Mail-Adressen werden zur Kommunikation und Authentifizierung verwendet.", + changePassword: "Setze ein neues Passwort.", + managePasskeys: + "Passkeys können für die Anmeldung bei diesem Account verwendet werden.", + isPrimaryEmail: + "Wird für die Kommunikation, Passcodes und als Benutzername für Passkeys verwendet. Um die primäre E-Mail-Adresse zu ändern, fügen Sie zuerst eine andere E-Mail-Adresse hinzu und legen Sie sie als primär fest.", + setPrimaryEmail: + "Legen Sie diese E-Mail-Adresse als primär fest, damit sie für die Kommunikation, für Passcodes und als Benutzername für Passkeys genutzt wird.", + emailVerified: "Diese E-Mail-Adresse wurde verifiziert.", + emailUnverified: "Diese E-Mail-Adresse wurde noch nicht verifiziert.", + emailDelete: + "Wenn Sie diese E-Mail-Adresse löschen, kann sie nicht mehr für die Anmeldung bei Ihrem Konto verwendet werden. Passkeys, die möglicherweise mit dieser E-Mail-Adresse erstellt wurden, funktionieren weiterhin.", + emailDeletePrimary: + "Die primäre E-Mail-Adresse kann nicht gelöscht werden. Fügen Sie zuerst eine andere E-Mail-Adresse hinzu und legen Sie diese als primär fest.", + renamePasskey: + "Legen Sie einen Namen für den Passkey fest, anhand dessen Sie erkennen können, wo er gespeichert ist.", + deletePasskey: + "Löschen Sie diesen Passkey aus Ihrem Konto. Beachten Sie, dass der Passkey noch auf Ihren Geräten vorhanden ist und auch dort gelöscht werden muss.", + }, + labels: { + or: "oder", + email: "E-Mail", + continue: "Weiter", + skip: "Überspringen", + save: "Speichern", + password: "Passwort", + signInPassword: "Mit einem Passwort anmelden", + signInPasscode: "Mit einem Passcode anmelden", + forgotYourPassword: "Passwort vergessen?", + back: "Zurück", + signInPasskey: "Anmelden mit Passkey", + registerAuthenticator: "Passkey einrichten", + signIn: "Anmelden", + signUp: "Registrieren", + sendNewPasscode: "Neuen Code senden", + passwordRetryAfter: "Neuer Versuch in {passwordRetryAfter}", + passcodeResendAfter: "Neuen Code in {passcodeResendAfter} anfordern", + unverifiedEmail: "unverifiziert", + primaryEmail: "primär", + setAsPrimaryEmail: "Als primär festlegen", + verify: "Verifizieren", + delete: "Löschen", + newEmailAddress: "Neue E-Mail-Adresse", + newPassword: "Neues Passwort", + rename: "Umbenennen", + newPasskeyName: "Neuer Passkey Name", + addEmail: "E-Mail-Adresse hinzufügen", + changePassword: "Password ändern", + addPasskey: "Passkey hinzufügen", + webauthnUnsupported: + "Passkeys werden von ihrem Browser nicht unterrstützt", + }, + errors: { + somethingWentWrong: + "Ein technischer Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.", + requestTimeout: "Die Anfrage hat das Zeitlimit überschritten.", + invalidPassword: "E-Mail-Adresse oder Passwort falsch.", + invalidPasscode: "Der Passcode war nicht richtig.", + passcodeAttemptsReached: + "Der Passcode wurde zu oft falsch eingegeben. Bitte fragen Sie einen neuen Code an.", + tooManyRequests: + "Es wurden zu viele Anfragen gestellt. Bitte warten Sie, um den gewünschten Vorgang zu wiederholen.", + unauthorized: + "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", + invalidWebauthnCredential: "Ungültiger Berechtigungsnachweis", + passcodeExpired: + "Der Passcode ist abgelaufen. Bitte fordern Sie einen neuen Code an.", + userVerification: + "Nutzer-Verifikation erforderlich. Bitte stellen Sie sicher, dass Ihr Gerät durch eine PIN oder Biometrie abgesichert ist.", + emailAddressAlreadyExistsError: "Die E-Mail-Adresse existiert bereits.", + maxNumOfEmailAddressesReached: + "Es können keine weiteren E-Mail-Adressen hinzugefügt werden.", + }, + }, +}; diff --git a/frontend/elements/src/_mixins.sass b/frontend/elements/src/_mixins.sass new file mode 100644 index 000000000..1ff7619cb --- /dev/null +++ b/frontend/elements/src/_mixins.sass @@ -0,0 +1,12 @@ +@use 'variables' + +@mixin font + font-weight: variables.$font-weight + font-size: variables.$font-size + font-family: variables.$font-family + +@mixin border + border-radius: variables.$border-radius + border-style: variables.$border-style + border-width: variables.$border-width + diff --git a/frontend/elements/src/_preset.sass b/frontend/elements/src/_preset.sass new file mode 100644 index 000000000..ee373546e --- /dev/null +++ b/frontend/elements/src/_preset.sass @@ -0,0 +1,54 @@ +// 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: 14em + +// Button Styles +$button-min-width: max-content diff --git a/frontend/elements/src/_variables.sass b/frontend/elements/src/_variables.sass new file mode 100644 index 000000000..bcbddea71 --- /dev/null +++ b/frontend/elements/src/_variables.sass @@ -0,0 +1,56 @@ +@use 'preset' + +// Color Scheme +$color: var(--color, preset.$color) +$color-shade-1: var(--color-shade-1, preset.$color-shade-1) +$color-shade-2: var(--color-shade-2, preset.$color-shade-2) + +$brand-color: var(--brand-color, preset.$brand-color) +$brand-color-shade-1: var(--brand-color-shade-1, preset.$brand-color-shade-1) +$brand-contrast-color: var(--brand-contrast-color, preset.$brand-contrast-color) + +$background-color: var(--background-color, preset.$background-color) +$error-color: var(--error-color, preset.$error-color) +$link-color: var(--link-color, preset.$link-color) + +// Font Styles +$font-weight: var(--font-weight, preset.$font-weight) +$font-size: var(--font-size, preset.$font-size) +$font-family: var(--font-family, preset.$font-family) + +// Border Styles +$border-radius: var(--border-radius, preset.$border-radius) +$border-style: var(--border-style, preset.$border-style) +$border-width: var(--border-width, preset.$border-width) + +// Item Styles +$item-height: var(--item-height, preset.$item-height) +$item-margin: var(--item-margin, preset.$item-margin) + +// Container Styles +$container-padding: var(--container-padding, preset.$container-padding) +$container-max-width: var(--container-max-width, preset.$container-max-width) + +// Headline Styles +$headline1-font-weight: var(--headline1-font-weight, preset.$headline1-font-weight) +$headline1-font-size: var(--headline1-font-size, preset.$headline1-font-size) +$headline1-margin: var(--headline1-margin, preset.$headline1-margin) + +$headline2-font-weight: var(--headline2-font-weight, preset.$headline2-font-weight) +$headline2-font-size: var(--headline2-font-size, preset.$headline2-font-size) +$headline2-margin: var(--headline2-margin, preset.$headline2-margin) + +// Divider Styles +$divider-padding: var(--divider-padding, preset.$divider-padding) +$divider-display: var(--divider-display, preset.$divider-display) +$divider-visibility: var(--divider-visibility, preset.$divider-visibility) + +// Link Styles +$link-text-decoration: var(--link-text-decoration, preset.$link-text-decoration) +$link-text-decoration-hover: var(--link-text-decoration-hover, preset.$link-text-decoration-hover) + +// Input Styles +$input-min-width: var(--input-min-width, preset.$input-min-width) + +// Button Styles +$button-min-width: var(--button-min-width, preset.$button-min-width) diff --git a/frontend/elements/src/components/accordion/Accordion.tsx b/frontend/elements/src/components/accordion/Accordion.tsx new file mode 100644 index 000000000..170ca7abd --- /dev/null +++ b/frontend/elements/src/components/accordion/Accordion.tsx @@ -0,0 +1,72 @@ +import * as preact from "preact"; +import { h } from "preact"; +import { StateUpdater } from "preact/compat"; + +import cx from "classnames"; + +import styles from "./styles.sass"; + +type Selector = (item: T, itemIndex?: number) => string | h.JSX.Element; + +interface Props { + name: string; + columnSelector: Selector; + contentSelector: Selector; + checkedItemIndex?: number; + setCheckedItemIndex: StateUpdater; + data: Array; + dropdown?: boolean; +} + +const Accordion = function ({ + name, + columnSelector, + contentSelector, + data, + checkedItemIndex, + setCheckedItemIndex, + dropdown = false, +}: Props) { + const clickHandler = (event: Event) => { + if (!(event.target instanceof HTMLInputElement)) return; + const itemIndex = parseInt(event.target.value, 10); + setCheckedItemIndex(itemIndex === checkedItemIndex ? null : itemIndex); + event.preventDefault(); + }; + + return ( +

    + ); +}; + +export default Accordion; diff --git a/frontend/elements/src/components/accordion/AddEmailDropdown.tsx b/frontend/elements/src/components/accordion/AddEmailDropdown.tsx new file mode 100644 index 000000000..ddba3fa4d --- /dev/null +++ b/frontend/elements/src/components/accordion/AddEmailDropdown.tsx @@ -0,0 +1,158 @@ +import * as preact from "preact"; +import { + StateUpdater, + useCallback, + useContext, + useMemo, + useState, +} from "preact/compat"; + +import { + Email, + HankoError, + TooManyRequestsError, +} from "@teamhanko/hanko-frontend-sdk"; + +import { AppContext } from "../../contexts/AppProvider"; +import { TranslateContext } from "@denysvuika/preact-translate"; + +import Form from "../form/Form"; +import Input from "../form/Input"; +import Button from "../form/Button"; +import Dropdown from "./Dropdown"; + +import LoginPasscodePage from "../../pages/LoginPasscodePage"; +import ProfilePage from "../../pages/ProfilePage"; + +interface Props { + setError: (e: HankoError) => void; + checkedItemIndex?: number; + setCheckedItemIndex: StateUpdater; +} + +const AddEmailDropdown = ({ + setError, + checkedItemIndex, + setCheckedItemIndex, +}: Props) => { + const { t } = useContext(TranslateContext); + const { hanko, config, user, setEmails, setPage, setPasscode } = + useContext(AppContext); + + const [isSuccess, setIsSuccess] = useState(); + const [isLoading, setIsLoading] = useState(); + const [newEmail, setNewEmail] = useState(); + + const addEmail = (event: Event) => { + event.preventDefault(); + return config.emails.require_verification + ? addEmailWithVerification() + : addEmailWithoutVerification(); + }; + + const renderPasscode = useCallback( + (email: Email) => { + const onSuccessHandler = () => { + return hanko.email + .list() + .then(setEmails) + .then(() => setPage()); + }; + + const showPasscodePage = (e?: HankoError) => + setPage( + setPage()} + /> + ); + + return hanko.passcode + .initialize(user.id, email.id, true) + .then(setPasscode) + .then(() => showPasscodePage()) + .catch((e) => { + if (e instanceof TooManyRequestsError) { + showPasscodePage(e); + return; + } + throw e; + }); + }, + [hanko, newEmail, setEmails, setPage, setPasscode, user.id] + ); + + const addEmailWithVerification = () => { + setIsLoading(true); + hanko.email + .create(newEmail) + .then(renderPasscode) + .finally(() => setIsLoading(false)) + .catch(setError); + }; + + const addEmailWithoutVerification = () => { + hanko.email + .create(newEmail) + .then(() => hanko.email.list()) + .then(setEmails) + .then(() => { + setError(null); + setNewEmail(""); + setIsSuccess(true); + setTimeout(() => { + setCheckedItemIndex(null); + setTimeout(() => { + setIsSuccess(false); + }, 500); + }, 1000); + return; + }) + .catch(setError); + }; + + const onInputHandler = (event: Event) => { + event.preventDefault(); + if (event.target instanceof HTMLInputElement) { + setNewEmail(event.target.value); + } + }; + + const disabled = useMemo( + () => isSuccess || isLoading, + [isLoading, isSuccess] + ); + + return ( + +
    + + +
    +
    + ); +}; + +export default AddEmailDropdown; diff --git a/frontend/elements/src/components/accordion/AddPasskeyDropdown.tsx b/frontend/elements/src/components/accordion/AddPasskeyDropdown.tsx new file mode 100644 index 000000000..1831106a9 --- /dev/null +++ b/frontend/elements/src/components/accordion/AddPasskeyDropdown.tsx @@ -0,0 +1,85 @@ +import * as preact from "preact"; +import { StateUpdater, useContext, useState } from "preact/compat"; + +import { + WebauthnSupport, + HankoError, + WebauthnRequestCancelledError, +} from "@teamhanko/hanko-frontend-sdk"; + +import { AppContext } from "../../contexts/AppProvider"; +import { TranslateContext } from "@denysvuika/preact-translate"; + +import Form from "../form/Form"; +import Button from "../form/Button"; +import Paragraph from "../paragraph/Paragraph"; +import Dropdown from "./Dropdown"; + +interface Props { + setError: (e: HankoError) => void; + checkedItemIndex?: number; + setCheckedItemIndex: StateUpdater; +} + +const AddPasskeyDropdown = ({ + setError, + checkedItemIndex, + setCheckedItemIndex, +}: Props) => { + const { t } = useContext(TranslateContext); + const { hanko, setWebauthnCredentials } = useContext(AppContext); + + const [isLoading, setIsLoading] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + + const webauthnSupported = WebauthnSupport.supported(); + + const addPasskey = (event: Event) => { + event.preventDefault(); + setIsLoading(true); + hanko.webauthn + .register() + .then(() => hanko.webauthn.listCredentials()) + .then(setWebauthnCredentials) + .then(() => { + setError(null); + setIsSuccess(true); + setTimeout(() => { + setCheckedItemIndex(null); + setTimeout(() => { + setIsSuccess(false); + }, 500); + }, 1000); + return; + }) + .finally(() => setIsLoading(false)) + .catch((e) => { + if (!(e instanceof WebauthnRequestCancelledError)) { + setError(e); + } + }); + }; + + return ( + + {t("texts.setupPasskey")} +
    + +
    +
    + ); +}; + +export default AddPasskeyDropdown; diff --git a/frontend/elements/src/components/accordion/ChangePasswordDropdown.tsx b/frontend/elements/src/components/accordion/ChangePasswordDropdown.tsx new file mode 100644 index 000000000..7508c7575 --- /dev/null +++ b/frontend/elements/src/components/accordion/ChangePasswordDropdown.tsx @@ -0,0 +1,97 @@ +import * as preact from "preact"; +import { StateUpdater, useContext, useState } from "preact/compat"; + +import { HankoError } from "@teamhanko/hanko-frontend-sdk"; + +import { AppContext } from "../../contexts/AppProvider"; +import { TranslateContext } from "@denysvuika/preact-translate"; + +import Form from "../form/Form"; +import Input from "../form/Input"; +import Button from "../form/Button"; +import Paragraph from "../paragraph/Paragraph"; +import Dropdown from "./Dropdown"; + +interface Props { + setError: (e: HankoError) => void; + checkedItemIndex?: number; + setCheckedItemIndex: StateUpdater; +} + +const ChangePasswordDropdown = ({ + setError, + checkedItemIndex, + setCheckedItemIndex, +}: Props) => { + const { t } = useContext(TranslateContext); + const { hanko, config, user } = useContext(AppContext); + + const [isLoading, setIsLoading] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + const [newPassword, setNewPassword] = useState(""); + + const changePassword = (event: Event) => { + event.preventDefault(); + setIsLoading(true); + hanko.password + .update(user.id, newPassword) + .then(() => { + setNewPassword(""); + setError(null); + setIsSuccess(true); + setTimeout(() => { + setCheckedItemIndex(null); + setTimeout(() => { + setIsSuccess(false); + }, 500); + }, 1000); + return; + }) + .finally(() => setIsLoading(false)) + .catch(setError); + }; + + const onInputHandler = (event: Event) => { + event.preventDefault(); + if (event.target instanceof HTMLInputElement) { + setNewPassword(event.target.value); + } + }; + + return ( + + + {t("texts.passwordFormatHint", { + minLength: config.password.min_password_length, + maxLength: 72, + })} + +
    + + +
    +
    + ); +}; + +export default ChangePasswordDropdown; diff --git a/frontend/elements/src/components/accordion/Dropdown.tsx b/frontend/elements/src/components/accordion/Dropdown.tsx new file mode 100644 index 000000000..2f5972df8 --- /dev/null +++ b/frontend/elements/src/components/accordion/Dropdown.tsx @@ -0,0 +1,35 @@ +import * as preact from "preact"; +import { ComponentChildren, Fragment, h } from "preact"; +import { StateUpdater } from "preact/compat"; + +import Accordion from "./Accordion"; + +interface Props { + name: string; + title: string | h.JSX.Element; + children: ComponentChildren; + checkedItemIndex?: number; + setCheckedItemIndex: StateUpdater; +} + +const Dropdown = ({ + name, + title, + children, + checkedItemIndex, + setCheckedItemIndex, +}: Props) => { + return ( + title} + contentSelector={() => {children}} + setCheckedItemIndex={setCheckedItemIndex} + checkedItemIndex={checkedItemIndex} + data={[{}]} + /> + ); +}; + +export default Dropdown; diff --git a/frontend/elements/src/components/accordion/ListEmailsAccordion.tsx b/frontend/elements/src/components/accordion/ListEmailsAccordion.tsx new file mode 100644 index 000000000..c53484458 --- /dev/null +++ b/frontend/elements/src/components/accordion/ListEmailsAccordion.tsx @@ -0,0 +1,242 @@ +import * as preact from "preact"; +import { Fragment } from "preact"; +import { + StateUpdater, + useCallback, + useContext, + useMemo, + useState, +} from "preact/compat"; + +import { + Email, + HankoError, + TooManyRequestsError, +} from "@teamhanko/hanko-frontend-sdk"; + +import styles from "./styles.sass"; + +import { AppContext } from "../../contexts/AppProvider"; +import { TranslateContext } from "@denysvuika/preact-translate"; + +import Accordion from "./Accordion"; +import Paragraph from "../paragraph/Paragraph"; +import Headline2 from "../headline/Headline2"; +import Link from "../link/Link"; + +import ProfilePage from "../../pages/ProfilePage"; +import LoginPasscodePage from "../../pages/LoginPasscodePage"; + +interface Props { + setError: (e: HankoError) => void; + checkedItemIndex?: number; + setCheckedItemIndex: StateUpdater; +} + +const ListEmailsAccordion = ({ + setError, + checkedItemIndex, + setCheckedItemIndex, +}: Props) => { + const { t } = useContext(TranslateContext); + const { hanko, user, emails, setEmails, setPage, setPasscode } = + useContext(AppContext); + + const [isPrimaryEmailLoading, setIsPrimaryEmailLoading] = + useState(false); + const [isVerificationLoading, setIsVerificationLoading] = + useState(false); + const [isDeletionLoading, setIsDeletionLoading] = useState(false); + + const isDisabled = useMemo( + () => isPrimaryEmailLoading || isVerificationLoading || isDeletionLoading, + [isDeletionLoading, isPrimaryEmailLoading, isVerificationLoading] + ); + + const renderPasscode = useCallback( + (email: Email) => { + const onBackHandler = () => setPage(); + + const showPasscodePage = (e?: HankoError) => + setPage( + + hanko.email.list().then(setEmails).then(onBackHandler) + } + onBack={onBackHandler} + /> + ); + + return hanko.passcode + .initialize(user.id, email.id, true) + .then(setPasscode) + .then(() => showPasscodePage()) + .catch((e) => { + if (e instanceof TooManyRequestsError) { + showPasscodePage(e); + return; + } + throw e; + }); + }, + [hanko.email, hanko.passcode, setEmails, setPage, setPasscode, user.id] + ); + + const changePrimaryEmail = (event: Event, email: Email) => { + event.preventDefault(); + setIsPrimaryEmailLoading(true); + hanko.email + .setPrimaryEmail(email.id) + .then(() => setError(null)) + .then(() => hanko.email.list()) + .then(setEmails) + .finally(() => setIsPrimaryEmailLoading(false)) + .catch(setError); + }; + + const deleteEmail = (event: Event, email: Email) => { + event.preventDefault(); + setIsDeletionLoading(true); + hanko.email + .delete(email.id) + .then(() => { + setError(null); + setCheckedItemIndex(null); + setIsDeletionLoading(false); + return; + }) + .then(() => hanko.email.list()) + .then(setEmails) + .finally(() => setIsDeletionLoading(false)) + .catch(setError); + }; + + const verifyEmail = (event: Event, email: Email) => { + setIsVerificationLoading(true); + renderPasscode(email) + .finally(() => setIsVerificationLoading(false)) + .catch(setError); + }; + + const labels = (email: Email) => { + const description = ( + + {!email.is_verified ? ( + + {" -"} {t("labels.unverifiedEmail")} + + ) : email.is_primary ? ( + + {" -"} {t("labels.primaryEmail")} + + ) : null} + + ); + + return email.is_primary ? ( + + {email.address} + {description} + + ) : ( + + {email.address} + {description} + + ); + }; + + const contents = (email: Email) => ( + + {!email.is_primary ? ( + + + {t("headlines.setPrimaryEmail")} + {t("texts.setPrimaryEmail")} +
    + changePrimaryEmail(event, email)} + loadingSpinnerPosition={"right"} + > + {t("labels.setAsPrimaryEmail")} + +
    +
    + ) : ( + + + {t("headlines.isPrimaryEmail")} + {t("texts.isPrimaryEmail")} + + + )} + {email.is_verified ? ( + + + {t("headlines.emailVerified")} + {t("texts.emailVerified")} + + + ) : ( + + + {t("headlines.emailUnverified")} + {t("texts.emailUnverified")} +
    + verifyEmail(event, email)} + loadingSpinnerPosition={"right"} + > + {t("labels.verify")} + +
    +
    + )} + {!email.is_primary ? ( + + + {t("headlines.emailDelete")} + {t("texts.emailDelete")} +
    + deleteEmail(event, email)} + loadingSpinnerPosition={"right"} + > + {t("labels.delete")} + +
    +
    + ) : ( + + + {t("headlines.emailDelete")} + {t("texts.emailDeletePrimary")} + + + )} +
    + ); + return ( + + ); +}; + +export default ListEmailsAccordion; diff --git a/frontend/elements/src/components/accordion/ListPasskeysAccordion.tsx b/frontend/elements/src/components/accordion/ListPasskeysAccordion.tsx new file mode 100644 index 000000000..9255b0a3d --- /dev/null +++ b/frontend/elements/src/components/accordion/ListPasskeysAccordion.tsx @@ -0,0 +1,130 @@ +import * as preact from "preact"; +import { Fragment } from "preact"; +import { StateUpdater, useContext, useState } from "preact/compat"; + +import { + HankoError, + WebauthnCredentials, + WebauthnCredential, +} from "@teamhanko/hanko-frontend-sdk"; + +import { AppContext } from "../../contexts/AppProvider"; +import { TranslateContext } from "@denysvuika/preact-translate"; + +import Accordion from "./Accordion"; +import Paragraph from "../paragraph/Paragraph"; +import Link from "../link/Link"; +import Headline2 from "../headline/Headline2"; + +import ProfilePage from "../../pages/ProfilePage"; +import RenamePasskeyPage from "../../pages/RenamePasskeyPage"; + +interface Props { + credentials: WebauthnCredentials; + setError: (e: HankoError) => void; + checkedItemIndex?: number; + setCheckedItemIndex: StateUpdater; +} + +const ListPasskeysAccordion = ({ + credentials, + setError, + checkedItemIndex, + setCheckedItemIndex, +}: Props) => { + const { t } = useContext(TranslateContext); + const { hanko, setWebauthnCredentials, setPage } = useContext(AppContext); + + const [isLoading, setIsLoading] = useState(false); + + const deletePasskey = (event: Event, credential: WebauthnCredential) => { + event.preventDefault(); + setIsLoading(true); + hanko.webauthn + .deleteCredential(credential.id) + .then(() => hanko.webauthn.listCredentials()) + .then(setWebauthnCredentials) + .then(() => { + setError(null); + setCheckedItemIndex(null); + return; + }) + .finally(() => setIsLoading(false)) + .catch(setError); + }; + + const onBackHandler = () => { + setPage(); + }; + + const renamePasskey = (event: Event, credential: WebauthnCredential) => { + event.preventDefault(); + setPage( + + ); + }; + + const uiDisplayName = (credential: WebauthnCredential) => { + if (credential.name) { + return credential.name; + } + const alphanumeric = credential.public_key.replace(/[\W_]/g, ""); + return `Passkey-${alphanumeric.substring( + alphanumeric.length - 7, + alphanumeric.length + )}`; + }; + + const convertTime = (t: string) => new Date(t).toLocaleString(); + + const labels = (credential: WebauthnCredential) => uiDisplayName(credential); + + const contents = (credential: WebauthnCredential) => ( + + + {t("headlines.renamePasskey")} + {t("texts.renamePasskey")} +
    + renamePasskey(event, credential)} + loadingSpinnerPosition={"right"} + > + {t("labels.rename")} + +
    + + {t("headlines.deletePasskey")} + {t("texts.deletePasskey")} +
    + deletePasskey(event, credential)} + loadingSpinnerPosition={"right"} + > + {t("labels.delete")} + +
    + + {t("headlines.createdAt")} + {convertTime(credential.created_at)} + +
    + ); + return ( + + ); +}; + +export default ListPasskeysAccordion; diff --git a/frontend/elements/src/components/accordion/styles.sass b/frontend/elements/src/components/accordion/styles.sass new file mode 100644 index 000000000..04275d3f5 --- /dev/null +++ b/frontend/elements/src/components/accordion/styles.sass @@ -0,0 +1,104 @@ +@use '../../variables' +@use '../../mixins' + +.accordion + @include mixins.font + + width: 100% + overflow: hidden + + .accordionItem + color: variables.$color + + margin: .25rem 0 + overflow: hidden + + .label + border-radius: variables.$border-radius + border-style: none + + height: variables.$item-height + background: variables.$background-color + + box-sizing: border-box + display: flex + align-items: center + justify-content: space-between + padding: 0 1rem + margin: 0 + cursor: pointer + transition: all .35s + + .labelText + white-space: nowrap + overflow: hidden + text-overflow: ellipsis + + .description + color: variables.$color-shade-1 + + &.dropdown + color: variables.$link-color + justify-content: flex-start + width: fit-content + + &:hover + color: variables.$brand-contrast-color + background: variables.$brand-color-shade-1 + + .description + color: variables.$brand-contrast-color + + &.dropdown + color: variables.$link-color + background: none + + &:not(.dropdown)::after + content: "\276F" + width: 1rem + text-align: center + transition: all .35s + + &.dropdown::before + content: "\002B" + width: 1em + text-align: center + transition: all .35s + + .accordionInput + position: absolute + opacity: 0 + z-index: -1 + + &:checked + + .label + color: variables.$brand-contrast-color + background: variables.$brand-color + + .description + color: variables.$brand-contrast-color + + &.dropdown + color: variables.$link-color + background: none + + &:not(.dropdown)::after + transform: rotate(90deg) + + &.dropdown::before + content: "\002D" + + ~ .accordionContent + margin: .25rem 1rem + opacity: 1 + max-height: 100vh + + .accordionContent + max-height: 0 + margin: 0 1rem + opacity: 0 + overflow: hidden + transition: all .35s + + &.dropdownContent + border-style: none diff --git a/frontend/elements/src/ui/components/Divider.tsx b/frontend/elements/src/components/divider/Divider.tsx similarity index 89% rename from frontend/elements/src/ui/components/Divider.tsx rename to frontend/elements/src/components/divider/Divider.tsx index a4c34cef6..776466bee 100644 --- a/frontend/elements/src/ui/components/Divider.tsx +++ b/frontend/elements/src/components/divider/Divider.tsx @@ -3,7 +3,7 @@ import { useContext } from "preact/compat"; import { TranslateContext } from "@denysvuika/preact-translate"; -import styles from "./Divider.sass"; +import styles from "./styles.sass"; const Divider = () => { const { t } = useContext(TranslateContext); @@ -17,6 +17,7 @@ const Divider = () => { {t("or")} diff --git a/frontend/elements/src/components/divider/styles.sass b/frontend/elements/src/components/divider/styles.sass new file mode 100644 index 000000000..0dd48a340 --- /dev/null +++ b/frontend/elements/src/components/divider/styles.sass @@ -0,0 +1,28 @@ +@use '../../variables' +@use '../../mixins' + +.dividerWrapper + @include mixins.font + + display: variables.$divider-display + visibility: variables.$divider-visibility + color: variables.$color-shade-1 + margin: variables.$item-margin + +.divider + border-bottom-style: variables.$border-style + border-bottom-width: variables.$border-width + + color: inherit + font: inherit + + width: 100% + text-align: center + line-height: .1em + margin: 0 auto + + .text + font: inherit + color: inherit + background: variables.$background-color + padding: variables.$divider-padding diff --git a/frontend/elements/src/ui/components/ErrorMessage.tsx b/frontend/elements/src/components/error/ErrorMessage.tsx similarity index 90% rename from frontend/elements/src/ui/components/ErrorMessage.tsx rename to frontend/elements/src/components/error/ErrorMessage.tsx index 93d2c3074..9c50c0b99 100644 --- a/frontend/elements/src/ui/components/ErrorMessage.tsx +++ b/frontend/elements/src/components/error/ErrorMessage.tsx @@ -5,9 +5,9 @@ import { TranslateContext } from "@denysvuika/preact-translate"; import { HankoError, TechnicalError } from "@teamhanko/hanko-frontend-sdk"; -import ExclamationMark from "./ExclamationMark"; +import ExclamationMark from "../icons/ExclamationMark"; -import styles from "./ErrorMessage.sass"; +import styles from "./styles.sass"; type Props = { error?: Error; diff --git a/frontend/elements/src/components/error/styles.sass b/frontend/elements/src/components/error/styles.sass new file mode 100644 index 000000000..ff16a3e62 --- /dev/null +++ b/frontend/elements/src/components/error/styles.sass @@ -0,0 +1,20 @@ +@use '../../variables' +@use '../../mixins' + +.errorMessage + @include mixins.font + @include mixins.border + + color: variables.$error-color + background: variables.$background-color + + padding: .25rem + margin: variables.$item-margin + min-height: variables.$item-height + + display: flex + align-items: center + box-sizing: border-box + + &[hidden] + display: none diff --git a/frontend/elements/src/ui/components/Button.tsx b/frontend/elements/src/components/form/Button.tsx similarity index 83% rename from frontend/elements/src/ui/components/Button.tsx rename to frontend/elements/src/components/form/Button.tsx index 1f4f6c5e4..058cf1e9f 100644 --- a/frontend/elements/src/ui/components/Button.tsx +++ b/frontend/elements/src/components/form/Button.tsx @@ -4,10 +4,12 @@ import { useEffect, useRef } from "preact/compat"; import cx from "classnames"; -import LoadingIndicator from "./LoadingIndicator"; -import styles from "./Button.sass"; +import styles from "./styles.sass"; + +import LoadingSpinner from "../icons/LoadingSpinner"; type Props = { + title?: string children: ComponentChildren; secondary?: boolean; isLoading?: boolean; @@ -17,6 +19,7 @@ type Props = { }; const Button = ({ + title, children, secondary, disabled, @@ -37,6 +40,7 @@ const Button = ({ ); }; diff --git a/frontend/elements/src/ui/components/InputPasscode.tsx b/frontend/elements/src/components/form/CodeInput.tsx similarity index 72% rename from frontend/elements/src/ui/components/InputPasscode.tsx rename to frontend/elements/src/components/form/CodeInput.tsx index 9ec48fd25..2df3efc6c 100644 --- a/frontend/elements/src/ui/components/InputPasscode.tsx +++ b/frontend/elements/src/components/form/CodeInput.tsx @@ -1,9 +1,8 @@ import * as preact from "preact"; -import { useEffect, useState } from "preact/compat"; +import { h } from "preact"; +import { useEffect, useMemo, useRef, useState } from "preact/compat"; -import InputPasscodeDigit from "./InputPasscodeDigit"; - -import styles from "./Input.sass"; +import styles from "./styles.sass"; // Inspired by https://github.com/devfolioco/react-otp-input @@ -14,7 +13,58 @@ interface Props { disabled?: boolean; } -const InputPasscode = ({ +interface DigitProps extends h.JSX.HTMLAttributes { + index: number; + focus: boolean; + digit: string; +} + +const Digit = ({ index, focus, digit = "", ...props }: DigitProps) => { + const ref = useRef(null); + + const focusInput = () => { + const { current: element } = ref; + if (element) { + element.focus(); + element.select(); + } + }; + + // Autofocus if it's the first input element + useEffect(() => { + if (index === 0) { + focusInput(); + } + }, [index, props.disabled]); + + // Focus the current input element + useMemo(() => { + if (focus) { + focusInput(); + } + }, [focus]); + + return ( +
    + +
    + ); +}; + +const CodeInput = ({ passcodeDigits = [], numberOfInputs = 6, onInput, @@ -116,7 +166,7 @@ const InputPasscode = ({ return (
    {Array.from(Array(numberOfInputs)).map((_, index) => ( - void; @@ -10,7 +10,7 @@ type Props = { const Form = ({ onSubmit, children }: Props) => { return ( -
    +
      {toChildArray(children).map((child, index) => (
    • diff --git a/frontend/elements/src/ui/components/InputText.tsx b/frontend/elements/src/components/form/Input.tsx similarity index 72% rename from frontend/elements/src/ui/components/InputText.tsx rename to frontend/elements/src/components/form/Input.tsx index 05cdf00b7..fddcf1aa6 100644 --- a/frontend/elements/src/ui/components/InputText.tsx +++ b/frontend/elements/src/components/form/Input.tsx @@ -2,13 +2,13 @@ import * as preact from "preact"; import { h } from "preact"; import { useEffect, useRef } from "preact/compat"; -import styles from "./Input.sass"; +import styles from "./styles.sass"; interface Props extends h.JSX.HTMLAttributes { label?: string; } -const InputText = ({ label, ...props }: Props) => { +const Input = ({ label, ...props }: Props) => { const ref = useRef(null); useEffect(() => { @@ -25,18 +25,12 @@ const InputText = ({ label, ...props }: Props) => { // @ts-ignore part={"input text-input"} ref={ref} - {...props} + aria-label={props.placeholder} className={styles.input} + {...props} /> -
    ); }; -export default InputText; +export default Input; diff --git a/frontend/elements/src/components/form/styles.sass b/frontend/elements/src/components/form/styles.sass new file mode 100644 index 000000000..52376f956 --- /dev/null +++ b/frontend/elements/src/components/form/styles.sass @@ -0,0 +1,141 @@ +@use '../../variables' +@use '../../mixins' + +// Form Styles +.form + display: flex + flex-grow: 1 + + .ul + flex-grow: 1 + margin: variables.$item-margin + padding-inline-start: 0 + list-style-type: none + display: flex + flex-wrap: wrap + gap: 1em + + .li + display: flex + max-width: 100% + flex-grow: 1 + flex-basis: min-content + +// Button Styles +.button + @include mixins.font + @include mixins.border + + white-space: nowrap + min-width: variables.$button-min-width + height: variables.$item-height + outline: none + cursor: pointer + transition: 0.1s ease-out + flex-grow: 1 + flex-shrink: 1 + + &:disabled + cursor: default + + &.primary + color: variables.$brand-contrast-color + background: variables.$brand-color + border-color: variables.$brand-color + + &.primary:hover + color: variables.$brand-contrast-color + background: variables.$brand-color-shade-1 + border-color: variables.$brand-color + + &.primary:focus + color: variables.$brand-contrast-color + background: variables.$brand-color + border-color: variables.$color + + &.primary:disabled + color: variables.$color-shade-1 + background: variables.$color-shade-2 + border-color: variables.$color-shade-2 + + &.secondary + color: variables.$color + background: variables.$background-color + border-color: variables.$color + + &.secondary:hover + color: variables.$color + background: variables.$color-shade-2 + border-color: variables.$color + + &.secondary:focus + color: variables.$color + background: variables.$background-color + border-color: variables.$brand-color + + &.secondary:disabled + color: variables.$color-shade-1 + background: variables.$color-shade-2 + border-color: variables.$color-shade-1 + +// Input Styles + +.inputWrapper + flex-grow: 1 + position: relative + display: flex + min-width: variables.$input-min-width + max-width: 100% + +.input + @include mixins.font + @include mixins.border + + height: variables.$item-height + color: variables.$color + border-color: variables.$color-shade-1 + background: variables.$background-color + + padding: 0 .5rem + outline: none + width: 100% + box-sizing: border-box + transition: 0.1s ease-out + + &:-webkit-autofill, &:-webkit-autofill:hover, &:-webkit-autofill:focus + -webkit-text-fill-color: variables.$color + -webkit-box-shadow: 0 0 0 50px variables.$background-color inset + + // Removes native "clear text" and "password reveal" buttons from Edge + &::-ms-reveal, &::-ms-clear + display: none + + &::placeholder + color: variables.$color-shade-1 + + &:focus + color: variables.$color + border-color: variables.$color + + &:disabled + color: variables.$color-shade-1 + background: variables.$color-shade-2 + border-color: variables.$color-shade-1 + +.passcodeInputWrapper + flex-grow: 1 + min-width: variables.$input-min-width + max-width: fit-content + position: relative + display: flex + justify-content: space-between + + .passcodeDigitWrapper + flex-grow: 1 + margin: 0 .5rem 0 0 + + &:last-child + margin: 0 + + .input + text-align: center diff --git a/frontend/elements/src/components/headline/Headline1.tsx b/frontend/elements/src/components/headline/Headline1.tsx new file mode 100644 index 000000000..9ded73514 --- /dev/null +++ b/frontend/elements/src/components/headline/Headline1.tsx @@ -0,0 +1,24 @@ +import * as preact from "preact"; +import { ComponentChildren } from "preact"; + +import cx from "classnames"; + +import styles from "./styles.sass"; + +type Props = { + children: ComponentChildren; +}; + +const Headline1 = ({ children }: Props) => { + return ( +

    + {children} +

    + ); +}; + +export default Headline1; diff --git a/frontend/elements/src/components/headline/Headline2.tsx b/frontend/elements/src/components/headline/Headline2.tsx new file mode 100644 index 000000000..e8c045a31 --- /dev/null +++ b/frontend/elements/src/components/headline/Headline2.tsx @@ -0,0 +1,24 @@ +import * as preact from "preact"; +import { ComponentChildren } from "preact"; + +import cx from "classnames"; + +import styles from "./styles.sass"; + +type Props = { + children: ComponentChildren; +}; + +const Headline2 = ({ children }: Props) => { + return ( +

    + {children} +

    + ); +}; + +export default Headline2; diff --git a/frontend/elements/src/components/headline/styles.sass b/frontend/elements/src/components/headline/styles.sass new file mode 100644 index 000000000..3d99f6050 --- /dev/null +++ b/frontend/elements/src/components/headline/styles.sass @@ -0,0 +1,22 @@ +@use '../../variables' +@use '../../mixins' + + + +.headline + color: variables.$color + font-family: variables.$font-family + text-align: left + letter-spacing: 0 + font-style: normal + line-height: 1.1 + + &.grade1 + font-size: variables.$headline1-font-size + font-weight: variables.$headline1-font-weight + margin: variables.$headline1-margin + + &.grade2 + font-size: variables.$headline2-font-size + font-weight: variables.$headline2-font-weight + margin: variables.$headline2-margin diff --git a/frontend/elements/src/components/icons/Checkmark.tsx b/frontend/elements/src/components/icons/Checkmark.tsx new file mode 100644 index 000000000..6ebccaae4 --- /dev/null +++ b/frontend/elements/src/components/icons/Checkmark.tsx @@ -0,0 +1,22 @@ +import * as preact from "preact"; + +import cx from "classnames"; + +import styles from "./styles.sass"; + +type Props = { + fadeOut?: boolean; + secondary?: boolean; +}; + +const Checkmark = ({ fadeOut, secondary }: Props) => { + return ( +
    +
    +
    +
    +
    + ); +}; + +export default Checkmark; diff --git a/frontend/elements/src/ui/components/ExclamationMark.tsx b/frontend/elements/src/components/icons/ExclamationMark.tsx similarity index 86% rename from frontend/elements/src/ui/components/ExclamationMark.tsx rename to frontend/elements/src/components/icons/ExclamationMark.tsx index 24bf1a97e..c3ce8b4a3 100644 --- a/frontend/elements/src/ui/components/ExclamationMark.tsx +++ b/frontend/elements/src/components/icons/ExclamationMark.tsx @@ -1,6 +1,6 @@ import * as preact from "preact"; -import styles from "./ExclamationMark.sass"; +import styles from "./styles.sass"; const ExclamationMark = () => { return ( diff --git a/frontend/elements/src/ui/components/LoadingIndicator.tsx b/frontend/elements/src/components/icons/LoadingSpinner.tsx similarity index 65% rename from frontend/elements/src/ui/components/LoadingIndicator.tsx rename to frontend/elements/src/components/icons/LoadingSpinner.tsx index de409b23e..962057b71 100644 --- a/frontend/elements/src/ui/components/LoadingIndicator.tsx +++ b/frontend/elements/src/components/icons/LoadingSpinner.tsx @@ -1,10 +1,11 @@ import * as preact from "preact"; import { ComponentChildren } from "preact"; +import cx from "classnames"; + import Checkmark from "./Checkmark"; -import LoadingWheel from "./LoadingWheel"; -import styles from "./LoadingIndicator.sass"; +import styles from "./styles.sass"; export type Props = { children?: ComponentChildren; @@ -14,7 +15,7 @@ export type Props = { secondary?: boolean; }; -const LoadingIndicator = ({ +const LoadingSpinner = ({ children, isLoading, isSuccess, @@ -22,9 +23,11 @@ const LoadingIndicator = ({ secondary, }: Props) => { return ( -
    +
    {isLoading ? ( - +
    ) : isSuccess ? ( ) : ( @@ -34,4 +37,4 @@ const LoadingIndicator = ({ ); }; -export default LoadingIndicator; +export default LoadingSpinner; diff --git a/frontend/elements/src/components/icons/styles.sass b/frontend/elements/src/components/icons/styles.sass new file mode 100644 index 000000000..f0b26f877 --- /dev/null +++ b/frontend/elements/src/components/icons/styles.sass @@ -0,0 +1,121 @@ +@use '../../variables' + +// Checkmark Styles + +.checkmark + display: inline-block + width: 16px + height: 16px + transform: rotate(45deg) + + .circle + box-sizing: border-box + display: inline-block + border-width: 2px + border-style: solid + border-color: variables.$brand-color + position: absolute + width: 16px + height: 16px + border-radius: 11px + left: 0 + top: 0 + + &.secondary + border-color: variables.$color-shade-1 + + .stem + position: absolute + width: 2px + height: 7px + background-color: variables.$brand-color + left: 8px + top: 3px + + &.secondary + background-color: variables.$color-shade-1 + + .kick + position: absolute + width: 5px + height: 2px + background-color: variables.$brand-color + left: 5px + top: 10px + + &.secondary + background-color: variables.$color-shade-1 + + &.fadeOut + animation: fadeOut ease-out 1.5s forwards !important + +@keyframes fadeOut + 0% + opacity: 1 + + 100% + opacity: 0 + +// ExclamationMark Styles + +.exclamationMark + width: 16px + height: 16px + position: relative + margin: 5px + + .circle + box-sizing: border-box + display: inline-block + background-color: variables.$error-color + position: absolute + width: 16px + height: 16px + border-radius: 11px + left: 0 + top: 0 + + .stem + position: absolute + width: 2px + height: 6px + background: variables.$background-color + left: 7px + top: 3px + + .dot + position: absolute + width: 2px + height: 2px + background: variables.$background-color + left: 7px + top: 10px + +// Loading Spinner Styles + +.loadingSpinnerWrapper + display: inline-block + margin: 0 5px + + .loadingSpinner + box-sizing: border-box + display: inline-block + border-width: 2px + border-style: solid + border-color: variables.$background-color + border-top: 2px solid variables.$brand-color + border-radius: 50% + width: 16px + height: 16px + animation: spin 500ms ease-in-out infinite + + &.secondary + border-color: variables.$color-shade-1 + border-top: 2px solid variables.$color-shade-2 + +@keyframes spin + 0% + transform: rotate(0deg) + + 100% + transform: rotate(360deg) diff --git a/frontend/elements/src/components/link/Link.tsx b/frontend/elements/src/components/link/Link.tsx new file mode 100644 index 000000000..54f6b7d27 --- /dev/null +++ b/frontend/elements/src/components/link/Link.tsx @@ -0,0 +1,65 @@ +import * as preact from "preact"; +import { Fragment, h } from "preact"; + +import cx from "classnames"; + +import LoadingSpinner, { + Props as LoadingSpinnerProps, +} from "../icons/LoadingSpinner"; + +import styles from "./styles.sass"; + +type LoadingSpinnerPosition = "left" | "right"; + +export interface Props + extends LoadingSpinnerProps, + h.JSX.HTMLAttributes { + dangerous?: boolean; + loadingSpinnerPosition?: LoadingSpinnerPosition; +} + +const Link = ({ + loadingSpinnerPosition, + dangerous = false, + ...props +}: Props) => { + const renderLink = () => ( + + ); + + return ( + + {loadingSpinnerPosition ? ( + + ) : ( + {renderLink()} + )} + + ); +}; + +export default Link; diff --git a/frontend/elements/src/components/link/styles.sass b/frontend/elements/src/components/link/styles.sass new file mode 100644 index 000000000..812f1816c --- /dev/null +++ b/frontend/elements/src/components/link/styles.sass @@ -0,0 +1,34 @@ +@use "../../variables" +@use "../../mixins" + +.link + @include mixins.font + + color: variables.$link-color + text-decoration: variables.$link-text-decoration + cursor: pointer + background: none!important + border: none + padding: 0!important + + &:hover + text-decoration: variables.$link-text-decoration-hover + + &.disabled + color: variables.$color + pointer-events: none + cursor: default + + &.danger + color: variables.$error-color!important + +.linkWrapper + display: inline-flex + flex-direction: row + justify-content: space-between + align-items: center + height: 20px + + &.reverse + flex-direction: row-reverse + diff --git a/frontend/elements/src/ui/components/Paragraph.tsx b/frontend/elements/src/components/paragraph/Paragraph.tsx similarity index 89% rename from frontend/elements/src/ui/components/Paragraph.tsx rename to frontend/elements/src/components/paragraph/Paragraph.tsx index 6e13aa317..94f7094fe 100644 --- a/frontend/elements/src/ui/components/Paragraph.tsx +++ b/frontend/elements/src/components/paragraph/Paragraph.tsx @@ -1,7 +1,7 @@ import * as preact from "preact"; import { ComponentChildren } from "preact"; -import styles from "./Paragraph.sass"; +import styles from "./styles.sass"; type Props = { children: ComponentChildren; diff --git a/frontend/elements/src/components/paragraph/styles.sass b/frontend/elements/src/components/paragraph/styles.sass new file mode 100644 index 000000000..743aca917 --- /dev/null +++ b/frontend/elements/src/components/paragraph/styles.sass @@ -0,0 +1,11 @@ +@use "../../variables" +@use "../../mixins" + +.paragraph + @include mixins.font + + color: variables.$color + margin: variables.$item-margin + + text-align: left + word-break: break-word diff --git a/frontend/elements/src/components/wrapper/Container.tsx b/frontend/elements/src/components/wrapper/Container.tsx new file mode 100644 index 000000000..236b263bf --- /dev/null +++ b/frontend/elements/src/components/wrapper/Container.tsx @@ -0,0 +1,24 @@ +import * as preact from "preact"; +import { ComponentChildren, h } from "preact"; +import { forwardRef } from "preact/compat"; + +import styles from "./styles.sass"; + +interface Props extends h.JSX.HTMLAttributes { + children: ComponentChildren; +} + +const Container = forwardRef((props: Props, ref) => { + return ( +
    + {props.children} +
    + ); +}); + +export default Container; diff --git a/frontend/elements/src/ui/components/Content.tsx b/frontend/elements/src/components/wrapper/Content.tsx similarity index 87% rename from frontend/elements/src/ui/components/Content.tsx rename to frontend/elements/src/components/wrapper/Content.tsx index af0bec88c..a1ae7fa11 100644 --- a/frontend/elements/src/ui/components/Content.tsx +++ b/frontend/elements/src/components/wrapper/Content.tsx @@ -1,7 +1,7 @@ import * as preact from "preact"; import { ComponentChildren } from "preact"; -import styles from "./Content.sass"; +import styles from "./styles.sass"; type Props = { children: ComponentChildren; diff --git a/frontend/elements/src/ui/components/Footer.tsx b/frontend/elements/src/components/wrapper/Footer.tsx similarity index 88% rename from frontend/elements/src/ui/components/Footer.tsx rename to frontend/elements/src/components/wrapper/Footer.tsx index 29ae1153f..d37164161 100644 --- a/frontend/elements/src/ui/components/Footer.tsx +++ b/frontend/elements/src/components/wrapper/Footer.tsx @@ -1,7 +1,7 @@ import * as preact from "preact"; import { ComponentChildren } from "preact"; -import styles from "./Footer.sass"; +import styles from "./styles.sass"; interface Props { children?: ComponentChildren; diff --git a/frontend/elements/src/components/wrapper/styles.sass b/frontend/elements/src/components/wrapper/styles.sass new file mode 100644 index 000000000..8356794ae --- /dev/null +++ b/frontend/elements/src/components/wrapper/styles.sass @@ -0,0 +1,37 @@ +@use "../../variables" + +// Container Styles + +.container + background-color: variables.$background-color + padding: variables.$container-padding + max-width: variables.$container-max-width + + display: flex + flex-direction: column + flex-wrap: nowrap + justify-content: center + align-items: center + align-content: flex-start + box-sizing: border-box + +// Content Styles + +.content + box-sizing: border-box + flex: 0 1 auto + width: 100% + height: 100% + +// Footer Styles + +.footer + padding: variables.$item-margin + box-sizing: border-box + width: 100% + + \:nth-child(1) + float: left + + \:nth-child(2) + float: right diff --git a/frontend/elements/src/contexts/AppProvider.tsx b/frontend/elements/src/contexts/AppProvider.tsx new file mode 100644 index 000000000..8322fbc3b --- /dev/null +++ b/frontend/elements/src/contexts/AppProvider.tsx @@ -0,0 +1,149 @@ +import * as preact from "preact"; +import { ComponentChildren, createContext, h } from "preact"; +import { TranslateProvider } from "@denysvuika/preact-translate"; + +import { + StateUpdater, + useState, + useCallback, + useMemo, + useRef, +} from "preact/compat"; + +import { + Hanko, + User, + UserInfo, + Passcode, + Emails, + Config, + WebauthnCredentials, +} from "@teamhanko/hanko-frontend-sdk"; + +import { translations } from "../Translations"; + +import Container from "../components/wrapper/Container"; + +import InitPage from "../pages/InitPage"; + +type ExperimentalFeature = "conditionalMediation"; +type ExperimentalFeatures = ExperimentalFeature[]; +type ComponentName = "auth" | "profile"; + +interface Props { + api?: string; + lang?: string; + fallbackLang?: string; + experimental?: string; + componentName: ComponentName; + children?: ComponentChildren; +} + +interface States { + config: Config; + setConfig: StateUpdater; + userInfo: UserInfo; + setUserInfo: StateUpdater; + passcode: Passcode; + setPasscode: StateUpdater; + user: User; + setUser: StateUpdater; + emails: Emails; + setEmails: StateUpdater; + webauthnCredentials: WebauthnCredentials; + setWebauthnCredentials: StateUpdater; + page: h.JSX.Element; + setPage: StateUpdater; +} + +interface Context extends States { + hanko: Hanko; + componentName: ComponentName; + experimentalFeatures?: ExperimentalFeatures; + emitSuccessEvent: () => void; +} + +export const AppContext = createContext(null); + +const AppProvider = ({ + api, + lang, + fallbackLang = "en", + componentName, + experimental = "", +}: Props) => { + const ref = useRef(null); + + const hanko = useMemo(() => { + if (api.length) { + return new Hanko(api, 13000); + } + return null; + }, [api]); + + const experimentalFeatures = useMemo( + () => + experimental + .split(" ") + .filter((feature) => feature.length) + .map((feature) => feature as ExperimentalFeature), + [experimental] + ); + + const emitSuccessEvent = useCallback(() => { + const event = new Event("hankoAuthSuccess", { + bubbles: true, + composed: true, + }); + + const fn = setTimeout(() => { + ref.current.dispatchEvent(event); + }, 500); + + return () => clearTimeout(fn); + }, []); + + const [config, setConfig] = useState(); + const [userInfo, setUserInfo] = useState(null); + const [passcode, setPasscode] = useState(); + const [user, setUser] = useState(); + const [emails, setEmails] = useState(); + const [webauthnCredentials, setWebauthnCredentials] = + useState(); + const [page, setPage] = useState(); + + return ( + + + {page} + + + ); +}; + +export default AppProvider; diff --git a/frontend/elements/src/index.ts b/frontend/elements/src/index.ts index 8f3184339..1ad7ab315 100644 --- a/frontend/elements/src/index.ts +++ b/frontend/elements/src/index.ts @@ -1,2 +1,2 @@ -import { HankoAuth, register } from "./ui/HankoAuth"; -export { HankoAuth, register }; +import { HankoAuth, HankoProfile, register } from "./Elements"; +export { HankoAuth, HankoProfile, register }; diff --git a/frontend/elements/src/pages/ErrorPage.tsx b/frontend/elements/src/pages/ErrorPage.tsx new file mode 100644 index 000000000..7cc40c794 --- /dev/null +++ b/frontend/elements/src/pages/ErrorPage.tsx @@ -0,0 +1,50 @@ +import * as preact from "preact"; +import { useCallback, useContext, useEffect } from "preact/compat"; + +import { HankoError } from "@teamhanko/hanko-frontend-sdk"; + +import { TranslateContext } from "@denysvuika/preact-translate"; +import { AppContext } from "../contexts/AppProvider"; + +import Form from "../components/form/Form"; +import Button from "../components/form/Button"; +import Content from "../components/wrapper/Content"; +import Headline1 from "../components/headline/Headline1"; +import ErrorMessage from "../components/error/ErrorMessage"; + +import InitPage from "./InitPage"; + +interface Props { + initialError: HankoError; +} + +const ErrorPage = ({ initialError }: Props) => { + const { t } = useContext(TranslateContext); + const { setPage } = useContext(AppContext); + + const retry = useCallback(() => setPage(), [setPage]); + + const onContinueClick = (event: Event) => { + event.preventDefault(); + retry(); + }; + + useEffect(() => { + addEventListener("hankoAuthSuccess", retry); + return () => { + removeEventListener("hankoAuthSuccess", retry); + }; + }, [retry]); + + return ( + + {t("headlines.error")} + + + + + + ); +}; + +export default ErrorPage; diff --git a/frontend/elements/src/pages/InitPage.tsx b/frontend/elements/src/pages/InitPage.tsx new file mode 100644 index 000000000..4443c0e05 --- /dev/null +++ b/frontend/elements/src/pages/InitPage.tsx @@ -0,0 +1,87 @@ +import * as preact from "preact"; +import { useCallback, useContext, useEffect } from "preact/compat"; + +import { User } from "@teamhanko/hanko-frontend-sdk"; + +import { AppContext } from "../contexts/AppProvider"; + +import ErrorPage from "./ErrorPage"; +import ProfilePage from "./ProfilePage"; +import LoginEmailPage from "./LoginEmailPage"; +import LoginFinishedPage from "./LoginFinishedPage"; +import RegisterPasskeyPage from "./RegisterPasskeyPage"; + +import LoadingSpinner from "../components/icons/LoadingSpinner"; + +const InitPage = () => { + const { + hanko, + componentName, + setConfig, + setUser, + setEmails, + setWebauthnCredentials, + setPage, + } = useContext(AppContext); + + const afterLogin = useCallback( + (_user: User) => + hanko.webauthn + .shouldRegister(_user) + .then((shouldRegister) => + shouldRegister ? : + ), + [hanko.webauthn] + ); + + const initHankoAuth = useCallback(() => { + let _user: User; + return Promise.allSettled([ + hanko.config.get().then(setConfig), + hanko.user.getCurrent().then((resp) => setUser((_user = resp))), + ]).then(([configResult, userResult]) => { + if (configResult.status === "rejected") { + return ; + } + if (userResult.status === "fulfilled") { + return afterLogin(_user); + } + return ; + }); + }, [afterLogin, hanko.config, hanko.user, setConfig, setUser]); + + const initHankoProfile = useCallback( + () => + Promise.all([ + hanko.config.get().then(setConfig), + hanko.user.getCurrent().then(setUser), + hanko.email.list().then(setEmails), + hanko.webauthn.listCredentials().then(setWebauthnCredentials), + ]).then(() => ), + [hanko, setConfig, setEmails, setUser, setWebauthnCredentials] + ); + + const getInitializer = useCallback(() => { + switch (componentName) { + case "auth": + return initHankoAuth; + case "profile": + return initHankoProfile; + default: + return; + } + }, [componentName, initHankoAuth, initHankoProfile]); + + useEffect(() => { + const initializer = getInitializer(); + if (initializer) { + initializer() + .then(setPage) + .catch((e) => setPage()); + } + }, [getInitializer, setPage]); + + return ; +}; + +export default InitPage; diff --git a/frontend/elements/src/pages/LoginEmailPage.tsx b/frontend/elements/src/pages/LoginEmailPage.tsx new file mode 100644 index 000000000..6422fe1ab --- /dev/null +++ b/frontend/elements/src/pages/LoginEmailPage.tsx @@ -0,0 +1,399 @@ +import * as preact from "preact"; +import { + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "preact/compat"; +import { Fragment } from "preact"; + +import { + HankoError, + TechnicalError, + NotFoundError, + WebauthnRequestCancelledError, + InvalidWebauthnCredentialError, + TooManyRequestsError, + WebauthnSupport, + UserInfo, + User, +} from "@teamhanko/hanko-frontend-sdk"; + +import { AppContext } from "../contexts/AppProvider"; +import { TranslateContext } from "@denysvuika/preact-translate"; + +import Button from "../components/form/Button"; +import Input from "../components/form/Input"; +import Content from "../components/wrapper/Content"; +import Form from "../components/form/Form"; +import Divider from "../components/divider/Divider"; +import ErrorMessage from "../components/error/ErrorMessage"; +import Headline1 from "../components/headline/Headline1"; + +import LoginPasscodePage from "./LoginPasscodePage"; +import RegisterConfirmPage from "./RegisterConfirmPage"; +import LoginPasswordPage from "./LoginPasswordPage"; +import RegisterPasskeyPage from "./RegisterPasskeyPage"; +import RegisterPasswordPage from "./RegisterPasswordPage"; +import ErrorPage from "./ErrorPage"; + +interface Props { + emailAddress?: string; +} + +const LoginEmailPage = (props: Props) => { + const { t } = useContext(TranslateContext); + const { + hanko, + experimentalFeatures, + emitSuccessEvent, + config, + setPage, + setPasscode, + setUserInfo, + setUser, + } = useContext(AppContext); + + const [emailAddress, setEmailAddress] = useState(props.emailAddress); + const [isPasskeyLoginLoading, setIsPasskeyLoginLoading] = useState(); + const [isPasskeyLoginSuccess, setIsPasskeyLoginSuccess] = useState(); + const [isEmailLoginLoading, setIsEmailLoginLoading] = useState(); + const [error, setError] = useState(null); + const [isWebAuthnSupported, setIsWebAuthnSupported] = useState(); + const [isConditionalMediationSupported, setIsConditionalMediationSupported] = + useState(); + const [isEmailLoginSuccess, setIsEmailLoginSuccess] = useState(); + + const disabled = useMemo( + () => + isEmailLoginLoading || + isEmailLoginSuccess || + isPasskeyLoginLoading || + isPasskeyLoginSuccess, + [ + isEmailLoginLoading, + isEmailLoginSuccess, + isPasskeyLoginLoading, + isPasskeyLoginSuccess, + ] + ); + + const onEmailInput = (event: Event) => { + if (event.target instanceof HTMLInputElement) { + setEmailAddress(event.target.value); + } + }; + + const onBackHandler = useCallback(() => { + setPage(); + }, [emailAddress, setPage]); + + const afterLoginHandler = useCallback( + (recoverPassword: boolean) => { + let _user: User; + return hanko.user + .getCurrent() + .then((resp) => setUser((_user = resp))) + .then(() => hanko.webauthn.shouldRegister(_user)) + .then((shouldRegisterPasskey) => { + const onSuccessHandler = () => { + if (shouldRegisterPasskey) { + setPage(); + return; + } + emitSuccessEvent(); + }; + + if (recoverPassword) { + setPage(); + } else { + onSuccessHandler(); + } + + return; + }) + .catch((e) => setPage()); + }, + [emitSuccessEvent, hanko.user, hanko.webauthn, setPage, setUser] + ); + + const renderPasscode = useCallback( + (userID: string, emailID: string, recoverPassword?: boolean) => { + const showPasscodePage = (e?: HankoError) => + setPage( + afterLoginHandler(recoverPassword)} + onBack={onBackHandler} + /> + ); + + return hanko.passcode + .initialize(userID, emailID, false) + .then(setPasscode) + .then(() => showPasscodePage()) + .catch((e) => { + if (e instanceof TooManyRequestsError) { + showPasscodePage(e); + return; + } + + throw e; + }); + }, + [ + afterLoginHandler, + emailAddress, + hanko.passcode, + onBackHandler, + setPage, + setPasscode, + ] + ); + + const renderRegistrationConfirm = useCallback( + () => + setPage( + afterLoginHandler(config.password.enabled)} + onPasscode={(userID: string, emailID: string) => + renderPasscode(userID, emailID, config.password.enabled) + } + emailAddress={emailAddress} + onBack={onBackHandler} + /> + ), + [ + afterLoginHandler, + config.password.enabled, + emailAddress, + onBackHandler, + renderPasscode, + setPage, + ] + ); + + const loginWithEmailAndWebAuthn = () => { + let _userInfo: UserInfo; + let webauthnLoginInitiated: boolean; + + return hanko.user + .getInfo(emailAddress) + .then((resp) => setUserInfo((_userInfo = resp))) + .then(() => { + if (!_userInfo.verified && config.emails.require_verification) { + return renderPasscode(_userInfo.id, _userInfo.email_id); + } + + if (!_userInfo.has_webauthn_credential || conditionalMediationEnabled) { + return renderAlternateLoginMethod(_userInfo); + } + + webauthnLoginInitiated = true; + return hanko.webauthn.login(_userInfo.id); + }) + .then(() => { + if (webauthnLoginInitiated) { + setIsEmailLoginLoading(false); + setIsEmailLoginSuccess(true); + emitSuccessEvent(); + } + + return; + }) + .catch((e) => { + if (e instanceof NotFoundError) { + renderRegistrationConfirm(); + return; + } + + if (e instanceof WebauthnRequestCancelledError) { + return renderAlternateLoginMethod(_userInfo); + } + + throw e; + }); + }; + + const loginWithEmail = () => { + let _userInfo: UserInfo; + return hanko.user + .getInfo(emailAddress) + .then((resp) => setUserInfo((_userInfo = resp))) + .then(() => { + if (!_userInfo.verified && config.emails.require_verification) { + return renderPasscode(_userInfo.id, _userInfo.email_id); + } + + return renderAlternateLoginMethod(_userInfo); + }) + .catch((e) => { + if (e instanceof NotFoundError) { + renderRegistrationConfirm(); + return; + } + + throw e; + }); + }; + + const onEmailSubmit = (event: Event) => { + event.preventDefault(); + setIsEmailLoginLoading(true); + + if (isWebAuthnSupported) { + loginWithEmailAndWebAuthn().catch((e) => { + setIsEmailLoginLoading(false); + setError(e); + }); + } else { + loginWithEmail().catch((e) => { + setIsEmailLoginLoading(false); + setError(e); + }); + } + }; + + const onPasskeySubmit = (event: Event) => { + event.preventDefault(); + setIsPasskeyLoginLoading(true); + + hanko.webauthn + .login() + .then(() => { + setError(null); + setIsPasskeyLoginLoading(false); + setIsPasskeyLoginSuccess(true); + emitSuccessEvent(); + + return; + }) + .catch((e) => { + setIsPasskeyLoginLoading(false); + setError(e instanceof WebauthnRequestCancelledError ? null : e); + }); + }; + + const conditionalMediationEnabled = useMemo( + () => + experimentalFeatures.includes("conditionalMediation") && + isConditionalMediationSupported, + [experimentalFeatures, isConditionalMediationSupported] + ); + + const renderAlternateLoginMethod = useCallback( + (_userInfo: UserInfo) => { + if (config.password.enabled) { + setPage( + afterLoginHandler(false)} + onRecovery={() => + renderPasscode(_userInfo.id, _userInfo.email_id, true) + } + onBack={onBackHandler} + /> + ); + return; + } + + return renderPasscode(_userInfo.id, _userInfo.email_id); + }, + [ + afterLoginHandler, + config.password.enabled, + onBackHandler, + renderPasscode, + setPage, + ] + ); + + const loginViaConditionalUI = useCallback(() => { + if (!conditionalMediationEnabled) { + // Browser doesn't support AutoFill-assisted requests or the experimental conditional mediation feature is not enabled. + return; + } + + hanko.webauthn + .login(null, true) + .then(() => { + setError(null); + emitSuccessEvent(); + setIsEmailLoginSuccess(true); + + return; + }) + .catch((e) => { + if (e instanceof InvalidWebauthnCredentialError) { + // An invalid WebAuthn credential has been used. Retry the login procedure, so another credential can be + // chosen by the user via conditional UI. + loginViaConditionalUI(); + } + setError(e instanceof WebauthnRequestCancelledError ? null : e); + }); + }, [conditionalMediationEnabled, emitSuccessEvent, hanko.webauthn]); + + useEffect(() => { + loginViaConditionalUI(); + }, [loginViaConditionalUI]); + + useEffect(() => { + setIsWebAuthnSupported(WebauthnSupport.supported()); + }, []); + + useEffect(() => { + WebauthnSupport.isConditionalMediationAvailable() + .then((supported) => setIsConditionalMediationSupported(supported)) + .catch((e) => setError(new TechnicalError(e))); + }, []); + + return ( + + {t("headlines.loginEmail")} + +
    + + +
    + {isWebAuthnSupported && !conditionalMediationEnabled ? ( + + +
    + +
    +
    + ) : null} +
    + ); +}; + +export default LoginEmailPage; diff --git a/frontend/elements/src/ui/pages/LoginFinished.tsx b/frontend/elements/src/pages/LoginFinishedPage.tsx similarity index 57% rename from frontend/elements/src/ui/pages/LoginFinished.tsx rename to frontend/elements/src/pages/LoginFinishedPage.tsx index 652d4ff60..240d80f78 100644 --- a/frontend/elements/src/ui/pages/LoginFinished.tsx +++ b/frontend/elements/src/pages/LoginFinishedPage.tsx @@ -2,16 +2,16 @@ import * as preact from "preact"; import { useContext, useState } from "preact/compat"; import { TranslateContext } from "@denysvuika/preact-translate"; -import { RenderContext } from "../contexts/PageProvider"; +import { AppContext } from "../contexts/AppProvider"; -import Headline from "../components/Headline"; -import Content from "../components/Content"; -import Button from "../components/Button"; -import Form from "../components/Form"; +import Headline1 from "../components/headline/Headline1"; +import Content from "../components/wrapper/Content"; +import Button from "../components/form/Button"; +import Form from "../components/form/Form"; -const LoginFinished = () => { +const LoginFinishedPage = () => { const { t } = useContext(TranslateContext); - const { emitSuccessEvent } = useContext(RenderContext); + const { emitSuccessEvent } = useContext(AppContext); const [isSuccess, setIsSuccess] = useState(false); const onContinue = (event: Event) => { @@ -22,7 +22,7 @@ const LoginFinished = () => { return ( - {t("headlines.loginFinished")} + {t("headlines.loginFinished")}
    +
    +
    +
    + + {t("labels.back")} + + 0 || disabled} + onClick={onResendClick} + isLoading={isResendLoading} + isSuccess={isResendSuccess} + loadingSpinnerPosition={"left"} + > + {resendAfter > 0 + ? t("labels.passcodeResendAfter", { + passcodeResendAfter: resendAfter, + }) + : t("labels.sendNewPasscode")} + +
    + + ); +}; + +export default LoginPasscodePage; diff --git a/frontend/elements/src/pages/LoginPasswordPage.tsx b/frontend/elements/src/pages/LoginPasswordPage.tsx new file mode 100644 index 000000000..5d1feff01 --- /dev/null +++ b/frontend/elements/src/pages/LoginPasswordPage.tsx @@ -0,0 +1,137 @@ +import * as preact from "preact"; +import { Fragment } from "preact"; +import { useContext, useEffect, useMemo, useState } from "preact/compat"; + +import { + HankoError, + TooManyRequestsError, + UserInfo, +} from "@teamhanko/hanko-frontend-sdk"; + +import { AppContext } from "../contexts/AppProvider"; +import { TranslateContext } from "@denysvuika/preact-translate"; + +import Content from "../components/wrapper/Content"; +import Footer from "../components/wrapper/Footer"; +import Form from "../components/form/Form"; +import Input from "../components/form/Input"; +import Button from "../components/form/Button"; +import ErrorMessage from "../components/error/ErrorMessage"; +import Link from "../components/link/Link"; +import Headline1 from "../components/headline/Headline1"; + +type Props = { + userInfo: UserInfo; + onRecovery: () => Promise; + onSuccess: () => void; + onBack: () => void; +}; + +const LoginPasswordPage = ({ onSuccess, onRecovery, onBack }: Props) => { + const { t } = useContext(TranslateContext); + const { hanko, userInfo } = useContext(AppContext); + + const [password, setPassword] = useState(); + const [passwordRetryAfter, setPasswordRetryAfter] = useState(); + const [isPasswordLoading, setIsPasswordLoading] = useState(); + const [isPasscodeLoading, setIsPasscodeLoading] = useState(); + const [isSuccess, setIsSuccess] = useState(); + const [error, setError] = useState(null); + + const disabled = useMemo( + () => isPasswordLoading || isPasscodeLoading || isSuccess, + [isPasscodeLoading, isPasswordLoading, isSuccess] + ); + + const onPasswordInput = async (event: Event) => { + if (event.target instanceof HTMLInputElement) { + setPassword(event.target.value); + } + }; + + const onPasswordSubmit = (event: Event) => { + event.preventDefault(); + setIsPasswordLoading(true); + + hanko.password + .login(userInfo.id, password) + .then(() => setIsSuccess(true)) + .then(onSuccess) + .finally(() => setIsPasswordLoading(false)) + .catch((e) => { + if (e instanceof TooManyRequestsError) { + setPasswordRetryAfter(e.retryAfter); + } + setError(e); + }); + }; + + const onRecoveryHandler = (event: Event) => { + event.preventDefault(); + setIsPasscodeLoading(true); + onRecovery() + .finally(() => setIsPasscodeLoading(false)) + .catch(setError); + }; + + const onBackHandler = (event: Event) => { + event.preventDefault(); + onBack(); + }; + + // Automatically clear the too many requests error message + useEffect(() => { + if (error instanceof TooManyRequestsError && passwordRetryAfter <= 0) { + setError(null); + } + }, [error, passwordRetryAfter]); + + return ( + + + {t("headlines.loginPassword")} + +
    + + +
    +
    +
    + + {t("labels.back")} + + + {t("labels.forgotYourPassword")} + +
    +
    + ); +}; + +export default LoginPasswordPage; diff --git a/frontend/elements/src/pages/ProfilePage.tsx b/frontend/elements/src/pages/ProfilePage.tsx new file mode 100644 index 000000000..f66188d61 --- /dev/null +++ b/frontend/elements/src/pages/ProfilePage.tsx @@ -0,0 +1,158 @@ +import * as preact from "preact"; +import { Fragment } from "preact"; +import { useContext, useEffect, useState } from "preact/compat"; + +import { HankoError } from "@teamhanko/hanko-frontend-sdk"; + +import { AppContext } from "../contexts/AppProvider"; +import { TranslateContext } from "@denysvuika/preact-translate"; + +import Content from "../components/wrapper/Content"; +import Headline1 from "../components/headline/Headline1"; +import Paragraph from "../components/paragraph/Paragraph"; +import ErrorMessage from "../components/error/ErrorMessage"; +import ListEmailsAccordion from "../components/accordion/ListEmailsAccordion"; +import ListPasskeysAccordion from "../components/accordion/ListPasskeysAccordion"; +import AddEmailDropdown from "../components/accordion/AddEmailDropdown"; +import ChangePasswordDropdown from "../components/accordion/ChangePasswordDropdown"; +import AddPasskeyDropdown from "../components/accordion/AddPasskeyDropdown"; + +const ProfilePage = () => { + const { t } = useContext(TranslateContext); + const { config, webauthnCredentials, emails } = useContext(AppContext); + + const [emailError, setEmailError] = useState(null); + const [passwordError, setPasswordError] = useState(null); + const [passkeyError, setPasskeyError] = useState(null); + + const [checkedItemIndexEmails, setCheckedItemIndexEmails] = + useState(null); + const [checkedItemIndexAddEmail, setCheckedItemIndexAddEmail] = + useState(null); + const [checkedItemIndexSetPassword, setCheckedItemIndexSetPassword] = + useState(null); + const [checkedItemIndexPasskeys, setCheckedItemIndexPasskeys] = + useState(null); + const [checkedItemIndexAddPasskey, setCheckedItemIndexAddPasskey] = + useState(null); + + useEffect(() => { + if (checkedItemIndexEmails !== null) { + setCheckedItemIndexAddEmail(null); + setCheckedItemIndexSetPassword(null); + setCheckedItemIndexPasskeys(null); + setCheckedItemIndexAddPasskey(null); + } + }, [checkedItemIndexEmails]); + + useEffect(() => { + if (checkedItemIndexAddEmail !== null) { + setCheckedItemIndexEmails(null); + setCheckedItemIndexSetPassword(null); + setCheckedItemIndexPasskeys(null); + setCheckedItemIndexAddPasskey(null); + } + }, [checkedItemIndexAddEmail]); + + useEffect(() => { + if (checkedItemIndexSetPassword !== null) { + setCheckedItemIndexAddEmail(null); + setCheckedItemIndexEmails(null); + setCheckedItemIndexPasskeys(null); + setCheckedItemIndexAddPasskey(null); + } + }, [checkedItemIndexSetPassword]); + + useEffect(() => { + if (checkedItemIndexPasskeys !== null) { + setCheckedItemIndexAddEmail(null); + setCheckedItemIndexEmails(null); + setCheckedItemIndexSetPassword(null); + setCheckedItemIndexAddPasskey(null); + } + }, [checkedItemIndexPasskeys]); + + useEffect(() => { + if (checkedItemIndexAddPasskey !== null) { + setCheckedItemIndexAddEmail(null); + setCheckedItemIndexEmails(null); + setCheckedItemIndexSetPassword(null); + setCheckedItemIndexPasskeys(null); + } + }, [checkedItemIndexAddPasskey]); + + useEffect(() => { + if (emailError !== null) { + setPasswordError(null); + setPasskeyError(null); + } + }, [emailError]); + + useEffect(() => { + if (passwordError !== null) { + setEmailError(null); + setPasskeyError(null); + } + }, [passwordError]); + + useEffect(() => { + if (passkeyError !== null) { + setEmailError(null); + setPasswordError(null); + } + }, [passkeyError]); + + return ( + + {t("headlines.profileEmails")} + + {t("texts.manageEmails")} + + + {emails.length < config.emails.max_num_of_addresses ? ( + + ) : null} + + {config.password.enabled ? ( + + {t("headlines.profilePassword")} + + {t("texts.changePassword")} + + + + + ) : null} + {t("headlines.profilePasskeys")} + + {t("texts.managePasskeys")} + + + + + + ); +}; + +export default ProfilePage; diff --git a/frontend/elements/src/pages/RegisterConfirmPage.tsx b/frontend/elements/src/pages/RegisterConfirmPage.tsx new file mode 100644 index 000000000..8828bb28b --- /dev/null +++ b/frontend/elements/src/pages/RegisterConfirmPage.tsx @@ -0,0 +1,89 @@ +import * as preact from "preact"; +import { Fragment } from "preact"; +import { useContext, useEffect, useState } from "preact/compat"; + +import { User, HankoError } from "@teamhanko/hanko-frontend-sdk"; + +import { AppContext } from "../contexts/AppProvider"; +import { TranslateContext } from "@denysvuika/preact-translate"; + +import Content from "../components/wrapper/Content"; +import Form from "../components/form/Form"; +import Button from "../components/form/Button"; +import Footer from "../components/wrapper/Footer"; +import ErrorMessage from "../components/error/ErrorMessage"; +import Paragraph from "../components/paragraph/Paragraph"; +import Headline1 from "../components/headline/Headline1"; +import Link from "../components/link/Link"; + +interface Props { + emailAddress: string; + onBack: () => void; + onSuccess: () => void; + onPasscode: (userID: string, emailID: string) => Promise; +} + +const RegisterConfirmPage = ({ + emailAddress, + onSuccess, + onPasscode, + onBack, +}: Props) => { + const { t } = useContext(TranslateContext); + const { hanko, config } = useContext(AppContext); + + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + const [error, setError] = useState(null); + + const onConfirmSubmit = (event: Event) => { + event.preventDefault(); + setIsLoading(true); + hanko.user.create(emailAddress).then(setUser).catch(setError); + }; + + const onBackClick = (event: Event) => { + event.preventDefault(); + onBack(); + }; + + useEffect(() => { + if (!user || !config) return; + + // User has been created + if (config.emails.require_verification) { + onPasscode(user.id, user.email_id).catch((e) => { + setIsLoading(false); + setError(e); + }); + } else { + setIsSuccess(true); + setIsLoading(false); + onSuccess(); + } + }, [config, onPasscode, onSuccess, user]); + + return ( + + + {t("headlines.registerConfirm")} + + {t("texts.createAccount", { emailAddress })} +
    + +
    +
    +
    +
    +
    + ); +}; + +export default RegisterConfirmPage; diff --git a/frontend/elements/src/ui/pages/RegisterAuthenticator.tsx b/frontend/elements/src/pages/RegisterPasskeyPage.tsx similarity index 50% rename from frontend/elements/src/ui/pages/RegisterAuthenticator.tsx rename to frontend/elements/src/pages/RegisterPasskeyPage.tsx index 90f0fe63a..bcb5d9e7b 100644 --- a/frontend/elements/src/ui/pages/RegisterAuthenticator.tsx +++ b/frontend/elements/src/pages/RegisterPasskeyPage.tsx @@ -1,6 +1,6 @@ import * as preact from "preact"; import { Fragment } from "preact"; -import { useContext, useState } from "preact/compat"; +import { useContext, useMemo, useState } from "preact/compat"; import { HankoError, @@ -11,53 +11,51 @@ import { import { TranslateContext } from "@denysvuika/preact-translate"; import { AppContext } from "../contexts/AppProvider"; -import { RenderContext } from "../contexts/PageProvider"; -import Content from "../components/Content"; -import Headline from "../components/Headline"; -import Form from "../components/Form"; -import Button from "../components/Button"; -import ErrorMessage from "../components/ErrorMessage"; -import Footer from "../components/Footer"; -import Paragraph from "../components/Paragraph"; +import Content from "../components/wrapper/Content"; +import Form from "../components/form/Form"; +import Button from "../components/form/Button"; +import ErrorMessage from "../components/error/ErrorMessage"; +import Footer from "../components/wrapper/Footer"; +import Paragraph from "../components/paragraph/Paragraph"; +import Headline1 from "../components/headline/Headline1"; -import LoadingIndicatorLink from "../components/link/withLoadingIndicator"; +import Link from "../components/link/Link"; +import ErrorPage from "./ErrorPage"; -const RegisterAuthenticator = () => { +const RegisterPasskeyPage = () => { const { t } = useContext(TranslateContext); - const { hanko } = useContext(AppContext); - const { renderError, emitSuccessEvent } = useContext(RenderContext); + const { hanko, emitSuccessEvent, setPage } = useContext(AppContext); - const [isLoading, setIsLoading] = useState(false); + const [isPasskeyLoading, setIsPasskeyLoading] = useState(false); const [isSuccess, setIsSuccess] = useState(false); const [isSkipLoading, setSkipIsLoading] = useState(false); const [error, setError] = useState(null); const registerWebAuthnCredential = (event: Event) => { event.preventDefault(); - setIsLoading(true); + setIsPasskeyLoading(true); hanko.webauthn .register() .then(() => { setIsSuccess(true); - setIsLoading(false); + setIsPasskeyLoading(false); emitSuccessEvent(); return; }) .catch((e) => { - console.error(e); if ( e instanceof UnauthorizedError || e instanceof UserVerificationError ) { - renderError(e); + setPage(); return; } setError(e instanceof WebauthnRequestCancelledError ? null : e); - setIsLoading(false); + setIsPasskeyLoading(false); }); }; @@ -67,26 +65,41 @@ const RegisterAuthenticator = () => { emitSuccessEvent(); }; + const disabled = useMemo( + () => isPasskeyLoading || isSkipLoading || isSuccess, + [isPasskeyLoading, isSkipLoading, isSuccess] + ); + return ( - {t("headlines.registerAuthenticator")} + {t("headlines.registerAuthenticator")} + {t("texts.setupPasskey")}
    - {t("texts.setupPasskey")} -
    ); }; -export default RegisterAuthenticator; +export default RegisterPasskeyPage; diff --git a/frontend/elements/src/pages/RegisterPasswordPage.tsx b/frontend/elements/src/pages/RegisterPasswordPage.tsx new file mode 100644 index 000000000..b27a5fbac --- /dev/null +++ b/frontend/elements/src/pages/RegisterPasswordPage.tsx @@ -0,0 +1,86 @@ +import * as preact from "preact"; +import { useContext, useState } from "preact/compat"; + +import { HankoError, UnauthorizedError } from "@teamhanko/hanko-frontend-sdk"; + +import { TranslateContext } from "@denysvuika/preact-translate"; +import { AppContext } from "../contexts/AppProvider"; + +import Content from "../components/wrapper/Content"; +import Form from "../components/form/Form"; +import Input from "../components/form/Input"; +import Button from "../components/form/Button"; +import ErrorMessage from "../components/error/ErrorMessage"; +import Paragraph from "../components/paragraph/Paragraph"; +import Headline1 from "../components/headline/Headline1"; + +import ErrorPage from "./ErrorPage"; + +type Props = { + onSuccess: () => void; +}; + +const RegisterPasswordPage = ({ onSuccess }: Props) => { + const { t } = useContext(TranslateContext); + const { hanko, config, user, setPage } = useContext(AppContext); + + const [isLoading, setIsLoading] = useState(); + const [isSuccess, setIsSuccess] = useState(); + const [error, setError] = useState(null); + const [password, setPassword] = useState(); + + const onPasswordInput = async (event: Event) => { + if (event.target instanceof HTMLInputElement) { + setPassword(event.target.value); + } + }; + + const onPasswordSubmit = (event: Event) => { + event.preventDefault(); + setIsLoading(true); + + hanko.password + .update(user.id, password) + .then(() => setIsSuccess(true)) + .then(() => onSuccess()) + .finally(() => setIsLoading(false)) + .catch((e) => { + if (e instanceof UnauthorizedError) { + setPage(); + return; + } + setError(e); + }); + }; + return ( + + {t("headlines.registerPassword")} + + + {t("texts.passwordFormatHint", { + minLength: config.password.min_password_length, + maxLength: 72, + })} + +
    + + +
    +
    + ); +}; + +export default RegisterPasswordPage; diff --git a/frontend/elements/src/pages/RenamePasskeyPage.tsx b/frontend/elements/src/pages/RenamePasskeyPage.tsx new file mode 100644 index 000000000..c61bd3d91 --- /dev/null +++ b/frontend/elements/src/pages/RenamePasskeyPage.tsx @@ -0,0 +1,94 @@ +import * as preact from "preact"; +import { Fragment } from "preact"; +import { useContext, useState } from "preact/compat"; + +import { HankoError, WebauthnCredential } from "@teamhanko/hanko-frontend-sdk"; + +import { AppContext } from "../contexts/AppProvider"; +import { TranslateContext } from "@denysvuika/preact-translate"; + +import Content from "../components/wrapper/Content"; +import Form from "../components/form/Form"; +import Input from "../components/form/Input"; +import Button from "../components/form/Button"; +import ErrorMessage from "../components/error/ErrorMessage"; +import Paragraph from "../components/paragraph/Paragraph"; +import Headline1 from "../components/headline/Headline1"; +import Footer from "../components/wrapper/Footer"; +import Link from "../components/link/Link"; + +type Props = { + oldName: string; + credential: WebauthnCredential; + onBack: () => void; +}; + +const RenamePasskeyPage = ({ credential, oldName, onBack }: Props) => { + const { t } = useContext(TranslateContext); + const { hanko, setWebauthnCredentials } = useContext(AppContext); + + const [isPasskeyLoading, setIsPasskeyLoading] = useState(); + const [error, setError] = useState(null); + const [newName, setNewName] = useState(oldName); + + const onNewNameInput = async (event: Event) => { + if (event.target instanceof HTMLInputElement) { + setNewName(event.target.value); + } + }; + + const onPasskeyNameSubmit = (event: Event) => { + event.preventDefault(); + setIsPasskeyLoading(true); + hanko.webauthn + .updateCredential(credential.id, newName) + .then(() => hanko.webauthn.listCredentials()) + .then(setWebauthnCredentials) + .then(() => onBack()) + .finally(() => setIsPasskeyLoading(false)) + .catch(setError); + }; + + const onBackHandler = (event: Event) => { + event.preventDefault(); + onBack(); + }; + + return ( + + + {t("headlines.renamePasskey")} + + {t("texts.renamePasskey")} +
    + + +
    +
    +
    + + {t("labels.back")} + +
    +
    + ); +}; + +export default RenamePasskeyPage; diff --git a/frontend/elements/src/test.html b/frontend/elements/src/test.html index 5afe63d11..c82f95f59 100644 --- a/frontend/elements/src/test.html +++ b/frontend/elements/src/test.html @@ -3,70 +3,40 @@ Hanko Web Component Test - + - + + diff --git a/frontend/elements/src/ui/HankoAuth.tsx b/frontend/elements/src/ui/HankoAuth.tsx deleted file mode 100644 index 5c1f4b1ba..000000000 --- a/frontend/elements/src/ui/HankoAuth.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import * as preact from "preact"; -import registerCustomElement from "@teamhanko/preact-custom-element"; -import { Fragment } from "preact"; - -import { TranslateProvider } from "@denysvuika/preact-translate"; - -import PageProvider from "./contexts/PageProvider"; -import AppProvider from "./contexts/AppProvider"; -import UserProvider from "./contexts/UserProvider"; -import PasscodeProvider from "./contexts/PasscodeProvider"; -import PasswordProvider from "./contexts/PasswordProvider"; - -import { translations } from "./Translations"; - -interface Props { - api: string; - experimental?: string; -} - -declare interface HankoAuthElement - extends preact.JSX.HTMLAttributes, - Props {} - -declare global { - // eslint-disable-next-line no-unused-vars - namespace JSX { - // eslint-disable-next-line no-unused-vars - interface IntrinsicElements { - "hanko-auth": HankoAuthElement; - } - } -} - -export const HankoAuth = ({ - api = "", - lang = "en", - experimental = "" -}: HankoAuthElement) => { - return ( - - - - - - - - - - - - - - ); -}; - -export interface RegisterOptions { - shadow?: boolean; - injectStyles?: boolean; -} - -export const register = ({ - shadow = true, - injectStyles = true, -}: RegisterOptions): Promise => { - const tagName = "hanko-auth"; - - return new Promise((resolve, reject) => { - if (!customElements.get(tagName)) { - registerCustomElement( - HankoAuth, - tagName, - ["api", "lang", "experimental"], - { - shadow, - } - ); - } - - if (injectStyles) { - customElements - .whenDefined(tagName) - .then((_) => { - const elements = document.getElementsByTagName(tagName); - - Array.from(elements).forEach((element) => { - if (shadow) { - element.shadowRoot.appendChild(window._hankoStyle); - } else { - element.appendChild(window._hankoStyle); - } - }); - - return resolve(); - }) - .catch((e) => { - reject(e); - }); - } else { - return resolve(); - } - }); -}; diff --git a/frontend/elements/src/ui/Translations.ts b/frontend/elements/src/ui/Translations.ts deleted file mode 100644 index 51e04d952..000000000 --- a/frontend/elements/src/ui/Translations.ts +++ /dev/null @@ -1,109 +0,0 @@ -export const translations = { - en: { - headlines: { - error: "An error has occurred", - loginEmail: "Sign in or sign up", - loginFinished: "Login successful", - loginPasscode: "Enter passcode", - loginPassword: "Enter password", - registerAuthenticator: "Save a passkey", - registerConfirm: "Create account?", - registerPassword: "Set new password", - }, - texts: { - enterPasscode: 'Enter the passcode that was sent to "{email}".', - setupPasskey: - "Sign in to your account easily and securely with a passkey. Note: Your biometric data is only stored on your devices and will never be shared with anyone.", - createAccount: - 'No account exists for "{email}". Do you want to create a new account?', - passwordFormatHint: "Must be at least 10 characters long.", - }, - labels: { - or: "or", - email: "Email", - continue: "Continue", - skip: "Skip", - password: "Password", - forgotYourPassword: "Forgot your password?", - back: "Back", - signInPasskey: "Sign in with a passkey", - registerAuthenticator: "Save a passkey", - signIn: "Sign in", - signUp: "Sign up", - sendNewPasscode: "Send new code", - passwordRetryAfter: "Retry in {passwordRetryAfter}", - passcodeResendAfter: "Request a new code in {passcodeResendAfter}", - }, - errors: { - somethingWentWrong: - "A technical error has occurred. Please try again later.", - requestTimeout: "The request timed out.", - invalidPassword: "Wrong email or password.", - invalidPasscode: "The passcode provided was not correct.", - passcodeAttemptsReached: - "The passcode was entered incorrectly too many times. Please request a new code.", - tooManyRequests: - "Too many requests have been made. Please wait to repeat the requested operation.", - unauthorized: "Your session has expired. Please log in again.", - invalidWebauthnCredential: "Invalid WebAuthn credentials.", - passcodeExpired: "The passcode has expired. Please request a new one.", - userVerification: - "User verification required. Please ensure your authenticator device is protected with a PIN or biometric.", - }, - }, - de: { - headlines: { - error: "Ein Fehler ist aufgetreten", - loginEmail: "Anmelden / Registrieren", - loginFinished: "Login erfolgreich", - loginPasscode: "Passcode eingeben", - loginPassword: "Passwort eingeben", - registerAuthenticator: "Passkey einrichten", - registerConfirm: "Konto erstellen?", - registerPassword: "Neues Passwort eingeben", - }, - texts: { - enterPasscode: - 'Geben Sie den Passcode ein, der an die E-Mail-Adresse "{email}" gesendet wurde.', - setupPasskey: - "Ihr Gerät unterstützt die sichere Anmeldung mit Passkeys. Hinweis: Ihre biometrischen Daten verbleiben sicher auf Ihrem Gerät und werden niemals an unseren Server gesendet.", - createAccount: - 'Es existiert kein Konto für "{email}". Möchten Sie ein neues Konto erstellen?', - passwordFormatHint: "mindestens 10 Zeichen", - }, - labels: { - or: "oder", - email: "E-Mail", - continue: "Weiter", - skip: "Überspringen", - password: "Passwort", - forgotYourPassword: "Passwort vergessen?", - back: "Zurück", - signInPasskey: "Anmelden mit Passkey", - registerAuthenticator: "Passkey einrichten", - signIn: "Anmelden", - signUp: "Registrieren", - sendNewPasscode: "Neuen Code senden", - passwordRetryAfter: "Neuer Versuch in {passwordRetryAfter}", - passcodeResendAfter: "Neuen Code in {passcodeResendAfter} anfordern", - }, - errors: { - somethingWentWrong: - "Ein technischer Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.", - requestTimeout: "Die Anfrage hat das Zeitlimit überschritten.", - invalidPassword: "E-Mail-Adresse oder Passwort falsch.", - invalidPasscode: "Der Passcode war nicht richtig.", - passcodeAttemptsReached: - "Der Passcode wurde zu oft falsch eingegeben. Bitte fragen Sie einen neuen Code an.", - tooManyRequests: - "Es wurden zu viele Anfragen gestellt. Bitte warten Sie, um den gewünschten Vorgang zu wiederholen.", - unauthorized: - "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", - invalidWebauthnCredential: "Ungültiger Berechtigungsnachweis", - passcodeExpired: - "Der Passcode ist abgelaufen. Bitte fordern Sie einen neuen Code an.", - userVerification: - "Nutzer-Verifikation erforderlich. Bitte stellen Sie sicher, dass Ihr Gerät durch eine PIN oder Biometrie abgesichert ist.", - }, - }, -}; diff --git a/frontend/elements/src/ui/components/Button.sass b/frontend/elements/src/ui/components/Button.sass deleted file mode 100644 index de2b611f1..000000000 --- a/frontend/elements/src/ui/components/Button.sass +++ /dev/null @@ -1,74 +0,0 @@ -@use 'default' - -.button - flex-grow: 1 - outline: none - cursor: pointer - transition: 0.1s ease-out - - &:disabled - cursor: default - - &.primary - @include default.font - color: default.$primary-button-color - background: default.$primary-button-background - border-width: default.$primary-button-border-width - border-style: default.$primary-button-border-style - border-color: default.$primary-button-border-color - border-radius: default.$border-radius - height: default.$input-height - margin: default.$item-margin - - &.primary:hover - color: default.$primary-button-color - background: default.$primary-button-background-hover - border-width: default.$primary-button-border-width-hover - border-style: default.$primary-button-border-style-hover - border-color: default.$primary-button-border-color-hover - - &.primary:focus - color: default.$primary-button-color-focus - background: default.$primary-button-background-focus - border-width: default.$primary-button-border-width-focus - border-style: default.$primary-button-border-style-focus - border-color: default.$primary-button-border-color-focus - - &.primary:disabled - color: default.$primary-button-color-disabled - background: default.$primary-button-background-disabled - border-width: default.$primary-button-border-width-disabled - border-style: default.$primary-button-border-style-disabled - border-color: default.$primary-button-border-color-disabled - - &.secondary - @include default.font - color: default.$secondary-button-color - background: default.$secondary-button-background - border-width: default.$secondary-button-border-width - border-style: default.$secondary-button-border-style - border-color: default.$secondary-button-border-color - border-radius: default.$border-radius - height: default.$input-height - margin: default.$item-margin - - &.secondary:hover - color: default.$secondary-button-color - background: default.$secondary-button-background-hover - border-width: default.$secondary-button-border-width-hover - border-style: default.$secondary-button-border-style-hover - border-color: default.$secondary-button-border-color-hover - - &.secondary:focus - color: default.$secondary-button-color-focus - background: default.$secondary-button-background-focus - border-width: default.$secondary-button-border-width-focus - border-style: default.$secondary-button-border-style-focus - border-color: default.$secondary-button-border-color-focus - - &.secondary:disabled - color: default.$secondary-button-color-disabled - background: default.$secondary-button-background-disabled - border-width: default.$secondary-button-border-width-disabled - border-style: default.$secondary-button-border-style-disabled - border-color: default.$secondary-button-border-color-disabled diff --git a/frontend/elements/src/ui/components/Checkmark.sass b/frontend/elements/src/ui/components/Checkmark.sass deleted file mode 100644 index 0f4169b9d..000000000 --- a/frontend/elements/src/ui/components/Checkmark.sass +++ /dev/null @@ -1,55 +0,0 @@ -@use 'default' - -.checkmark - display: inline-block - width: 16px - height: 16px - transform: rotate(45deg) - - .circle - box-sizing: border-box - display: inline-block - border-width: 2px - border-style: solid - border-color: default.$checkmark-color - position: absolute - width: 16px - height: 16px - border-radius: 11px - left: 0 - top: 0 - - &.secondary - border-color: default.$checkmark-color-secondary - - .stem - position: absolute - width: 2px - height: 7px - background-color: default.$checkmark-color - left: 8px - top: 3px - - &.secondary - background-color: default.$checkmark-color-secondary - - .kick - position: absolute - width: 5px - height: 2px - background-color: default.$checkmark-color - left: 5px - top: 10px - - &.secondary - background-color: default.$checkmark-color-secondary - - &.fadeOut - animation: fadeOut ease-out 1.5s forwards !important - -@keyframes fadeOut - 0% - opacity: 1 - - 100% - opacity: 0 diff --git a/frontend/elements/src/ui/components/Checkmark.tsx b/frontend/elements/src/ui/components/Checkmark.tsx deleted file mode 100644 index b8915f8da..000000000 --- a/frontend/elements/src/ui/components/Checkmark.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import * as preact from "preact"; -import cx from "classnames"; - -import styles from "./Checkmark.sass"; - -type Props = { - fadeOut?: boolean; - secondary?: boolean; -}; - -const Checkmark = ({ fadeOut, secondary }: Props) => { - return ( -
    -
    -
    -
    -
    - ); -}; - -export default Checkmark; diff --git a/frontend/elements/src/ui/components/Container.sass b/frontend/elements/src/ui/components/Container.sass deleted file mode 100644 index f912508e5..000000000 --- a/frontend/elements/src/ui/components/Container.sass +++ /dev/null @@ -1,14 +0,0 @@ -@use 'default' - -.container - background-color: default.$container-background - padding: default.$container-padding - max-width: default.$container-max-width - - display: flex - flex-direction: column - flex-wrap: nowrap - justify-content: center - align-items: center - align-content: flex-start - box-sizing: border-box \ No newline at end of file diff --git a/frontend/elements/src/ui/components/Container.tsx b/frontend/elements/src/ui/components/Container.tsx deleted file mode 100644 index 21abebacf..000000000 --- a/frontend/elements/src/ui/components/Container.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import * as preact from "preact"; -import { useEffect, useRef } from "preact/compat"; -import { ComponentChildren } from "preact"; - -import styles from "./Container.sass"; - -type Props = { - emitSuccessEvent?: boolean; - children: ComponentChildren; -}; - -const Container = ({ children, emitSuccessEvent }: Props) => { - const ref = useRef(null); - - useEffect(() => { - if (!emitSuccessEvent) { - return; - } - - const event = new Event("hankoAuthSuccess", { - bubbles: true, - composed: true, - }); - - const fn = setTimeout(() => { - ref.current.dispatchEvent(event); - }, 500); - - return () => clearTimeout(fn); - }, [emitSuccessEvent]); - - return ( -
    - {children} -
    - ); -}; - -export default Container; diff --git a/frontend/elements/src/ui/components/Content.sass b/frontend/elements/src/ui/components/Content.sass deleted file mode 100644 index 510274eba..000000000 --- a/frontend/elements/src/ui/components/Content.sass +++ /dev/null @@ -1,5 +0,0 @@ -.content - box-sizing: border-box - flex: 0 1 auto - width: 100% - height: 100% diff --git a/frontend/elements/src/ui/components/Divider.sass b/frontend/elements/src/ui/components/Divider.sass deleted file mode 100644 index 29980a347..000000000 --- a/frontend/elements/src/ui/components/Divider.sass +++ /dev/null @@ -1,24 +0,0 @@ -@use 'default' - -.dividerWrapper - @include default.font - display: default.$divider-display - visibility: default.$divider-visibility - margin: default.$item-margin - color: default.$divider-color - -.divider - border-bottom: default.$divider-border - color: inherit - font: inherit - - width: 100% - text-align: center - line-height: 0.1em - margin: 0 auto - - span - font: inherit - color: inherit - background: default.$container-background - padding: default.$divider-padding diff --git a/frontend/elements/src/ui/components/ErrorMessage.sass b/frontend/elements/src/ui/components/ErrorMessage.sass deleted file mode 100644 index e010e7cf4..000000000 --- a/frontend/elements/src/ui/components/ErrorMessage.sass +++ /dev/null @@ -1,17 +0,0 @@ -@use 'default' - -.errorMessage - @include default.font - color: default.$error-color - background: default.$error-background - border: default.$error-border - border-radius: default.$border-radius - padding: default.$error-padding - margin: default.$item-margin - - display: flex - align-items: center - box-sizing: border-box - - &[hidden] - display: none diff --git a/frontend/elements/src/ui/components/ExclamationMark.sass b/frontend/elements/src/ui/components/ExclamationMark.sass deleted file mode 100644 index 496b74f5b..000000000 --- a/frontend/elements/src/ui/components/ExclamationMark.sass +++ /dev/null @@ -1,34 +0,0 @@ -@use 'default' - -.exclamationMark - width: 16px - height: 16px - position: relative - margin: 10px - - .circle - box-sizing: border-box - display: inline-block - background-color: default.$error-color - position: absolute - width: 16px - height: 16px - border-radius: 11px - left: 0 - top: 0 - - .stem - position: absolute - width: 2px - height: 6px - background: default.$error-background - left: 7px - top: 3px - - .dot - position: absolute - width: 2px - height: 2px - background: default.$error-background - left: 7px - top: 10px diff --git a/frontend/elements/src/ui/components/Footer.sass b/frontend/elements/src/ui/components/Footer.sass deleted file mode 100644 index 8bd0ad60a..000000000 --- a/frontend/elements/src/ui/components/Footer.sass +++ /dev/null @@ -1,12 +0,0 @@ -@use 'default' - -.footer - padding: default.$item-margin - box-sizing: border-box - width: 100% - - \:nth-child(1) - float: left - - \:nth-child(2) - float: right diff --git a/frontend/elements/src/ui/components/Form.sass b/frontend/elements/src/ui/components/Form.sass deleted file mode 100644 index 9f7d2baa3..000000000 --- a/frontend/elements/src/ui/components/Form.sass +++ /dev/null @@ -1,7 +0,0 @@ -.ul - padding-inline-start: 0 - list-style-type: none - margin: 0 - -.li - display: flex diff --git a/frontend/elements/src/ui/components/Headline.sass b/frontend/elements/src/ui/components/Headline.sass deleted file mode 100644 index 54b0d5e0b..000000000 --- a/frontend/elements/src/ui/components/Headline.sass +++ /dev/null @@ -1,12 +0,0 @@ -@use 'default' - -.title - color: default.$color - font-family: default.$font-family - font-size: default.$headline-font-size - font-weight: default.$headline-font-weight - display: default.$headline-display - margin: default.$headline-margin - text-align: left - letter-spacing: 0 - font-style: normal diff --git a/frontend/elements/src/ui/components/Headline.tsx b/frontend/elements/src/ui/components/Headline.tsx deleted file mode 100644 index 285d3c192..000000000 --- a/frontend/elements/src/ui/components/Headline.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import * as preact from "preact"; -import { ComponentChildren } from "preact"; - -import styles from "./Headline.sass"; - -type Props = { - children: ComponentChildren; -}; - -const Headline = ({ children }: Props) => { - return ( -

    - {children} -

    - ); -}; - -export default Headline; diff --git a/frontend/elements/src/ui/components/Input.sass b/frontend/elements/src/ui/components/Input.sass deleted file mode 100644 index 65f935685..000000000 --- a/frontend/elements/src/ui/components/Input.sass +++ /dev/null @@ -1,88 +0,0 @@ -@use 'default' - -.inputWrapper - position: relative - margin: default.$item-margin - display: flex - flex-grow: 1 - -.label - @include default.font - background: default.$text-input-background - color: default.$text-input-color - - left: 0 - top: 50% - position: absolute - transform: translateY(-50%) - padding: 0 0.3rem - margin: 0 0.5rem - transition: 0.1s ease - transform-origin: left top - pointer-events: none - -.input - @include default.font - height: default.$input-height - color: default.$text-input-color - border-style: default.$text-input-border-style - border-width: default.$text-input-border-width - border-color: default.$text-input-border-color - border-radius: default.$border-radius - background: default.$text-input-background - padding: default.$text-input-padding - - width: 100% - outline: none - box-sizing: border-box - transition: 0.1s ease-out - - &:focus + .label - color: default.$text-input-color-focus - top: 0 - transform: translateY(-50%) scale(0.9) !important - opacity: 1 - transition: opacity 1s - -webkit-transition: opacity 1s - - &:not(:placeholder-shown) + .label - top: 0 - transform: translateY(-50%) scale(0.9) !important - - &:-webkit-autofill - -webkit-box-shadow: 0 0 0 50px default.$text-input-background inset - - &::first-line - color: default.$text-input-color-focus - - // Removes native "clear text" and "password reveal" buttons from Edge - &::-ms-reveal, &::-ms-clear - display: none - - &:focus - color: default.$text-input-color-focus - border-style: default.$text-input-border-style-focus - border-width: default.$text-input-border-width-focus - border-color: default.$text-input-border-color-focus - - &:disabled - color: default.$text-input-color-disabled - background: default.$text-input-background-disabled - border-style: default.$text-input-border-style-disabled - border-width: default.$text-input-border-width-disabled - border-color: default.$text-input-border-color-disabled - -.passcodeInputWrapper - display: flex - justify-content: space-between - margin: default.$item-margin - -.passcodeDigitWrapper - flex-grow: 1 - margin: 0 default.$passcode-input-space-between 0 0 - - &:last-child - margin: 0 - - input - text-align: center diff --git a/frontend/elements/src/ui/components/InputPasscodeDigit.tsx b/frontend/elements/src/ui/components/InputPasscodeDigit.tsx deleted file mode 100644 index 1dafa9c44..000000000 --- a/frontend/elements/src/ui/components/InputPasscodeDigit.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import * as preact from "preact"; -import { h } from "preact"; -import { useEffect, useMemo, useRef } from "preact/compat"; - -import styles from "./Input.sass"; - -interface Props extends h.JSX.HTMLAttributes { - index: number; - focus: boolean; - digit: string; -} - -const InputPasscodeDigit = ({ index, focus, digit = "", ...props }: Props) => { - const ref = useRef(null); - - const focusInput = () => { - const { current: element } = ref; - if (element) { - element.focus(); - element.select(); - } - }; - - // Autofocus if it's the first input element - useEffect(() => { - if (index === 0) { - focusInput(); - } - }, [index, props.disabled]); - - // Focus the current input element - useMemo(() => { - if (focus) { - focusInput(); - } - }, [focus]); - - return ( -
    - -
    - ); -}; - -export default InputPasscodeDigit; diff --git a/frontend/elements/src/ui/components/Link.sass b/frontend/elements/src/ui/components/Link.sass deleted file mode 100644 index 0c8f85fe4..000000000 --- a/frontend/elements/src/ui/components/Link.sass +++ /dev/null @@ -1,16 +0,0 @@ -@use "default" - -.link - @include default.font - color: default.$link-color - text-decoration: default.$link-text-decoration - cursor: pointer - - &:hover - color: default.$link-color-hover - text-decoration: default.$link-text-decoration-hover - - &.disabled - color: default.$link-color-disabled - pointer-events: none - cursor: default diff --git a/frontend/elements/src/ui/components/Link.tsx b/frontend/elements/src/ui/components/Link.tsx deleted file mode 100644 index b19679f77..000000000 --- a/frontend/elements/src/ui/components/Link.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import * as preact from "preact"; -import { ComponentChildren, FunctionalComponent } from "preact"; -import cx from "classnames"; - -import styles from "./Link.sass"; - -export type Props = { - children?: ComponentChildren; - onClick?: (event: Event) => void; - disabled?: boolean; - hidden?: boolean; -}; - -const Link: FunctionalComponent = ({ - children, - onClick, - disabled, - hidden, -}: Props) => { - return ( - - ); -}; - -export default Link; diff --git a/frontend/elements/src/ui/components/LoadingIndicator.sass b/frontend/elements/src/ui/components/LoadingIndicator.sass deleted file mode 100644 index b6f25cd8e..000000000 --- a/frontend/elements/src/ui/components/LoadingIndicator.sass +++ /dev/null @@ -1,3 +0,0 @@ -.loadingIndicator - display: inline-block - margin: 0 5px diff --git a/frontend/elements/src/ui/components/LoadingWheel.sass b/frontend/elements/src/ui/components/LoadingWheel.sass deleted file mode 100644 index b74a68333..000000000 --- a/frontend/elements/src/ui/components/LoadingWheel.sass +++ /dev/null @@ -1,20 +0,0 @@ -@use 'default' - -.loadingWheel - box-sizing: border-box - display: inline-block - border-width: 2px - border-style: solid - border-color: default.$container-background - border-top: 2px solid default.$primary-button-background - border-radius: 50% - width: 16px - height: 16px - animation: spin 500ms ease-in-out infinite - -@keyframes spin - 0% - transform: rotate(0deg) - - 100% - transform: rotate(360deg) diff --git a/frontend/elements/src/ui/components/LoadingWheel.tsx b/frontend/elements/src/ui/components/LoadingWheel.tsx deleted file mode 100644 index 189dd92be..000000000 --- a/frontend/elements/src/ui/components/LoadingWheel.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import * as preact from "preact"; - -import styles from "./LoadingWheel.sass"; - -const LoadingWheel = () => { - return
    ; -}; - -export default LoadingWheel; diff --git a/frontend/elements/src/ui/components/Paragraph.sass b/frontend/elements/src/ui/components/Paragraph.sass deleted file mode 100644 index 802b61e86..000000000 --- a/frontend/elements/src/ui/components/Paragraph.sass +++ /dev/null @@ -1,7 +0,0 @@ -@use 'default' - -.paragraph - @include default.font - text-align: left - color: default.$color - margin: default.$item-margin diff --git a/frontend/elements/src/ui/components/_default.sass b/frontend/elements/src/ui/components/_default.sass deleted file mode 100644 index 4dbf04812..000000000 --- a/frontend/elements/src/ui/components/_default.sass +++ /dev/null @@ -1,143 +0,0 @@ -@use 'preset' -@use 'sass:list' - -@function convert-hsl-color($variable, $default-hsl, $lightness-adjust: 0%) - $default-h: list.nth($default-hsl, 1) - $default-s: list.nth($default-hsl, 2) - $default-l: list.nth($default-hsl, 3) - @return #{"hsl(var(#{$variable}-h, #{$default-h}), var(#{$variable}-s, #{$default-s}), calc(var(#{$variable}-l, #{$default-l}) + #{$lightness-adjust}))"} - -// Default General Styles - -$font-weight: var(--font-weight, preset.$font-weight) -$font-size: var(--font-size, preset.$font-size) -$font-family: var(--font-family, preset.$font-family) -$color: convert-hsl-color(--color, preset.$color-hsl) -$border-radius: var(--border-radius, preset.$border-radius) -$input-height: var(--input-height, preset.$input-height) -$item-margin: var(--item-margin, preset.$item-margin) - -// Default Lightness Adjust - -$lightness-adjust-dark: var(--lightness-adjust-dark, preset.$lightness-adjust-dark) -$lightness-adjust-dark-light: var(--lightness-adjust-dark-light, preset.$lightness-adjust-dark-light) -$lightness-adjust-light-dark: var(--lightness-adjust-light-dark, preset.$lightness-adjust-light-dark) -$lightness-adjust-light: var(--lightness-adjust-light, preset.$lightness-adjust-light) - -// Default Container Styles - -$container-background: convert-hsl-color(--background-color, preset.$background-hsl) -$container-padding: var(--container-padding, preset.$container-padding) -$container-max-width: var(--container-max-width, preset.$container-max-width) - -// Default Text Input Styles - -$text-input-padding: preset.$text-input-padding -$text-input-color: convert-hsl-color(--color, preset.$color-hsl, $lightness-adjust-light) -$text-input-color-focus: convert-hsl-color(--color, preset.$color-hsl) -$text-input-color-disabled: convert-hsl-color(--color, preset.$color-hsl, $lightness-adjust-dark) -$text-input-border-width: var(--border-width, preset.$border-width) -$text-input-border-width-focus: var(--border-width, preset.$border-width) -$text-input-border-style: var(--border-style, preset.$border-style) -$text-input-border-style-focus: var(--border-style, preset.$border-style) -$text-input-border-color: convert-hsl-color(--color, preset.$color-hsl, $lightness-adjust-light) -$text-input-border-color-focus: convert-hsl-color(--color, preset.$color-hsl) -$text-input-border-width-disabled: var(--border-width, preset.$border-width) -$text-input-border-style-disabled: var(--border-style, preset.$border-style) -$text-input-border-color-disabled: convert-hsl-color(--color, preset.$color-hsl, $lightness-adjust-dark) -$text-input-background: convert-hsl-color(--background-color, preset.$background-hsl) -$text-input-background-disabled: convert-hsl-color(--background-color, preset.$background-hsl, $lightness-adjust-dark-light) - -// Default Primary Button Styles - -$primary-button-color: convert-hsl-color(--background-color, preset.$color-hsl) -$primary-button-color-focus: convert-hsl-color(--background-color, preset.$color-hsl, $lightness-adjust-light-dark) -$primary-button-color-disabled: convert-hsl-color(--background-color, preset.$color-hsl, $lightness-adjust-light-dark) -$primary-button-border-width: var(--border-width, preset.$border-width) -$primary-button-border-width-hover: var(--border-width, preset.$border-width) -$primary-button-border-width-focus: var(--border-width, preset.$border-width) -$primary-button-border-width-disabled: var(--border-width, preset.$border-width) -$primary-button-border-style: var(--border-style, preset.$border-style) -$primary-button-border-style-hover: var(--border-style, preset.$border-style) -$primary-button-border-style-focus: var(--border-style, preset.$border-style) -$primary-button-border-style-disabled: var(--border-style, preset.$border-style) -$primary-button-border-color: convert-hsl-color(--brand-color, preset.$brand-color-hsl) -$primary-button-border-color-hover: convert-hsl-color(--brand-color, preset.$brand-color-hsl) -$primary-button-border-color-focus: convert-hsl-color(--brand-color, preset.$brand-color-hsl, $lightness-adjust-light-dark) -$primary-button-border-color-disabled: convert-hsl-color(--brand-color, preset.$brand-color-hsl, $lightness-adjust-light) -$primary-button-background: convert-hsl-color(--brand-color, preset.$brand-color-hsl) -$primary-button-background-hover: convert-hsl-color(--brand-color, preset.$brand-color-hsl, $lightness-adjust-light-dark) -$primary-button-background-focus: convert-hsl-color(--brand-color, preset.$brand-color-hsl) -$primary-button-background-disabled: convert-hsl-color(--brand-color, preset.$brand-color-hsl, $lightness-adjust-light) - -// Default Secondary Button Styles - -$secondary-button-color: convert-hsl-color(--color, preset.$color-hsl) -$secondary-button-color-focus: convert-hsl-color(--color, preset.$color-hsl, $lightness-adjust-light) -$secondary-button-color-disabled: convert-hsl-color(--color, preset.$color-hsl, $lightness-adjust-light-dark) -$secondary-button-border-width: var(--border-width, preset.$border-width) -$secondary-button-border-width-hover: var(--border-width, preset.$border-width) -$secondary-button-border-width-focus: var(--border-width, preset.$border-width) -$secondary-button-border-width-disabled: var(--border-width, preset.$border-width) -$secondary-button-border-style: var(--border-style, preset.$border-style) -$secondary-button-border-style-hover: var(--border-style, preset.$border-style) -$secondary-button-border-style-focus: var(--border-style, preset.$border-style) -$secondary-button-border-style-disabled: var(--border-style, preset.$border-style) -$secondary-button-border-color: convert-hsl-color(--color, preset.$color-hsl, $lightness-adjust-light) -$secondary-button-border-color-hover: convert-hsl-color(--color, preset.$color-hsl) -$secondary-button-border-color-focus: convert-hsl-color(--color, preset.$color-hsl, $lightness-adjust-light-dark) -$secondary-button-border-color-disabled: convert-hsl-color(--color, preset.$color-hsl, $lightness-adjust-light) -$secondary-button-background: convert-hsl-color(--background-color, preset.$background-hsl) -$secondary-button-background-hover: convert-hsl-color(--background-color, preset.$background-hsl, $lightness-adjust-dark-light) -$secondary-button-background-focus: convert-hsl-color(--background-color, preset.$background-hsl) -$secondary-button-background-disabled: convert-hsl-color(--background-color, preset.$background-hsl, $lightness-adjust-light-dark) - -// Default Headline Styles - -$headline-font-weight: var(--headline-font-weight, preset.$headline-font-weight) -$headline-font-size: var(--headline-font-size, preset.$headline-font-size) -$headline-font-family: var(--font-family, preset.$font-family) -$headline-display: preset.$headline-display -$headline-margin: var(--item-margin, preset.$item-margin) - -// Default Divider Styles - -$divider-color: convert-hsl-color(--color, preset.$color-hsl, $lightness-adjust-light) -$divider-padding: preset.$divider-padding -$divider-border-width: var(--border-width, preset.$border-width) -$divider-border-style: var(--border-style, preset.$border-style) -$divider-border: $divider-border-width $divider-border-style convert-hsl-color(--color, preset.$color-hsl, $lightness-adjust-light) -$divider-display: preset.$divider-display -$divider-visibility: preset.$divider-visibility - -// Default Error Styles - -$error-color: convert-hsl-color(--error-color, preset.$error-color-hsl) -$error-background: convert-hsl-color(--background-color, preset.$background-hsl) -$error-padding: preset.$error-padding -$error-border-width: var(--border-width, preset.$border-width) -$error-border-style: var(--border-style, preset.$border-style) -$error-border: $error-border-width $error-border-style convert-hsl-color(--error-color, preset.$error-color-hsl) - -// Default Passcode Input Styles - -$passcode-input-space-between: preset.$passcode-input-space-between - -// Default Link Styles - -$link-color: convert-hsl-color(--color, preset.$color-hsl, $lightness-adjust-light) -$link-color-disabled: convert-hsl-color(--color, preset.$color-hsl, $lightness-adjust-light-dark) -$link-color-hover: convert-hsl-color(--color, preset.$color-hsl) -$link-text-decoration-hover: preset.$link-text-decoration-hover -$link-text-decoration: preset.$link-text-decoration - -// Default Checkmark Styles - -$checkmark-color: convert-hsl-color(--brand-color, preset.$brand-color-hsl) -$checkmark-color-secondary: convert-hsl-color(---background-color, preset.$background-hsl) - -@mixin font - font-weight: $font-weight - font-size: $font-size - font-family: $font-family - diff --git a/frontend/elements/src/ui/components/_preset.sass b/frontend/elements/src/ui/components/_preset.sass deleted file mode 100644 index 3c1aed1ab..000000000 --- a/frontend/elements/src/ui/components/_preset.sass +++ /dev/null @@ -1,55 +0,0 @@ -// General Styles - -$background-hsl: 0, 0%, 100% -$font-weight: 400 -$font-size: 16px -$font-family: sans-serif -$color-hsl: 0, 0%, 0% -$brand-color-hsl: 230, 100%, 90% -$border-radius: 3px -$border-style: solid -$border-width: 1.5px -$input-height: 50px -$item-margin: 15px 0 - -// Lightness Adjust - -$lightness-adjust-dark: -30% -$lightness-adjust-dark-light: -10% -$lightness-adjust-light-dark: 2% -$lightness-adjust-light: 5% - -// Container Styles -$container-padding: 0 15px -$container-max-width: 600px - -// Text Input Styles - -$text-input-padding: 0 0.7rem - -// Headline Styles - -$headline-font-weight: 700 -$headline-font-size: 30px -$headline-display: block - -// Divider Styles - -$divider-padding: 0 42px -$divider-display: block -$divider-visibility: visible - -// Error Styles - -$error-color-hsl: 351, 100%, 59% -$error-padding: 5px - -// Passcode Input Styles - -$passcode-input-space-between: 10px - -// Link Styles - -$link-color-hsl: 204, 17%, 49% -$link-text-decoration: none -$link-text-decoration-hover: underline diff --git a/frontend/elements/src/ui/components/link/toEmailLogin.tsx b/frontend/elements/src/ui/components/link/toEmailLogin.tsx deleted file mode 100644 index fde94b97a..000000000 --- a/frontend/elements/src/ui/components/link/toEmailLogin.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import * as preact from "preact"; -import { FunctionalComponent, RenderableProps } from "preact"; -import { useContext } from "preact/compat"; - -import { TranslateContext } from "@denysvuika/preact-translate"; -import { RenderContext } from "../../contexts/PageProvider"; - -import Link, { Props as LinkProps } from "../Link"; - -const linkToEmailLogin =

    ( - LinkComponent: FunctionalComponent -) => { - return function LinkToEmailLogin(props: RenderableProps

    ) { - const { t } = useContext(TranslateContext); - const { renderLoginEmail } = useContext(RenderContext); - - const onClick = () => { - renderLoginEmail(); - }; - - return ( - - {t("labels.back")} - - ); - }; -}; - -export default linkToEmailLogin(Link); diff --git a/frontend/elements/src/ui/components/link/toPasswordLogin.tsx b/frontend/elements/src/ui/components/link/toPasswordLogin.tsx deleted file mode 100644 index bbbe364a9..000000000 --- a/frontend/elements/src/ui/components/link/toPasswordLogin.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import * as preact from "preact"; -import { FunctionalComponent, RenderableProps } from "preact"; -import { useContext } from "preact/compat"; - -import { TranslateContext } from "@denysvuika/preact-translate"; -import { RenderContext } from "../../contexts/PageProvider"; - -import Link, { Props as LinkProps } from "../Link"; - -interface Props { - userID: string; -} - -const linkToPasswordLogin =

    ( - LinkComponent: FunctionalComponent -) => { - return function LinkToPasswordLogin(props: RenderableProps

    ) { - const { t } = useContext(TranslateContext); - const { renderPassword, renderError } = useContext(RenderContext); - - const onClick = () => { - renderPassword(props.userID).catch((e) => renderError(e)); - }; - - return ( - - {t("labels.back")} - - ); - }; -}; - -export default linkToPasswordLogin(Link); diff --git a/frontend/elements/src/ui/components/link/withLoadingIndicator.sass b/frontend/elements/src/ui/components/link/withLoadingIndicator.sass deleted file mode 100644 index 0e6940d1b..000000000 --- a/frontend/elements/src/ui/components/link/withLoadingIndicator.sass +++ /dev/null @@ -1,9 +0,0 @@ -.linkWithLoadingIndicator - display: inline-flex - flex-direction: row - justify-content: space-between - align-items: center - height: 20px - - &.swap - flex-direction: row-reverse diff --git a/frontend/elements/src/ui/components/link/withLoadingIndicator.tsx b/frontend/elements/src/ui/components/link/withLoadingIndicator.tsx deleted file mode 100644 index 8e16b08cc..000000000 --- a/frontend/elements/src/ui/components/link/withLoadingIndicator.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import * as preact from "preact"; -import { FunctionalComponent, RenderableProps } from "preact"; -import cx from "classnames"; - -import Link, { Props as LinkProps } from "../Link"; -import LoadingIndicator, { - Props as LoadingIndicatorProps, -} from "../LoadingIndicator"; - -import styles from "./withLoadingIndicator.sass"; - -export interface Props { - swap?: boolean; -} - -const linkWithLoadingIndicator = < - P extends Props & LinkProps & LoadingIndicatorProps ->( - LinkComponent: FunctionalComponent -) => { - return function LinkWithLoadingIndicator(props: RenderableProps

    ) { - return ( - - ); - }; -}; - -export default linkWithLoadingIndicator< - Props & LinkProps & LoadingIndicatorProps ->(Link); diff --git a/frontend/elements/src/ui/contexts/AppProvider.tsx b/frontend/elements/src/ui/contexts/AppProvider.tsx deleted file mode 100644 index b88ee7280..000000000 --- a/frontend/elements/src/ui/contexts/AppProvider.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import * as preact from "preact"; -import { ComponentChildren, createContext } from "preact"; -import { useCallback, useMemo, useState } from "preact/compat"; - -import { Hanko, Config } from "@teamhanko/hanko-frontend-sdk"; - -type ExperimentalFeature = "conditionalMediation"; -type ExperimentalFeatures = ExperimentalFeature[]; - -interface Props { - api?: string; - lang?: string; - experimental?: string; - children: ComponentChildren; -} - -interface Context { - config: Config; - experimentalFeatures?: ExperimentalFeatures; - configInitialize: () => Promise; - hanko: Hanko; -} - -export const AppContext = createContext(null); - -const AppProvider = ({ api, children, experimental = "" }: Props) => { - const [config, setConfig] = useState(null); - - const hanko = useMemo(() => { - if (api.length) { - return new Hanko(api, 13000); - } - return null; - }, [api]); - - const experimentalFeatures = useMemo( - () => - experimental - .split(" ") - .filter((feature) => feature.length) - .map((feature) => feature as ExperimentalFeature), - [experimental] - ); - - const configInitialize = useCallback(() => { - return new Promise((resolve, reject) => { - if (!hanko) { - return; - } - - hanko.config - .get() - .then((c) => { - setConfig(c); - - return resolve(c); - }) - .catch((e) => reject(e)); - }); - }, [hanko]); - - return ( - - {children} - - ); -}; - -export default AppProvider; diff --git a/frontend/elements/src/ui/contexts/PageProvider.tsx b/frontend/elements/src/ui/contexts/PageProvider.tsx deleted file mode 100644 index 59c779042..000000000 --- a/frontend/elements/src/ui/contexts/PageProvider.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import * as preact from "preact"; -import { createContext, h } from "preact"; -import { - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "preact/compat"; - -import { HankoError, User } from "@teamhanko/hanko-frontend-sdk"; - -import { AppContext } from "./AppProvider"; -import { PasswordContext } from "./PasswordProvider"; -import { PasscodeContext } from "./PasscodeProvider"; -import { TranslateContext } from "@denysvuika/preact-translate"; - -import Initialize from "../pages/Initialize"; -import LoginEmail from "../pages/LoginEmail"; -import LoginPasscode from "../pages/LoginPasscode"; -import LoginPassword from "../pages/LoginPassword"; -import LoginFinished from "../pages/LoginFinished"; -import RegisterConfirm from "../pages/RegisterConfirm"; -import RegisterPassword from "../pages/RegisterPassword"; -import RegisterAuthenticator from "../pages/RegisterAuthenticator"; -import Error from "../pages/Error"; -import Container from "../components/Container"; - -interface Props { - lang?: string; -} - -interface Context { - emitSuccessEvent: () => void; - eventuallyRenderEnrollment: ( - user: User, - recoverPassword: boolean - ) => Promise; - renderPassword: (userID: string) => Promise; - renderPasscode: ( - userID: string, - recoverPassword: boolean, - hideBackButton: boolean - ) => Promise; - renderError: (e: HankoError) => void; - renderLoginEmail: () => void; - renderLoginFinished: () => void; - renderRegisterConfirm: () => void; - renderRegisterAuthenticator: () => void; - renderInitialize: () => void; -} - -export const RenderContext = createContext(null); - -const PageProvider = ({ lang }: Props) => { - const { hanko } = useContext(AppContext); - const { passwordInitialize } = useContext(PasswordContext); - const { passcodeInitialize } = useContext(PasscodeContext); - const { setLang } = useContext(TranslateContext); - const [page, setPage] = useState(); - const [loginFinished, setLoginFinished] = useState(false); - - const emitSuccessEvent = useCallback(() => { - setLoginFinished(true); - }, []); - - const pages = useMemo( - () => ({ - loginEmail: () => setPage(), - loginPasscode: ( - userID: string, - recoverPassword: boolean, - initialError?: HankoError, - hideBackLink?: boolean - ) => - setPage( - - ), - loginPassword: (userID: string, initialError: HankoError) => - setPage(), - registerConfirm: () => setPage(), - registerPassword: (user: User, enrollWebauthn: boolean) => - setPage( - - ), - registerAuthenticator: () => setPage(), - loginFinished: () => setPage(), - error: (error: HankoError) => setPage(), - initialize: () => setPage(), - }), - [] - ); - - const renderLoginEmail = useCallback(() => { - pages.loginEmail(); - }, [pages]); - - const renderLoginFinished = useCallback(() => { - pages.loginFinished(); - }, [pages]); - - const renderPassword = useCallback( - (userID: string) => { - return new Promise((resolve, reject) => { - passwordInitialize(userID) - .then((e) => pages.loginPassword(userID, e)) - .catch((e) => reject(e)); - }); - }, - [pages, passwordInitialize] - ); - - const renderPasscode = useCallback( - (userID: string, recoverPassword: boolean, hideBackButton: boolean) => { - return new Promise((resolve, reject) => { - passcodeInitialize(userID) - .then((e) => { - pages.loginPasscode(userID, recoverPassword, e, hideBackButton); - - return resolve(); - }) - .catch((e) => reject(e)); - }); - }, - [pages, passcodeInitialize] - ); - - const eventuallyRenderEnrollment = useCallback( - (user: User, recoverPassword: boolean) => { - return new Promise((resolve, reject) => { - hanko.webauthn - .shouldRegister(user) - .then((shouldRegisterAuthenticator) => { - let rendered = true; - if (recoverPassword) { - pages.registerPassword(user, shouldRegisterAuthenticator); - } else if (shouldRegisterAuthenticator) { - pages.registerAuthenticator(); - } else { - rendered = false; - } - - return resolve(rendered); - }) - .catch((e) => reject(e)); - }); - }, - [hanko, pages] - ); - - const renderRegisterConfirm = useCallback(() => { - pages.registerConfirm(); - }, [pages]); - - const renderRegisterAuthenticator = useCallback(() => { - pages.registerAuthenticator(); - }, [pages]); - - const renderError = useCallback( - (e: HankoError) => { - pages.error(e); - }, - [pages] - ); - - const renderInitialize = useCallback(() => { - pages.initialize(); - }, [pages]); - - useEffect(() => { - setLang(lang); - }, [lang, setLang]); - - return ( - - {page} - - ); -}; - -export default PageProvider; diff --git a/frontend/elements/src/ui/contexts/PasscodeProvider.tsx b/frontend/elements/src/ui/contexts/PasscodeProvider.tsx deleted file mode 100644 index 6b0d1016e..000000000 --- a/frontend/elements/src/ui/contexts/PasscodeProvider.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import * as preact from "preact"; -import { ComponentChildren, createContext, FunctionalComponent } from "preact"; -import { useCallback, useContext, useEffect, useState } from "preact/compat"; - -import { - HankoError, - TooManyRequestsError, - MaxNumOfPasscodeAttemptsReachedError, -} from "@teamhanko/hanko-frontend-sdk"; - -import { AppContext } from "./AppProvider"; - -interface Props { - children: ComponentChildren; -} - -interface Context { - passcodeIsActive: boolean; - passcodeTTL: number; - passcodeResendAfter: number; - passcodeInitialize: (userID: string) => Promise; - passcodeResend: (userID: string) => Promise; - passcodeFinalize: (userID: string, passcode: string) => Promise; -} - -export const PasscodeContext = createContext(null); - -const PasscodeProvider: FunctionalComponent = ({ children }: Props) => { - const { hanko } = useContext(AppContext); - - const [passcodeTTL, setPasscodeTTL] = useState(0); - const [passcodeResendAfter, setPasscodeResendAfter] = useState(0); - const [passcodeIsActive, setPasscodeIsActive] = useState(false); - - const passcodeResend = useCallback( - (userID: string): Promise => { - return new Promise((resolve, reject) => { - hanko.passcode - .initialize(userID) - .then((passcode) => { - setPasscodeTTL(passcode.ttl); - setPasscodeIsActive(true); - - return resolve(); - }) - .catch((e) => { - if (e instanceof TooManyRequestsError) { - setPasscodeResendAfter(e.retryAfter); - } - - reject(e); - }); - }); - }, - [hanko] - ); - - const passcodeInitialize = useCallback( - (userID: string) => { - return new Promise((resolve, reject) => { - const ttl = hanko.passcode.getTTL(userID); - const resendAfter = hanko.passcode.getResendAfter(userID); - - setPasscodeTTL(ttl); - setPasscodeResendAfter(resendAfter); - - if (ttl > 0) { - setPasscodeIsActive(true); - - return resolve(null); - } else if (resendAfter <= 0) { - passcodeResend(userID) - .then(() => { - setPasscodeIsActive(true); - - return resolve(null); - }) - .catch((e) => { - if (e instanceof TooManyRequestsError) { - resolve(e); - } else { - reject(e); - } - }); - } else { - resolve(new TooManyRequestsError(resendAfter)); - } - }); - }, - [hanko, passcodeResend] - ); - - const passcodeFinalize = useCallback( - (userID: string, code: string) => { - return new Promise((resolve, reject) => { - hanko.passcode - .finalize(userID, code) - .then(() => resolve()) - .catch((e) => { - if (e instanceof MaxNumOfPasscodeAttemptsReachedError) { - setPasscodeIsActive(false); - } - - reject(e); - }); - }); - }, - [hanko] - ); - - useEffect(() => { - const timer = - passcodeTTL > 0 && - setInterval(() => setPasscodeTTL(passcodeTTL - 1), 1000); - - return () => clearInterval(timer); - }, [passcodeTTL]); - - useEffect(() => { - const timer = - passcodeResendAfter > 0 && - setInterval(() => setPasscodeResendAfter(passcodeResendAfter - 1), 1000); - - return () => clearInterval(timer); - }, [passcodeResendAfter]); - - return ( - - {children} - - ); -}; - -export default PasscodeProvider; diff --git a/frontend/elements/src/ui/contexts/PasswordProvider.tsx b/frontend/elements/src/ui/contexts/PasswordProvider.tsx deleted file mode 100644 index 965b50d45..000000000 --- a/frontend/elements/src/ui/contexts/PasswordProvider.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import * as preact from "preact"; -import { ComponentChildren, createContext } from "preact"; -import { useCallback, useContext, useEffect, useState } from "preact/compat"; - -import { - HankoError, - TooManyRequestsError, -} from "@teamhanko/hanko-frontend-sdk"; - -import { AppContext } from "./AppProvider"; - -interface Props { - children: ComponentChildren; -} - -interface Context { - passwordInitialize: (userID: string) => Promise; - passwordFinalize: (userID: string, password: string) => Promise; - passwordRetryAfter: number; -} - -export const PasswordContext = createContext(null); - -const PasswordProvider = ({ children }: Props) => { - const { hanko } = useContext(AppContext); - const [passwordRetryAfter, setPasswordRetryAfter] = useState(0); - - const passwordInitialize = useCallback( - (userID: string) => { - return new Promise((resolve) => { - const retryAfter = hanko.password.getRetryAfter(userID); - - setPasswordRetryAfter(retryAfter); - resolve(retryAfter > 0 ? new TooManyRequestsError(retryAfter) : null); - }); - }, - [hanko] - ); - - const passwordFinalize = useCallback( - (userID: string, password: string) => { - return new Promise((resolve, reject) => { - hanko.password - .login(userID, password) - .then(() => resolve()) - .catch((e) => { - if (e instanceof TooManyRequestsError) { - setPasswordRetryAfter(e.retryAfter); - } - - return reject(e); - }); - }); - }, - [hanko] - ); - - useEffect(() => { - const timer = - passwordRetryAfter > 0 && - setInterval(() => setPasswordRetryAfter(passwordRetryAfter - 1), 1000); - - return () => clearInterval(timer); - }, [passwordRetryAfter]); - - return ( - - {children} - - ); -}; - -export default PasswordProvider; diff --git a/frontend/elements/src/ui/contexts/UserProvider.tsx b/frontend/elements/src/ui/contexts/UserProvider.tsx deleted file mode 100644 index 91020538b..000000000 --- a/frontend/elements/src/ui/contexts/UserProvider.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import * as preact from "preact"; -import { ComponentChildren } from "preact"; -import { - createContext, - StateUpdater, - useCallback, - useContext, - useState, -} from "preact/compat"; - -import { User } from "@teamhanko/hanko-frontend-sdk"; - -import { AppContext } from "./AppProvider"; - -interface Props { - children: ComponentChildren; -} - -interface Context { - user: User; - email: string; - setEmail: StateUpdater; - userInitialize: () => Promise; -} - -export const UserContext = createContext(null); - -const UserProvider = ({ children }: Props) => { - const { hanko } = useContext(AppContext); - const [user, setUser] = useState(null); - const [email, setEmail] = useState(null); - - const userInitialize = useCallback(() => { - return new Promise((resolve, reject) => { - hanko.user - .getCurrent() - .then((u) => { - setUser(u); - - return resolve(u); - }) - .catch((e) => { - reject(e); - }); - }); - }, [hanko]); - - return ( - - {children} - - ); -}; - -export default UserProvider; diff --git a/frontend/elements/src/ui/pages/Error.tsx b/frontend/elements/src/ui/pages/Error.tsx deleted file mode 100644 index 3bc33a21d..000000000 --- a/frontend/elements/src/ui/pages/Error.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import * as preact from "preact"; -import { useContext, useState } from "preact/compat"; - -import { HankoError } from "@teamhanko/hanko-frontend-sdk"; - -import { TranslateContext } from "@denysvuika/preact-translate"; -import { RenderContext } from "../contexts/PageProvider"; - -import ErrorMessage from "../components/ErrorMessage"; -import Form from "../components/Form"; -import Button from "../components/Button"; -import Content from "../components/Content"; -import Headline from "../components/Headline"; - -interface Props { - initialError: HankoError; -} - -const Error = ({ initialError }: Props) => { - const { t } = useContext(TranslateContext); - const { renderInitialize } = useContext(RenderContext); - - const [isLoading, setIsLoading] = useState(false); - - const onContinueClick = (event: Event) => { - event.preventDefault(); - setIsLoading(true); - renderInitialize(); - }; - - return ( - - {t("headlines.error")} - -

    - -
    - - ); -}; - -export default Error; diff --git a/frontend/elements/src/ui/pages/Initialize.tsx b/frontend/elements/src/ui/pages/Initialize.tsx deleted file mode 100644 index 77c0c3183..000000000 --- a/frontend/elements/src/ui/pages/Initialize.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import * as preact from "preact"; -import { useContext, useEffect } from "preact/compat"; - -import { UnauthorizedError } from "@teamhanko/hanko-frontend-sdk"; - -import { AppContext } from "../contexts/AppProvider"; -import { UserContext } from "../contexts/UserProvider"; -import { RenderContext } from "../contexts/PageProvider"; - -import LoadingIndicator from "../components/LoadingIndicator"; - -const Initialize = () => { - const { config, configInitialize } = useContext(AppContext); - const { userInitialize } = useContext(UserContext); - const { - eventuallyRenderEnrollment, - renderLoginEmail, - renderLoginFinished, - renderError, - } = useContext(RenderContext); - - useEffect(() => { - configInitialize().catch((e) => renderError(e)); - }, [configInitialize, renderError]); - - useEffect(() => { - if (config === null) { - return; - } - - userInitialize() - .then((u) => eventuallyRenderEnrollment(u, false)) - .then((rendered) => { - if (!rendered) { - renderLoginFinished(); - } - - return; - }) - .catch((e) => { - if (e instanceof UnauthorizedError) { - renderLoginEmail(); - } else { - renderError(e); - } - }); - }, [ - config, - eventuallyRenderEnrollment, - renderError, - renderLoginEmail, - renderLoginFinished, - userInitialize, - ]); - - return ; -}; - -export default Initialize; diff --git a/frontend/elements/src/ui/pages/LoginEmail.tsx b/frontend/elements/src/ui/pages/LoginEmail.tsx deleted file mode 100644 index 56cca9551..000000000 --- a/frontend/elements/src/ui/pages/LoginEmail.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import * as preact from "preact"; -import { - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "preact/compat"; -import { Fragment } from "preact"; - -import { - HankoError, - TechnicalError, - NotFoundError, - WebauthnRequestCancelledError, - InvalidWebauthnCredentialError, - WebauthnSupport, -} from "@teamhanko/hanko-frontend-sdk"; - -import { TranslateContext } from "@denysvuika/preact-translate"; -import { AppContext } from "../contexts/AppProvider"; -import { RenderContext } from "../contexts/PageProvider"; -import { UserContext } from "../contexts/UserProvider"; - -import Button from "../components/Button"; -import InputText from "../components/InputText"; -import Headline from "../components/Headline"; -import Content from "../components/Content"; -import Form from "../components/Form"; -import Divider from "../components/Divider"; -import ErrorMessage from "../components/ErrorMessage"; - -const LoginEmail = () => { - const { t } = useContext(TranslateContext); - const { email, setEmail } = useContext(UserContext); - const { hanko, config, experimentalFeatures } = useContext(AppContext); - const { - renderPassword, - renderPasscode, - emitSuccessEvent, - renderRegisterConfirm, - } = useContext(RenderContext); - - const [isPasskeyLoginLoading, setIsPasskeyLoginLoading] = - useState(false); - const [isPasskeyLoginSuccess, setIsPasskeyLoginSuccess] = - useState(false); - const [isEmailLoginLoading, setIsEmailLoginLoading] = - useState(false); - const [isEmailLoginSuccess, setIsEmailLoginSuccess] = - useState(false); - const [error, setError] = useState(null); - const [isWebAuthnSupported, setIsWebAuthnSupported] = useState(null); - const [isConditionalMediationSupported, setIsConditionalMediationSupported] = - useState(null); - - const onEmailInput = (event: Event) => { - if (event.target instanceof HTMLInputElement) { - setEmail(event.target.value); - } - }; - - const loginWithEmailAndWebAuthn = () => { - let userID: string; - let webauthnLoginInitiated: boolean; - - return hanko.user - .getInfo(email) - .then((userInfo) => { - if (!userInfo.verified) { - return renderPasscode(userInfo.id, config.password.enabled, true); - } - - if (!userInfo.has_webauthn_credential || conditionalMediationEnabled) { - return renderAlternateLoginMethod(userInfo.id); - } - - userID = userInfo.id; - webauthnLoginInitiated = true; - return hanko.webauthn.login(userInfo.id); - }) - .then(() => { - if (webauthnLoginInitiated) { - setIsEmailLoginLoading(false); - setIsEmailLoginSuccess(true); - emitSuccessEvent(); - } - - return; - }) - .catch((e) => { - if (e instanceof NotFoundError) { - return renderRegisterConfirm(); - } - - if (e instanceof WebauthnRequestCancelledError) { - return renderAlternateLoginMethod(userID); - } - - throw e; - }); - }; - - const loginWithEmail = () => { - return hanko.user - .getInfo(email) - .then((info) => { - if (!info.verified) { - return renderPasscode(info.id, config.password.enabled, true); - } - - return renderAlternateLoginMethod(info.id); - }) - .catch((e) => { - if (e instanceof NotFoundError) { - return renderRegisterConfirm(); - } - - throw e; - }); - }; - - const onEmailSubmit = (event: Event) => { - event.preventDefault(); - setIsEmailLoginLoading(true); - - if (isWebAuthnSupported) { - loginWithEmailAndWebAuthn().catch((e) => { - setIsEmailLoginLoading(false); - setError(e); - }); - } else { - loginWithEmail().catch((e) => { - setIsEmailLoginLoading(false); - setError(e); - }); - } - }; - - const onPasskeySubmit = (event: Event) => { - event.preventDefault(); - setIsPasskeyLoginLoading(true); - - hanko.webauthn - .login() - .then(() => { - setError(null); - setIsPasskeyLoginLoading(false); - setIsPasskeyLoginSuccess(true); - emitSuccessEvent(); - - return; - }) - .catch((e) => { - setIsPasskeyLoginLoading(false); - setError(e instanceof WebauthnRequestCancelledError ? null : e); - }); - }; - - const conditionalMediationEnabled = useMemo( - () => - experimentalFeatures.includes("conditionalMediation") && - isConditionalMediationSupported, - [experimentalFeatures, isConditionalMediationSupported] - ); - - const renderAlternateLoginMethod = useCallback( - (userID: string) => { - if (config.password.enabled) { - return renderPassword(userID).catch((e) => { - throw e; - }); - } - - return renderPasscode(userID, false, false).catch((e) => { - throw e; - }); - }, - [config.password.enabled, renderPasscode, renderPassword] - ); - - const loginViaConditionalUI = useCallback(() => { - if (!conditionalMediationEnabled) { - // Browser doesn't support AutoFill-assisted requests or the experimental conditional mediation feature is not enabled. - return; - } - - hanko.webauthn - .login(null, true) - .then(() => { - setError(null); - emitSuccessEvent(); - setIsEmailLoginSuccess(true); - - return; - }) - .catch((e) => { - if (e instanceof InvalidWebauthnCredentialError) { - // An invalid WebAuthn credential has been used. Retry the login procedure, so another credential can be - // chosen by the user via conditional UI. - loginViaConditionalUI(); - } - setError(e instanceof WebauthnRequestCancelledError ? null : e); - }); - }, [conditionalMediationEnabled, emitSuccessEvent, hanko.webauthn]); - - useEffect(() => { - loginViaConditionalUI(); - }, [loginViaConditionalUI]); - - useEffect(() => { - setIsWebAuthnSupported(WebauthnSupport.supported()); - }, []); - - useEffect(() => { - WebauthnSupport.isConditionalMediationAvailable() - .then((supported) => setIsConditionalMediationSupported(supported)) - .catch((e) => setError(new TechnicalError(e))); - }, []); - - return ( - - {t("headlines.loginEmail")} - -
    - - - - {isWebAuthnSupported && !conditionalMediationEnabled ? ( - - -
    - -
    -
    - ) : null} -
    - ); -}; - -export default LoginEmail; diff --git a/frontend/elements/src/ui/pages/LoginPasscode.tsx b/frontend/elements/src/ui/pages/LoginPasscode.tsx deleted file mode 100644 index 181f224dd..000000000 --- a/frontend/elements/src/ui/pages/LoginPasscode.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import * as preact from "preact"; -import { Fragment } from "preact"; -import { useContext, useEffect, useState } from "preact/compat"; - -import { - HankoError, - PasscodeExpiredError, - TechnicalError, -} from "@teamhanko/hanko-frontend-sdk"; - -import { UserContext } from "../contexts/UserProvider"; -import { PasscodeContext } from "../contexts/PasscodeProvider"; -import { TranslateContext } from "@denysvuika/preact-translate"; -import { RenderContext } from "../contexts/PageProvider"; - -import Button from "../components/Button"; -import Content from "../components/Content"; -import Headline from "../components/Headline"; -import Form from "../components/Form"; -import Footer from "../components/Footer"; -import InputPasscode from "../components/InputPasscode"; -import ErrorMessage from "../components/ErrorMessage"; -import Paragraph from "../components/Paragraph"; - -import LoadingIndicatorLink from "../components/link/withLoadingIndicator"; -import LinkToEmailLogin from "../components/link/toEmailLogin"; -import LinkToPasswordLogin from "../components/link/toPasswordLogin"; - -type Props = { - userID: string; - recoverPassword: boolean; - numberOfDigits?: number; - initialError?: HankoError; - hideBackLink?: boolean; -}; - -const LoginPasscode = ({ - userID, - recoverPassword, - numberOfDigits = 6, - initialError, - hideBackLink, -}: Props) => { - const { t } = useContext(TranslateContext); - const { eventuallyRenderEnrollment, emitSuccessEvent } = - useContext(RenderContext); - const { email, userInitialize } = useContext(UserContext); - const { - passcodeTTL, - passcodeIsActive, - passcodeResendAfter, - passcodeResend, - passcodeFinalize, - } = useContext(PasscodeContext); - - const [isPasscodeLoading, setIsPasscodeLoading] = useState(false); - const [isPasscodeSuccess, setIsPasscodeSuccess] = useState(false); - const [isResendLoading, setIsResendLoading] = useState(false); - const [isResendSuccess, setIsResendSuccess] = useState(false); - const [passcodeDigits, setPasscodeDigits] = useState([]); - const [error, setError] = useState(initialError); - - const onPasscodeInput = (digits: string[]) => { - // Automatically submit the Passcode when every input contains a digit. - if (digits.filter((digit) => digit !== "").length === numberOfDigits) { - passcodeSubmit(digits); - } - - setPasscodeDigits(digits); - }; - - const passcodeSubmit = (code: string[]) => { - setIsPasscodeLoading(true); - - passcodeFinalize(userID, code.join("")) - .then(() => userInitialize()) - .then((u) => eventuallyRenderEnrollment(u, recoverPassword)) - .then((rendered) => { - if (!rendered) { - setIsPasscodeSuccess(true); - setIsPasscodeLoading(false); - emitSuccessEvent(); - } - - return; - }) - .catch((e) => { - // Clear Passcode digits when there is no technical error. - if (!(e instanceof TechnicalError)) { - setPasscodeDigits([]); - } - - setIsPasscodeSuccess(false); - setIsPasscodeLoading(false); - setError(e); - }); - }; - - const onPasscodeSubmitClick = (event: Event) => { - event.preventDefault(); - passcodeSubmit(passcodeDigits); - }; - - const onResendClick = (event: Event) => { - event.preventDefault(); - setIsResendSuccess(false); - setIsResendLoading(true); - - passcodeResend(userID) - .then(() => { - setIsResendSuccess(true); - setPasscodeDigits([]); - setIsResendLoading(false); - setError(null); - - return; - }) - .catch((e) => { - setIsResendLoading(false); - setIsResendSuccess(false); - setError(e); - }); - }; - - useEffect(() => { - if (passcodeTTL <= 0 && !isPasscodeSuccess) { - setError(new PasscodeExpiredError()); - } - }, [isPasscodeSuccess, passcodeTTL]); - - return ( - - - {t("headlines.loginPasscode")} - -
    - - {t("texts.enterPasscode", { email })} - - -
    -
    - {recoverPassword ? ( -
    -
    - ); -}; - -export default LoginPasscode; diff --git a/frontend/elements/src/ui/pages/LoginPassword.tsx b/frontend/elements/src/ui/pages/LoginPassword.tsx deleted file mode 100644 index 0677ccdbc..000000000 --- a/frontend/elements/src/ui/pages/LoginPassword.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import * as preact from "preact"; -import { Fragment } from "preact"; -import { useContext, useEffect, useState } from "preact/compat"; - -import { - HankoError, - TooManyRequestsError, -} from "@teamhanko/hanko-frontend-sdk"; - -import { TranslateContext } from "@denysvuika/preact-translate"; -import { PasswordContext } from "../contexts/PasswordProvider"; -import { UserContext } from "../contexts/UserProvider"; -import { RenderContext } from "../contexts/PageProvider"; - -import Content from "../components/Content"; -import Footer from "../components/Footer"; -import Headline from "../components/Headline"; -import Form from "../components/Form"; -import InputText from "../components/InputText"; -import Button from "../components/Button"; -import ErrorMessage from "../components/ErrorMessage"; - -import LoadingIndicatorLink from "../components/link/withLoadingIndicator"; -import LinkToEmailLogin from "../components/link/toEmailLogin"; - -type Props = { - userID: string; - initialError: HankoError; -}; - -const LoginPassword = ({ userID, initialError }: Props) => { - const { t } = useContext(TranslateContext); - const { - eventuallyRenderEnrollment, - renderPasscode, - emitSuccessEvent, - renderError, - } = useContext(RenderContext); - const { userInitialize } = useContext(UserContext); - const { passwordFinalize, passwordRetryAfter } = useContext(PasswordContext); - - const [password, setPassword] = useState(""); - const [isPasswordLoading, setIsPasswordLoading] = useState(false); - const [isPasscodeLoading, setIsPasscodeLoading] = useState(false); - const [isSuccess, setIsSuccess] = useState(false); - const [error, setError] = useState(initialError); - - const onPasswordInput = async (event: Event) => { - if (event.target instanceof HTMLInputElement) { - setPassword(event.target.value); - } - }; - - const onPasswordSubmit = (event: Event) => { - event.preventDefault(); - setIsPasswordLoading(true); - - passwordFinalize(userID, password) - .then(() => userInitialize()) - .then((u) => eventuallyRenderEnrollment(u, false)) - .then((rendered) => { - if (!rendered) { - setIsSuccess(true); - setIsPasswordLoading(false); - emitSuccessEvent(); - } - - return; - }) - .catch((e) => { - setIsPasswordLoading(false); - setError(e); - }); - }; - - const onForgotPasswordClick = () => { - setIsPasscodeLoading(true); - renderPasscode(userID, true, false).catch((e) => renderError(e)); - }; - - // Automatically clear the too many requests error message - useEffect(() => { - if (error instanceof TooManyRequestsError && passwordRetryAfter <= 0) { - setError(null); - } - }, [error, passwordRetryAfter]); - - return ( - - - {t("headlines.loginPassword")} - -
    - - - -
    -
    - - - {t("labels.forgotYourPassword")} - -
    -
    - ); -}; - -export default LoginPassword; diff --git a/frontend/elements/src/ui/pages/RegisterConfirm.tsx b/frontend/elements/src/ui/pages/RegisterConfirm.tsx deleted file mode 100644 index 4782d8d8e..000000000 --- a/frontend/elements/src/ui/pages/RegisterConfirm.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import * as preact from "preact"; -import { Fragment } from "preact"; -import { useContext, useEffect, useState } from "preact/compat"; - -import { User, ConflictError, HankoError } from "@teamhanko/hanko-frontend-sdk"; - -import { AppContext } from "../contexts/AppProvider"; -import { TranslateContext } from "@denysvuika/preact-translate"; -import { UserContext } from "../contexts/UserProvider"; -import { RenderContext } from "../contexts/PageProvider"; - -import Content from "../components/Content"; -import Headline from "../components/Headline"; -import Form from "../components/Form"; -import Button from "../components/Button"; -import Footer from "../components/Footer"; -import ErrorMessage from "../components/ErrorMessage"; -import Paragraph from "../components/Paragraph"; - -import LinkToEmailLogin from "../components/link/toEmailLogin"; - -const RegisterConfirm = () => { - const { t } = useContext(TranslateContext); - const { hanko, config } = useContext(AppContext); - const { email } = useContext(UserContext); - const { renderPasscode } = useContext(RenderContext); - - const [user, setUser] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const onConfirmSubmit = (event: Event) => { - event.preventDefault(); - setIsLoading(true); - - hanko.user - .create(email) - .then((u) => setUser(u)) - .catch((e) => { - if (e instanceof ConflictError) { - return hanko.user.getInfo(email); - } - - throw e; - }) - .then((userInfo) => { - if (userInfo) { - return renderPasscode(userInfo.id, config.password.enabled, true); - } - return; - }) - .catch((e) => { - setIsLoading(false); - setError(e); - }); - }; - - // User has been created - useEffect(() => { - if (user === null || config === null) { - return; - } - - renderPasscode(user.id, config.password.enabled, true).catch((e) => { - setIsLoading(false); - setError(e); - }); - }, [config, renderPasscode, user]); - - return ( - - - {t("headlines.registerConfirm")} - -
    - {t("texts.createAccount", { email })} - -
    -
    -
    -
    -
    - ); -}; - -export default RegisterConfirm; diff --git a/frontend/elements/src/ui/pages/RegisterPassword.tsx b/frontend/elements/src/ui/pages/RegisterPassword.tsx deleted file mode 100644 index 705af3a1f..000000000 --- a/frontend/elements/src/ui/pages/RegisterPassword.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import * as preact from "preact"; -import { useContext, useState } from "preact/compat"; - -import { - User, - HankoError, - UnauthorizedError, -} from "@teamhanko/hanko-frontend-sdk"; - -import { TranslateContext } from "@denysvuika/preact-translate"; -import { AppContext } from "../contexts/AppProvider"; -import { RenderContext } from "../contexts/PageProvider"; - -import Content from "../components/Content"; -import Headline from "../components/Headline"; -import Form from "../components/Form"; -import InputText from "../components/InputText"; -import Button from "../components/Button"; -import ErrorMessage from "../components/ErrorMessage"; -import Paragraph from "../components/Paragraph"; - -type Props = { - user: User; - registerAuthenticator: boolean; -}; - -const RegisterPassword = ({ user, registerAuthenticator }: Props) => { - const { t } = useContext(TranslateContext); - const { hanko } = useContext(AppContext); - const { renderError, emitSuccessEvent, renderRegisterAuthenticator } = - useContext(RenderContext); - - const [isLoading, setIsLoading] = useState(false); - const [isSuccess, setIsSuccess] = useState(false); - const [error, setError] = useState(null); - const [password, setPassword] = useState(""); - - const onPasswordInput = async (event: Event) => { - if (event.target instanceof HTMLInputElement) { - setPassword(event.target.value); - } - }; - - const onPasswordSubmit = (event: Event) => { - event.preventDefault(); - setIsLoading(true); - - hanko.password - .update(user.id, password) - .then(() => { - if (registerAuthenticator) { - renderRegisterAuthenticator(); - } else { - emitSuccessEvent(); - setIsSuccess(true); - } - - setIsLoading(false); - - return; - }) - .catch((e) => { - if (e instanceof UnauthorizedError) { - renderError(e); - - return; - } - - setIsLoading(false); - setError(e); - }); - }; - - return ( - - {t("headlines.registerPassword")} - -
    - - {t("texts.passwordFormatHint")} - - -
    - ); -}; - -export default RegisterPassword; diff --git a/frontend/elements/webpack.config.cjs b/frontend/elements/webpack.config.cjs index cec456fd7..3e47ff990 100644 --- a/frontend/elements/webpack.config.cjs +++ b/frontend/elements/webpack.config.cjs @@ -3,10 +3,10 @@ const path = require("path"); module.exports = { entry: { hankoAuth: { - filename: 'element.hanko-auth.js', + filename: 'elements.js', import: './src/index.ts', library: { - name: 'HankoAuth', + name: 'Elements', type: 'umd', umdNamedDefine: true, }, diff --git a/frontend/elements/webpack.config.dev.cjs b/frontend/elements/webpack.config.dev.cjs index 74142882b..4750d6a7f 100644 --- a/frontend/elements/webpack.config.dev.cjs +++ b/frontend/elements/webpack.config.dev.cjs @@ -3,11 +3,11 @@ const path = require("path"); module.exports = { devtool: 'eval-source-map', entry: { - hankoAuth: { - filename: 'element.hanko-auth.js', + elements: { + filename: 'elements.js', import: './src/index.ts', library: { - name: 'HankoAuth', + name: 'Elements', type: 'umd', umdNamedDefine: true, }, diff --git a/frontend/frontend-sdk/README.md b/frontend/frontend-sdk/README.md index ef6406e49..393011d8e 100644 --- a/frontend/frontend-sdk/README.md +++ b/frontend/frontend-sdk/README.md @@ -67,6 +67,8 @@ To see the latest documentation, please click [here](https://docs.hanko.io/jsdoc - `Credential` - `UserInfo` - `User` +- `Email` +- `Emails` - `Passcode` ### Errors diff --git a/frontend/frontend-sdk/package-lock.json b/frontend/frontend-sdk/package-lock.json index 74f7c5179..836150411 100644 --- a/frontend/frontend-sdk/package-lock.json +++ b/frontend/frontend-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@teamhanko/hanko-frontend-sdk", - "version": "0.0.9-alpha", + "version": "0.1.0-alpha", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@teamhanko/hanko-frontend-sdk", - "version": "0.0.9-alpha", + "version": "0.1.0-alpha", "license": "MIT", "dependencies": { "@types/js-cookie": "^3.0.2" diff --git a/frontend/frontend-sdk/package.json b/frontend/frontend-sdk/package.json index fa5f76c19..83fa82a48 100644 --- a/frontend/frontend-sdk/package.json +++ b/frontend/frontend-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@teamhanko/hanko-frontend-sdk", - "version": "0.0.9-alpha", + "version": "0.1.0-alpha", "private": false, "publishConfig": { "access": "public" diff --git a/frontend/frontend-sdk/src/Hanko.ts b/frontend/frontend-sdk/src/Hanko.ts index 93bb7302d..a8a4c5484 100644 --- a/frontend/frontend-sdk/src/Hanko.ts +++ b/frontend/frontend-sdk/src/Hanko.ts @@ -3,6 +3,7 @@ import { PasscodeClient } from "./lib/client/PasscodeClient"; 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. @@ -16,6 +17,7 @@ class Hanko { webauthn: WebauthnClient; password: PasswordClient; passcode: PasscodeClient; + email: EmailClient; // eslint-disable-next-line require-jsdoc constructor(api: string, timeout = 13000) { @@ -44,6 +46,11 @@ class Hanko { * @type {PasscodeClient} */ this.passcode = new PasscodeClient(api, timeout); + /** + * @public + * @type {EmailClient} + */ + this.email = new EmailClient(api, timeout); } } diff --git a/frontend/frontend-sdk/src/index.ts b/frontend/frontend-sdk/src/index.ts index 2b5e5c442..809aebbaf 100644 --- a/frontend/frontend-sdk/src/index.ts +++ b/frontend/frontend-sdk/src/index.ts @@ -11,6 +11,7 @@ import { PasscodeClient } from "./lib/client/PasscodeClient"; import { PasswordClient } from "./lib/client/PasswordClient"; import { UserClient } from "./lib/client/UserClient"; import { WebauthnClient } from "./lib/client/WebauthnClient"; +import { EmailClient } from "./lib/client/EmailClient"; export { ConfigClient, @@ -18,6 +19,7 @@ export { WebauthnClient, PasswordClient, PasscodeClient, + EmailClient, }; // Utils @@ -35,6 +37,10 @@ import { Credential, UserInfo, User, + Email, + Emails, + WebauthnCredential, + WebauthnCredentials, Passcode, } from "./lib/Dto"; @@ -45,6 +51,10 @@ export type { Credential, UserInfo, User, + Email, + Emails, + WebauthnCredential, + WebauthnCredentials, Passcode, }; @@ -52,34 +62,38 @@ export type { import { HankoError, - TechnicalError, ConflictError, - RequestTimeoutError, - WebauthnRequestCancelledError, + EmailAddressAlreadyExistsError, InvalidPasswordError, InvalidPasscodeError, InvalidWebauthnCredentialError, - PasscodeExpiredError, + MaxNumOfEmailAddressesReachedError, MaxNumOfPasscodeAttemptsReachedError, NotFoundError, + PasscodeExpiredError, + RequestTimeoutError, + TechnicalError, TooManyRequestsError, UnauthorizedError, - UserVerificationError + UserVerificationError, + WebauthnRequestCancelledError, } from "./lib/Errors"; export { HankoError, - TechnicalError, ConflictError, - RequestTimeoutError, - WebauthnRequestCancelledError, + EmailAddressAlreadyExistsError, InvalidPasswordError, InvalidPasscodeError, InvalidWebauthnCredentialError, - PasscodeExpiredError, + MaxNumOfEmailAddressesReachedError, MaxNumOfPasscodeAttemptsReachedError, NotFoundError, + PasscodeExpiredError, + RequestTimeoutError, + TechnicalError, TooManyRequestsError, UnauthorizedError, UserVerificationError, + WebauthnRequestCancelledError, }; diff --git a/frontend/frontend-sdk/src/lib/Dto.ts b/frontend/frontend-sdk/src/lib/Dto.ts index 47175ca5f..c4f011464 100644 --- a/frontend/frontend-sdk/src/lib/Dto.ts +++ b/frontend/frontend-sdk/src/lib/Dto.ts @@ -5,9 +5,23 @@ import { PublicKeyCredentialWithAttestationJSON } from "@github/webauthn-json"; * @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; } /** @@ -18,6 +32,7 @@ interface PasswordConfig { */ interface Config { password: PasswordConfig; + emails: EmailConfig; } /** @@ -38,11 +53,13 @@ interface WebauthnFinalized { * @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; } @@ -77,7 +94,7 @@ interface Credential { */ interface User { id: string; - email: string; + email_id: string; webauthn_credentials: Credential[]; } @@ -97,13 +114,75 @@ interface Passcode { * @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 {} + +/** + * @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 {} + +/** + * @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 {} + export type { PasswordConfig, Config, @@ -112,6 +191,10 @@ export type { UserInfo, Me, User, + Email, + Emails, Passcode, Attestation, + WebauthnCredential, + WebauthnCredentials, }; diff --git a/frontend/frontend-sdk/src/lib/Errors.ts b/frontend/frontend-sdk/src/lib/Errors.ts index 8c4febc31..4179bd80e 100644 --- a/frontend/frontend-sdk/src/lib/Errors.ts +++ b/frontend/frontend-sdk/src/lib/Errors.ts @@ -237,6 +237,45 @@ class UserVerificationError extends HankoError { } } +/** + * 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, @@ -251,5 +290,7 @@ export { NotFoundError, TooManyRequestsError, UnauthorizedError, - UserVerificationError + UserVerificationError, + MaxNumOfEmailAddressesReachedError, + EmailAddressAlreadyExistsError, }; diff --git a/frontend/frontend-sdk/src/lib/client/EmailClient.ts b/frontend/frontend-sdk/src/lib/client/EmailClient.ts new file mode 100644 index 000000000..faa4aa15c --- /dev/null +++ b/frontend/frontend-sdk/src/lib/client/EmailClient.ts @@ -0,0 +1,115 @@ +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} + * @throws {UnauthorizedError} + * @throws {RequestTimeoutError} + * @throws {TechnicalError} + * @see https://docs.hanko.io/api/public#tag/Email-Management/operation/listEmails + */ + async list(): Promise { + 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} + * @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 { + 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} + * @throws {UnauthorizedError} + * @throws {RequestTimeoutError} + * @throws {TechnicalError} + * @see https://docs.hanko.io/api/public#tag/Email-Management/operation/setPrimaryEmail + */ + async setPrimaryEmail(emailID: string): Promise { + 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} + * @throws {UnauthorizedError} + * @throws {RequestTimeoutError} + * @throws {TechnicalError} + * @see https://docs.hanko.io/api/public#tag/Email-Management/operation/deleteEmail + */ + async delete(emailID: string): Promise { + 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/frontend/frontend-sdk/src/lib/client/HttpClient.ts b/frontend/frontend-sdk/src/lib/client/HttpClient.ts index 5fb25723f..e45287177 100644 --- a/frontend/frontend-sdk/src/lib/client/HttpClient.ts +++ b/frontend/frontend-sdk/src/lib/client/HttpClient.ts @@ -43,6 +43,7 @@ class Response { statusText: string; url: string; _decodedJSON: any; + private xhr: XMLHttpRequest; // eslint-disable-next-line require-jsdoc constructor(xhr: XMLHttpRequest) { @@ -71,7 +72,11 @@ class Response { * @type {string} */ this.url = xhr.responseURL; - this._decodedJSON = JSON.parse(xhr.response); + /** + * @private + * @type {XMLHttpRequest} + */ + this.xhr = xhr; } /** @@ -80,8 +85,20 @@ class Response { * @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); + } } /** @@ -200,6 +217,37 @@ class HttpClient { 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} + * @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} + * @throws {RequestTimeoutError} + * @throws {TechnicalError} + */ + delete(path: string) { + return this._fetch(path, { + method: "DELETE", + }); + } } export { Headers, Response, HttpClient }; diff --git a/frontend/frontend-sdk/src/lib/client/PasscodeClient.ts b/frontend/frontend-sdk/src/lib/client/PasscodeClient.ts index 83c9f03d0..252bf88a6 100644 --- a/frontend/frontend-sdk/src/lib/client/PasscodeClient.ts +++ b/frontend/frontend-sdk/src/lib/client/PasscodeClient.ts @@ -3,8 +3,10 @@ import { Passcode } from "../Dto"; import { InvalidPasscodeError, MaxNumOfPasscodeAttemptsReachedError, + PasscodeExpiredError, TechnicalError, TooManyRequestsError, + UnauthorizedError, } from "../Errors"; import { Client } from "./Client"; @@ -33,36 +35,65 @@ class PasscodeClient extends Client { * 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} * @throws {TooManyRequestsError} * @throws {RequestTimeoutError} + * @throws {UnauthorizedError} * @throws {TechnicalError} * @see https://docs.hanko.io/api/public#tag/Passcode/operation/passcodeInit */ - async initialize(userID: string): Promise { - const response = await this.client.post("/passcode/login/initialize", { - user_id: userID, - }); + async initialize( + userID: string, + emailID?: string, + force?: boolean + ): Promise { + this.state.read(); - if (response.status === 429) { - const retryAfter = parseInt( - response.headers.get("X-Retry-After") || "0", - 10 - ); + 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 (retryAfter > 0) { + throw new TooManyRequestsError(retryAfter); + } + + const body: any = { user_id: userID }; + + if (emailID) { + body.email_id = emailID; + } + + const response = await this.client.post(`/passcode/login/initialize`, body); - this.state.read().setResendAfter(userID, retryAfter).write(); + 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 - .read() - .setActiveID(userID, passcode.id) - .setTTL(userID, passcode.ttl) - .write(); + this.state.setActiveID(userID, passcode.id).setTTL(userID, passcode.ttl); + + if (emailID) { + this.state.setEmailID(userID, emailID); + } + + this.state.write(); return passcode; } @@ -81,6 +112,12 @@ class PasscodeClient extends Client { */ async finalize(userID: string, code: string): Promise { 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/frontend/frontend-sdk/src/lib/client/PasswordClient.ts b/frontend/frontend-sdk/src/lib/client/PasswordClient.ts index 1a2a98e02..50336299b 100644 --- a/frontend/frontend-sdk/src/lib/client/PasswordClient.ts +++ b/frontend/frontend-sdk/src/lib/client/PasswordClient.ts @@ -1,8 +1,10 @@ import { PasswordState } from "../state/PasswordState"; +import { PasscodeState } from "../state/PasscodeState"; import { InvalidPasswordError, TechnicalError, TooManyRequestsError, + UnauthorizedError, } from "../Errors"; import { Client } from "./Client"; @@ -15,7 +17,8 @@ import { Client } from "./Client"; * @extends {Client} */ class PasswordClient extends Client { - state: PasswordState; + passwordState: PasswordState; + passcodeState: PasscodeState; // eslint-disable-next-line require-jsdoc constructor(api: string, timeout = 13000) { @@ -24,7 +27,12 @@ class PasswordClient extends Client { * @public * @type {PasswordState} */ - this.state = new PasswordState(); + this.passwordState = new PasswordState(); + /** + * @public + * @type {PasscodeState} + */ + this.passcodeState = new PasscodeState(); } /** @@ -47,18 +55,15 @@ class PasswordClient extends Client { 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; } @@ -79,7 +84,9 @@ class PasswordClient extends Client { password, }); - if (!response.ok) { + if (response.status === 401) { + throw new UnauthorizedError(); + } else if (!response.ok) { throw new TechnicalError(); } @@ -93,7 +100,7 @@ class PasswordClient extends Client { * @return {number} */ getRetryAfter(userID: string) { - return this.state.read().getRetryAfter(userID); + return this.passwordState.read().getRetryAfter(userID); } } diff --git a/frontend/frontend-sdk/src/lib/client/WebauthnClient.ts b/frontend/frontend-sdk/src/lib/client/WebauthnClient.ts index 394ff9062..9d1d99831 100644 --- a/frontend/frontend-sdk/src/lib/client/WebauthnClient.ts +++ b/frontend/frontend-sdk/src/lib/client/WebauthnClient.ts @@ -1,4 +1,14 @@ +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, @@ -6,13 +16,13 @@ import { 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. @@ -23,7 +33,8 @@ import { Client } from "./Client"; * @extends {Client} */ class WebauthnClient extends Client { - state: WebauthnState; + webauthnState: WebauthnState; + passcodeState: PasscodeState; controller: AbortController; _getCredential = getWebauthnCredential; @@ -36,7 +47,12 @@ class WebauthnClient extends Client { * @public * @type {WebauthnState} */ - this.state = new WebauthnState(); + this.webauthnState = new WebauthnState(); + /** + * @public + * @type {PasscodeState} + */ + this.passcodeState = new PasscodeState(); } /** @@ -95,11 +111,13 @@ class WebauthnClient extends Client { 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; } @@ -160,7 +178,7 @@ class WebauthnClient extends Client { } const finalizeResponse: WebauthnFinalized = attestationResponse.json(); - this.state + this.webauthnState .read() .addCredential(finalizeResponse.user_id, finalizeResponse.credential_id) .write(); @@ -168,6 +186,81 @@ class WebauthnClient extends Client { return; } + /** + * Returns a list of all WebAuthn credentials assigned to the current user. + * + * @return {Promise} + * @throws {UnauthorizedError} + * @throws {RequestTimeoutError} + * @throws {TechnicalError} + * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/listCredentials + */ + async listCredentials(): Promise { + 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} + * @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 { + 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} + * @throws {NotFoundError} + * @throws {UnauthorizedError} + * @throws {RequestTimeoutError} + * @throws {TechnicalError} + * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/deleteCredential + */ + async deleteCredential(credentialID: string): Promise { + 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 @@ -183,7 +276,7 @@ class WebauthnClient extends Client { return supported; } - const matches = this.state + const matches = this.webauthnState .read() .matchCredentials(user.id, user.webauthn_credentials); diff --git a/frontend/frontend-sdk/src/lib/state/PasscodeState.ts b/frontend/frontend-sdk/src/lib/state/PasscodeState.ts index 0ed2f9367..01d65a039 100644 --- a/frontend/frontend-sdk/src/lib/state/PasscodeState.ts +++ b/frontend/frontend-sdk/src/lib/state/PasscodeState.ts @@ -8,11 +8,13 @@ import { UserState } from "./UserState"; * @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; } /** @@ -69,6 +71,29 @@ class PasscodeState extends UserState { 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. * @@ -81,6 +106,7 @@ class PasscodeState extends UserState { delete passcode.id; delete passcode.ttl; delete passcode.resendAfter; + delete passcode.emailID; return this; } diff --git a/frontend/frontend-sdk/tests/lib/client/ConfigClient.spec.ts b/frontend/frontend-sdk/tests/lib/client/ConfigClient.spec.ts index 37e3a2076..d7bfc5402 100644 --- a/frontend/frontend-sdk/tests/lib/client/ConfigClient.spec.ts +++ b/frontend/frontend-sdk/tests/lib/client/ConfigClient.spec.ts @@ -16,7 +16,7 @@ describe("configClient.get()", () => { jest.spyOn(configClient.client, "get").mockResolvedValue(response); const config = await configClient.get(); expect(configClient.client.get).toHaveBeenCalledWith("/.well-known/config"); - expect(config).toEqual({ password: { enabled: true } }); + expect(config).toEqual(response._decodedJSON); }); it("should throw technical error when API response is not ok", async () => { diff --git a/frontend/frontend-sdk/tests/lib/client/EmailClient.spec.ts b/frontend/frontend-sdk/tests/lib/client/EmailClient.spec.ts new file mode 100644 index 000000000..316b86d47 --- /dev/null +++ b/frontend/frontend-sdk/tests/lib/client/EmailClient.spec.ts @@ -0,0 +1,191 @@ +import { EmailClient } from "../../../src"; +import { Response } from "../../../src/lib/client/HttpClient"; + +const emailID = "test-email-1"; +const emailAddress = "test-email-1@test"; + +let emailClient: EmailClient; + +beforeEach(() => { + emailClient = new EmailClient("http://test.api"); +}); + +describe("EmailClient.list()", () => { + it("should list email addresses", async () => { + const response = new Response(new XMLHttpRequest()); + response.ok = true; + response._decodedJSON = [ + { + id: emailID, + address: emailAddress, + is_verified: false, + is_primary: true, + }, + ]; + + jest.spyOn(emailClient.client, "get").mockResolvedValue(response); + const list = await emailClient.list(); + expect(emailClient.client.get).toHaveBeenCalledWith("/emails"); + expect(list).toEqual(response._decodedJSON); + }); + + it.each` + status | error + ${401} | ${"Unauthorized error"} + ${500} | ${"Technical error"} + `( + "should throw error if API returns an error status", + async ({ status, error }) => { + const response = new Response(new XMLHttpRequest()); + response.status = status; + response.ok = status >= 200 && status <= 299; + + jest.spyOn(emailClient.client, "get").mockResolvedValueOnce(response); + + const email = emailClient.list(); + await expect(email).rejects.toThrow(error); + } + ); + + it("should throw error on API communication failure", async () => { + emailClient.client.get = jest + .fn() + .mockRejectedValue(new Error("Test error")); + + const user = emailClient.list(); + await expect(user).rejects.toThrowError("Test error"); + }); +}); + +describe("EmailClient.create()", () => { + it("should create a email address", async () => { + const response = new Response(new XMLHttpRequest()); + response.ok = true; + response._decodedJSON = { + id: "", + address: "", + is_verified: false, + is_primary: true, + }; + + jest.spyOn(emailClient.client, "post").mockResolvedValue(response); + + const createResponse = emailClient.create(emailAddress); + await expect(createResponse).resolves.toBe(response._decodedJSON); + + expect(emailClient.client.post).toHaveBeenCalledWith(`/emails`, { + address: emailAddress, + }); + }); + + it.each` + status | error + ${400} | ${"The email address already exists"} + ${401} | ${"Unauthorized error"} + ${409} | ${"Maximum number of email addresses reached error"} + ${500} | ${"Technical error"} + `( + "should throw error if API returns an error status", + async ({ status, error }) => { + const response = new Response(new XMLHttpRequest()); + response.status = status; + response.ok = status >= 200 && status <= 299; + + jest.spyOn(emailClient.client, "post").mockResolvedValueOnce(response); + + const email = emailClient.create(emailAddress); + await expect(email).rejects.toThrow(error); + } + ); + + it("should throw error on API communication failure", async () => { + emailClient.client.post = jest + .fn() + .mockRejectedValue(new Error("Test error")); + + const user = emailClient.create(emailAddress); + await expect(user).rejects.toThrowError("Test error"); + }); +}); + +describe("EmailClient.setPrimaryEmail()", () => { + it("should set a primary email address", async () => { + const response = new Response(new XMLHttpRequest()); + response.ok = true; + + jest.spyOn(emailClient.client, "post").mockResolvedValue(response); + const update = await emailClient.setPrimaryEmail(emailID); + expect(emailClient.client.post).toHaveBeenCalledWith( + `/emails/${emailID}/set_primary` + ); + expect(update).toEqual(undefined); + }); + + it.each` + status | error + ${401} | ${"Unauthorized error"} + ${500} | ${"Technical error"} + `( + "should throw error if API returns an error status", + async ({ status, error }) => { + const response = new Response(new XMLHttpRequest()); + response.status = status; + response.ok = status >= 200 && status <= 299; + + jest.spyOn(emailClient.client, "post").mockResolvedValueOnce(response); + + const email = emailClient.setPrimaryEmail(emailID); + await expect(email).rejects.toThrow(error); + } + ); + + it("should throw error on API communication failure", async () => { + emailClient.client.post = jest + .fn() + .mockRejectedValue(new Error("Test error")); + + const user = emailClient.setPrimaryEmail(emailID); + await expect(user).rejects.toThrowError("Test error"); + }); +}); + +describe("EmailClient.delete()", () => { + it("should delete email addresses", async () => { + const response = new Response(new XMLHttpRequest()); + response.ok = true; + + jest.spyOn(emailClient.client, "delete").mockResolvedValue(response); + const deleteResponse = await emailClient.delete(emailID); + expect(emailClient.client.delete).toHaveBeenCalledWith( + `/emails/${emailID}` + ); + expect(deleteResponse).toEqual(undefined); + }); + + it.each` + status | error + ${401} | ${"Unauthorized error"} + ${500} | ${"Technical error"} + `( + "should throw error if API returns an error status", + async ({ status, error }) => { + const response = new Response(new XMLHttpRequest()); + response.status = status; + response.ok = status >= 200 && status <= 299; + + jest.spyOn(emailClient.client, "delete").mockResolvedValueOnce(response); + + const deleteResponse = emailClient.delete(emailID); + await expect(deleteResponse).rejects.toThrow(error); + } + ); + + it("should throw error on API communication failure", async () => { + emailClient.client.delete = jest + .fn() + .mockRejectedValue(new Error("Test error")); + + const user = emailClient.delete(emailID); + await expect(user).rejects.toThrowError("Test error"); + }); +}); diff --git a/frontend/frontend-sdk/tests/lib/client/HttpClient.spec.ts b/frontend/frontend-sdk/tests/lib/client/HttpClient.spec.ts index c25c1d173..08e0fdcb6 100644 --- a/frontend/frontend-sdk/tests/lib/client/HttpClient.spec.ts +++ b/frontend/frontend-sdk/tests/lib/client/HttpClient.spec.ts @@ -157,6 +157,28 @@ describe("httpClient.put()", () => { }); }); +describe("httpClient.patch()", () => { + it("should call patch with correct args", async () => { + httpClient._fetch = jest.fn(); + await httpClient.patch("/test"); + + expect(httpClient._fetch).toHaveBeenCalledWith("/test", { + method: "PATCH", + }); + }); +}); + +describe("httpClient.delete()", () => { + it("should call delete with correct args", async () => { + httpClient._fetch = jest.fn(); + await httpClient.delete("/test"); + + expect(httpClient._fetch).toHaveBeenCalledWith("/test", { + method: "DELETE", + }); + }); +}); + describe("headers.get()", () => { it("should return headers", async () => { const header = new Headers(xhr); diff --git a/frontend/frontend-sdk/tests/lib/client/PasscodeClient.spec.ts b/frontend/frontend-sdk/tests/lib/client/PasscodeClient.spec.ts index efeaa6a9d..633815194 100644 --- a/frontend/frontend-sdk/tests/lib/client/PasscodeClient.spec.ts +++ b/frontend/frontend-sdk/tests/lib/client/PasscodeClient.spec.ts @@ -2,6 +2,7 @@ import { InvalidPasscodeError, MaxNumOfPasscodeAttemptsReachedError, PasscodeClient, + PasscodeExpiredError, TechnicalError, TooManyRequestsError, } from "../../../src"; @@ -9,6 +10,7 @@ import { Response } from "../../../src/lib/client/HttpClient"; const userID = "test-user-1"; const passcodeID = "test-passcode-1"; +const emailID = "test-email-1"; const passcodeTTL = 180; const passcodeRetryAfter = 180; const passcodeValue = "123456"; @@ -50,6 +52,54 @@ describe("PasscodeClient.initialize()", () => { ); }); + it("should initialize a passcode with specified email id", async () => { + const response = new Response(new XMLHttpRequest()); + response.ok = true; + + jest.spyOn(passcodeClient.client, "post").mockResolvedValue(response); + jest.spyOn(passcodeClient.state, "setEmailID"); + + await passcodeClient.initialize(userID, emailID, true); + + expect(passcodeClient.state.setEmailID).toHaveBeenCalledWith( + userID, + emailID + ); + expect(passcodeClient.client.post).toHaveBeenCalledWith( + "/passcode/login/initialize", + { user_id: userID, email_id: emailID } + ); + }); + + it("should restore the previous passcode", async () => { + jest.spyOn(passcodeClient.state, "read"); + jest.spyOn(passcodeClient.state, "getTTL").mockReturnValue(passcodeTTL); + jest.spyOn(passcodeClient.state, "getActiveID").mockReturnValue(passcodeID); + jest.spyOn(passcodeClient.state, "getEmailID").mockReturnValue(emailID); + + await expect(passcodeClient.initialize(userID, emailID)).resolves.toEqual({ + id: passcodeID, + ttl: passcodeTTL, + }); + + expect(passcodeClient.state.read).toHaveBeenCalledTimes(1); + expect(passcodeClient.state.getTTL).toHaveBeenCalledWith(userID); + expect(passcodeClient.state.getActiveID).toHaveBeenCalledWith(userID); + expect(passcodeClient.state.getEmailID).toHaveBeenCalledWith(userID); + }); + + it("should throw an error as long as email backoff is active", async () => { + jest + .spyOn(passcodeClient.state, "getResendAfter") + .mockReturnValue(passcodeRetryAfter); + + await expect(passcodeClient.initialize(userID, emailID)).rejects.toThrow( + TooManyRequestsError + ); + + expect(passcodeClient.state.getResendAfter).toHaveBeenCalledWith(userID); + }); + it("should throw error and set retry after in state on too many request response from API", async () => { const xhr = new XMLHttpRequest(); const response = new Response(xhr); @@ -77,21 +127,31 @@ describe("PasscodeClient.initialize()", () => { expect(response.headers.get).toHaveBeenCalledWith("X-Retry-After"); }); - it("should throw error when API response is not ok", async () => { - const response = new Response(new XMLHttpRequest()); - passcodeClient.client.post = jest.fn().mockResolvedValue(response); + it.each` + status | error + ${401} | ${"Unauthorized error"} + ${500} | ${"Technical error"} + `( + "should throw error when API response is not ok", + async ({ status, error }) => { + const response = new Response(new XMLHttpRequest()); + response.status = status; + response.ok = status >= 200 && status <= 299; - const config = passcodeClient.initialize("test-user-1"); - await expect(config).rejects.toThrowError(TechnicalError); - }); + passcodeClient.client.post = jest.fn().mockResolvedValue(response); + + const passcode = passcodeClient.initialize("test-user-1"); + await expect(passcode).rejects.toThrowError(error); + } + ); it("should throw error on API communication failure", async () => { passcodeClient.client.post = jest .fn() .mockRejectedValue(new Error("Test error")); - const config = passcodeClient.initialize("test-user-1"); - await expect(config).rejects.toThrowError("Test error"); + const passcode = passcodeClient.initialize("test-user-1"); + await expect(passcode).rejects.toThrowError("Test error"); }); }); @@ -104,6 +164,7 @@ describe("PasscodeClient.finalize()", () => { jest.spyOn(passcodeClient.state, "reset"); jest.spyOn(passcodeClient.state, "write"); jest.spyOn(passcodeClient.state, "getActiveID").mockReturnValue(passcodeID); + jest.spyOn(passcodeClient.state, "getTTL").mockReturnValue(passcodeTTL); jest.spyOn(passcodeClient.client, "post").mockResolvedValue(response); await expect( @@ -125,6 +186,7 @@ describe("PasscodeClient.finalize()", () => { jest.spyOn(passcodeClient.state, "read"); jest.spyOn(passcodeClient.state, "getActiveID").mockReturnValue(passcodeID); + jest.spyOn(passcodeClient.state, "getTTL").mockReturnValue(passcodeTTL); jest.spyOn(passcodeClient.client, "post").mockResolvedValue(response); await expect( @@ -142,6 +204,7 @@ describe("PasscodeClient.finalize()", () => { jest.spyOn(passcodeClient.state, "reset"); jest.spyOn(passcodeClient.state, "write"); jest.spyOn(passcodeClient.state, "getActiveID").mockReturnValue(passcodeID); + jest.spyOn(passcodeClient.state, "getTTL").mockReturnValue(passcodeTTL); jest.spyOn(passcodeClient.client, "post").mockResolvedValue(response); await expect( @@ -153,9 +216,16 @@ describe("PasscodeClient.finalize()", () => { expect(passcodeClient.state.getActiveID).toHaveBeenCalledWith(userID); }); + it("should throw error when the passcode has expired", async () => { + jest.spyOn(passcodeClient.state, "getTTL").mockReturnValue(0); + const finalizeResponse = passcodeClient.finalize(userID, passcodeValue); + await expect(finalizeResponse).rejects.toThrowError(PasscodeExpiredError); + }); + it("should throw error when API response is not ok", async () => { const response = new Response(new XMLHttpRequest()); passcodeClient.client.post = jest.fn().mockResolvedValue(response); + jest.spyOn(passcodeClient.state, "getTTL").mockReturnValue(passcodeTTL); const finalizeResponse = passcodeClient.finalize(userID, passcodeValue); await expect(finalizeResponse).rejects.toThrowError(TechnicalError); @@ -165,6 +235,7 @@ describe("PasscodeClient.finalize()", () => { passcodeClient.client.post = jest .fn() .mockRejectedValue(new Error("Test error")); + jest.spyOn(passcodeClient.state, "getTTL").mockReturnValue(passcodeTTL); const finalizeResponse = passcodeClient.finalize(userID, passcodeValue); await expect(finalizeResponse).rejects.toThrowError("Test error"); diff --git a/frontend/frontend-sdk/tests/lib/client/PasswordClient.spec.ts b/frontend/frontend-sdk/tests/lib/client/PasswordClient.spec.ts index 796cd2246..38e990d4a 100644 --- a/frontend/frontend-sdk/tests/lib/client/PasswordClient.spec.ts +++ b/frontend/frontend-sdk/tests/lib/client/PasswordClient.spec.ts @@ -20,10 +20,15 @@ describe("PasswordClient.login()", () => { const response = new Response(new XMLHttpRequest()); response.ok = true; jest.spyOn(passwordClient.client, "post").mockResolvedValue(response); + jest.spyOn(passwordClient.passcodeState, "read"); + jest.spyOn(passwordClient.passcodeState, "reset"); + jest.spyOn(passwordClient.passcodeState, "write"); const loginResponse = passwordClient.login(userID, password); await expect(loginResponse).resolves.toBeUndefined(); - + expect(passwordClient.passcodeState.read).toHaveBeenCalledTimes(1); + expect(passwordClient.passcodeState.reset).toHaveBeenCalledWith(userID); + expect(passwordClient.passcodeState.write).toHaveBeenCalledTimes(1); expect(passwordClient.client.post).toHaveBeenCalledWith("/password/login", { user_id: userID, password, @@ -49,20 +54,20 @@ describe("PasswordClient.login()", () => { jest .spyOn(response.headers, "get") .mockReturnValue(`${passwordRetryAfter}`); - jest.spyOn(passwordClient.state, "read"); - jest.spyOn(passwordClient.state, "setRetryAfter"); - jest.spyOn(passwordClient.state, "write"); + jest.spyOn(passwordClient.passwordState, "read"); + jest.spyOn(passwordClient.passwordState, "setRetryAfter"); + jest.spyOn(passwordClient.passwordState, "write"); await expect(passwordClient.login(userID, password)).rejects.toThrowError( TooManyRequestsError ); - expect(passwordClient.state.read).toHaveBeenCalledTimes(1); - expect(passwordClient.state.setRetryAfter).toHaveBeenCalledWith( + expect(passwordClient.passwordState.read).toHaveBeenCalledTimes(1); + expect(passwordClient.passwordState.setRetryAfter).toHaveBeenCalledWith( userID, passwordRetryAfter ); - expect(passwordClient.state.write).toHaveBeenCalledTimes(1); + expect(passwordClient.passwordState.write).toHaveBeenCalledTimes(1); expect(response.headers.get).toHaveBeenCalledWith("X-Retry-After"); }); @@ -99,13 +104,22 @@ describe("PasswordClient.update()", () => { }); }); - it("should throw error when API response is not ok", async () => { - const response = new Response(new XMLHttpRequest()); - passwordClient.client.put = jest.fn().mockResolvedValue(response); - - const config = passwordClient.update(userID, password); - await expect(config).rejects.toThrowError(TechnicalError); - }); + it.each` + status | error + ${401} | ${"Unauthorized error"} + ${500} | ${"Technical error"} + `( + "should throw error when API response is not ok", + async ({ status, error }) => { + const response = new Response(new XMLHttpRequest()); + response.ok = status >= 200 && status <= 299; + response.status = status; + passwordClient.client.put = jest.fn().mockResolvedValue(response); + + const config = passwordClient.update(userID, password); + await expect(config).rejects.toThrowError(error); + } + ); it("should throw error on API communication failure", async () => { passwordClient.client.put = jest @@ -119,7 +133,7 @@ describe("PasswordClient.update()", () => { describe("PasswordClient.getRetryAfter()", () => { it("should return password resend after seconds", async () => { jest - .spyOn(passwordClient.state, "getRetryAfter") + .spyOn(passwordClient.passwordState, "getRetryAfter") .mockReturnValue(passwordRetryAfter); expect(passwordClient.getRetryAfter(userID)).toEqual(passwordRetryAfter); }); diff --git a/frontend/frontend-sdk/tests/lib/client/WebauthnClient.spec.ts b/frontend/frontend-sdk/tests/lib/client/WebauthnClient.spec.ts index 4227968ee..016268b47 100644 --- a/frontend/frontend-sdk/tests/lib/client/WebauthnClient.spec.ts +++ b/frontend/frontend-sdk/tests/lib/client/WebauthnClient.spec.ts @@ -42,9 +42,12 @@ describe("webauthnClient.login()", () => { .mockResolvedValueOnce(initResponse) .mockResolvedValueOnce(finalResponse); - jest.spyOn(webauthnClient.state, "read"); - jest.spyOn(webauthnClient.state, "addCredential"); - jest.spyOn(webauthnClient.state, "write"); + jest.spyOn(webauthnClient.webauthnState, "read"); + jest.spyOn(webauthnClient.webauthnState, "addCredential"); + jest.spyOn(webauthnClient.webauthnState, "write"); + jest.spyOn(webauthnClient.passcodeState, "read"); + jest.spyOn(webauthnClient.passcodeState, "reset"); + jest.spyOn(webauthnClient.passcodeState, "write"); await webauthnClient.login(userID, true); @@ -53,12 +56,15 @@ describe("webauthnClient.login()", () => { mediation: "conditional", }); expect(webauthnClient._createAbortSignal).toHaveBeenCalledTimes(1); - expect(webauthnClient.state.read).toHaveBeenCalledTimes(1); - expect(webauthnClient.state.addCredential).toHaveBeenCalledWith( + expect(webauthnClient.webauthnState.read).toHaveBeenCalledTimes(1); + expect(webauthnClient.webauthnState.addCredential).toHaveBeenCalledWith( userID, credentialID ); - expect(webauthnClient.state.write).toHaveBeenCalledTimes(1); + expect(webauthnClient.webauthnState.write).toHaveBeenCalledTimes(1); + expect(webauthnClient.passcodeState.read).toHaveBeenCalledTimes(1); + expect(webauthnClient.passcodeState.reset).toHaveBeenCalledWith(userID); + expect(webauthnClient.passcodeState.write).toHaveBeenCalledTimes(1); expect(webauthnClient.client.post).toHaveBeenNthCalledWith( 1, "/webauthn/login/initialize", @@ -150,9 +156,9 @@ describe("webauthnClient.register()", () => { .mockResolvedValueOnce(initResponse) .mockResolvedValueOnce(finalResponse); - jest.spyOn(webauthnClient.state, "read"); - jest.spyOn(webauthnClient.state, "addCredential"); - jest.spyOn(webauthnClient.state, "write"); + jest.spyOn(webauthnClient.webauthnState, "read"); + jest.spyOn(webauthnClient.webauthnState, "addCredential"); + jest.spyOn(webauthnClient.webauthnState, "write"); await webauthnClient.register(); @@ -160,12 +166,12 @@ describe("webauthnClient.register()", () => { ...fakeCreationOptions, }); expect(webauthnClient._createAbortSignal).toHaveBeenCalledTimes(1); - expect(webauthnClient.state.read).toHaveBeenCalledTimes(1); - expect(webauthnClient.state.addCredential).toHaveBeenCalledWith( + expect(webauthnClient.webauthnState.read).toHaveBeenCalledTimes(1); + expect(webauthnClient.webauthnState.addCredential).toHaveBeenCalledWith( userID, credentialID ); - expect(webauthnClient.state.write).toHaveBeenCalledTimes(1); + expect(webauthnClient.webauthnState.write).toHaveBeenCalledTimes(1); expect(webauthnClient.client.post).toHaveBeenNthCalledWith( 1, "/webauthn/registration/initialize" @@ -245,7 +251,7 @@ describe("webauthnClient.shouldRegister()", () => { const user: User = { id: userID, - email: userID, + email_id: "", webauthn_credentials: [], }; @@ -255,11 +261,11 @@ describe("webauthnClient.shouldRegister()", () => { if (credentialMatched) { jest - .spyOn(webauthnClient.state, "matchCredentials") + .spyOn(webauthnClient.webauthnState, "matchCredentials") .mockReturnValueOnce([{ id: credentialID }]); } else { jest - .spyOn(webauthnClient.state, "matchCredentials") + .spyOn(webauthnClient.webauthnState, "matchCredentials") .mockReturnValueOnce([]); } @@ -269,15 +275,156 @@ describe("webauthnClient.shouldRegister()", () => { expect(shouldRegister).toEqual(expected); } ); +}); - describe("webauthnClient._createAbortSignal()", () => { - it("should call abort() on the current controller and return a new one", async () => { - const signal1 = webauthnClient._createAbortSignal(); - const abortFn = jest.fn(); - webauthnClient.controller.abort = abortFn; - const signal2 = webauthnClient._createAbortSignal(); - expect(abortFn).toHaveBeenCalled(); - expect(signal1).not.toBe(signal2); - }); +describe("webauthnClient._createAbortSignal()", () => { + it("should call abort() on the current controller and return a new one", async () => { + const signal1 = webauthnClient._createAbortSignal(); + const abortFn = jest.fn(); + webauthnClient.controller.abort = abortFn; + const signal2 = webauthnClient._createAbortSignal(); + expect(abortFn).toHaveBeenCalled(); + expect(signal1).not.toBe(signal2); + }); +}); + +describe("webauthnClient.listCredentials()", () => { + it("should list webauthn credentials", async () => { + const response = new Response(new XMLHttpRequest()); + response.ok = true; + response._decodedJSON = [ + { + id: credentialID, + public_key: "", + attestation_type: "", + aaguid: "", + created_at: "", + transports: [], + }, + ]; + + jest.spyOn(webauthnClient.client, "get").mockResolvedValue(response); + const list = await webauthnClient.listCredentials(); + expect(webauthnClient.client.get).toHaveBeenCalledWith( + "/webauthn/credentials" + ); + expect(list).toEqual(response._decodedJSON); + }); + + it.each` + status | error + ${401} | ${"Unauthorized error"} + ${500} | ${"Technical error"} + `( + "should throw error if API returns an error status", + async ({ status, error }) => { + const response = new Response(new XMLHttpRequest()); + response.status = status; + response.ok = status >= 200 && status <= 299; + + jest.spyOn(webauthnClient.client, "get").mockResolvedValueOnce(response); + + const email = webauthnClient.listCredentials(); + await expect(email).rejects.toThrow(error); + } + ); + + it("should throw error on API communication failure", async () => { + webauthnClient.client.get = jest + .fn() + .mockRejectedValue(new Error("Test error")); + + const user = webauthnClient.listCredentials(); + await expect(user).rejects.toThrowError("Test error"); + }); +}); + +describe("webauthnClient.updateCredential()", () => { + it("should update a webauthn credential", async () => { + const response = new Response(new XMLHttpRequest()); + response.ok = true; + + jest.spyOn(webauthnClient.client, "patch").mockResolvedValue(response); + const update = await webauthnClient.updateCredential( + credentialID, + "new name" + ); + expect(webauthnClient.client.patch).toHaveBeenCalledWith( + `/webauthn/credentials/${credentialID}`, + { name: "new name" } + ); + expect(update).toEqual(undefined); + }); + + it.each` + status | error + ${401} | ${"Unauthorized error"} + ${500} | ${"Technical error"} + `( + "should throw error if API returns an error status", + async ({ status, error }) => { + const response = new Response(new XMLHttpRequest()); + response.status = status; + response.ok = status >= 200 && status <= 299; + + jest + .spyOn(webauthnClient.client, "patch") + .mockResolvedValueOnce(response); + + const email = webauthnClient.updateCredential(credentialID, "new name"); + await expect(email).rejects.toThrow(error); + } + ); + + it("should throw error on API communication failure", async () => { + webauthnClient.client.patch = jest + .fn() + .mockRejectedValue(new Error("Test error")); + + const user = webauthnClient.updateCredential(credentialID, "new name"); + await expect(user).rejects.toThrowError("Test error"); + }); +}); + +describe("webauthnClient.delete()", () => { + it("should delete a webauthn credential", async () => { + const response = new Response(new XMLHttpRequest()); + response.ok = true; + + jest.spyOn(webauthnClient.client, "delete").mockResolvedValue(response); + const deleteResponse = await webauthnClient.deleteCredential(credentialID); + expect(webauthnClient.client.delete).toHaveBeenCalledWith( + `/webauthn/credentials/${credentialID}` + ); + expect(deleteResponse).toEqual(undefined); + }); + + it.each` + status | error + ${401} | ${"Unauthorized error"} + ${500} | ${"Technical error"} + `( + "should throw error if API returns an error status", + async ({ status, error }) => { + const response = new Response(new XMLHttpRequest()); + response.status = status; + response.ok = status >= 200 && status <= 299; + + jest + .spyOn(webauthnClient.client, "delete") + .mockResolvedValueOnce(response); + + const deleteResponse = webauthnClient.deleteCredential(credentialID); + await expect(deleteResponse).rejects.toThrow(error); + } + ); + + it("should throw error on API communication failure", async () => { + webauthnClient.client.delete = jest + .fn() + .mockRejectedValue(new Error("Test error")); + + const user = webauthnClient.deleteCredential(credentialID); + await expect(user).rejects.toThrowError("Test error"); }); }); diff --git a/quickstart/main.go b/quickstart/main.go index 0f0ea47fd..b0a682b3a 100644 --- a/quickstart/main.go +++ b/quickstart/main.go @@ -57,6 +57,7 @@ func main() { return c.Render(http.StatusOK, "secured.html", map[string]interface{}{ "HankoFrontendSdkUrl": hankoFrontendSdkUrl, "HankoUrl": hankoUrl, + "HankoElementUrl": hankoElementUrl, }) }, middleware.SessionMiddleware(hankoUrlInternal)) diff --git a/quickstart/public/assets/css/common.css b/quickstart/public/assets/css/common.css index d2ede7c3a..6407df603 100644 --- a/quickstart/public/assets/css/common.css +++ b/quickstart/public/assets/css/common.css @@ -6,27 +6,7 @@ body { color: white; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - background: url("../img/bg.jpg") no-repeat center center fixed; - background-size: cover; -} - -.button { - font-size: 14px; - height: 52px; - width: 100%; - border-radius: 5px; - padding: 8px 30px 8px 30px; - background: #506CF0; - border: none; - color: white; -} - -.button:hover { - background: #8093f4; -} - -.button:disabled { - background: #CBD4FF; + background-color: #05304D; } .nav__itemList { @@ -62,9 +42,9 @@ body { max-width: 1200px; } -@media screen and (max-width: 470px) { +@media screen and (max-width: 700px) { .content { - padding: 0 20px; + padding: 0; } } @@ -79,3 +59,49 @@ body { .footer img { padding-bottom: 2rem; } + +hanko-auth, hanko-profile { + /* Color Scheme */ + --color: white; + --color-shade-1: #A6B6C0; + --color-shade-2: #355970; + + --brand-color: #B3CDFF; + --brand-color-shade-1: #8EADDA; + --brand-contrast-color: #011726; + + --background-color: #05304D; + --error-color: #FF6068; + --link-color: #B3CDFF; + + /* Font Styles */ + --font-weight: 400; + --font-size: 16px; + --font-family: "Inter", sans-serif; + + /* Border Styles */ + --border-radius: 5px; + --border-style: solid; + --border-width: 1px; + + /* Item Styles */ + --item-height: 40px; + --item-margin: .75em 0; + + /* Input Styles */ + --input-min-width: 16em; + + /* Button Styles */ + --button-min-width: 6em; + + /* Container Styles */ + --container-padding: 0; + --container-max-width: 800px; + + /* Headline Styles */ + --headline1-font-size: 24px; + --headline1-margin: 0 0 1rem; + + --headline2-font-size: 18px; + --headline2-margin: 1rem 0 .5rem; +} diff --git a/quickstart/public/assets/css/index.css b/quickstart/public/assets/css/index.css index 294d4e434..5afdaf6eb 100644 --- a/quickstart/public/assets/css/index.css +++ b/quickstart/public/assets/css/index.css @@ -5,8 +5,7 @@ } .auth-container { - background-color: white; - max-width: 370px; + max-width: 360px; min-width: 200px; margin: auto; border-radius: 16px; @@ -19,42 +18,3 @@ } } -hanko-auth { - --font-family: "Inter", sans-serif; - --border-radius: 5px; -} - -.hanko_input { - border-color: #BFC2CD !important; -} - -.hanko_button.hanko_primary { - background-color: #CBD4FF !important; - color: black !important; - border-style: none !important; - font-weight: 500 !important; -} - - -.hanko_button.hanko_primary:hover { - background-color: #b3beff !important; -} - -.hanko_button.hanko_secondary { - background-color: #506CF0 !important; - color: white !important; - border-style: none !important; - font-weight: 500 !important; -} - -.hanko_button.hanko_secondary:hover { - background-color: #6980f2 !important; -} - -.hanko_divider { - border-bottom-color: #BFC2CD !important; -} - -.hanko_link { - color: #688293 !important; -} diff --git a/quickstart/public/assets/css/secured.css b/quickstart/public/assets/css/secured.css index 40079922e..29b223494 100644 --- a/quickstart/public/assets/css/secured.css +++ b/quickstart/public/assets/css/secured.css @@ -4,29 +4,21 @@ margin-bottom: 20px; } -.placeholder { - height: 77px; +.profile-container { + max-width: 700px; + min-width: 200px; + margin: auto; + border-radius: 16px; + padding: 25px; } -.buttonWrapper { - flex-shrink: 0; +hanko-profile { + --headline1-margin: 2em 0 1em; + --input-min-width: 20em; } -.passkeys > div { - display: flex; - width: 100%; - align-items: center; - flex-wrap: nowrap; - justify-content: space-between; -} - -@media screen and (max-width: 600px) { - .buttonWrapper { - margin-top: 28px; - width: 100%; - } - - .passkeys > div { - flex-wrap: wrap; +@media screen and (max-width: 420px) { + hanko-profile { + --input-min-width: 14em; } } diff --git a/quickstart/public/assets/img/bg.jpg b/quickstart/public/assets/img/bg.jpg deleted file mode 100644 index 86707facf53f47851136d1fae3bf4a3608113ea3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21594 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 diff --git a/quickstart/public/assets/img/exampleApp.svg b/quickstart/public/assets/img/exampleApp.svg deleted file mode 100644 index ccc62d165..000000000 --- a/quickstart/public/assets/img/exampleApp.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/quickstart/public/html/index.html b/quickstart/public/html/index.html index 3c6c97915..a6a166c38 100644 --- a/quickstart/public/html/index.html +++ b/quickstart/public/html/index.html @@ -20,13 +20,11 @@
    - Example app Powered by Hanko
    @@ -24,62 +25,22 @@
    -
    -

    My Profile

    -
    -
    - -
    -
    -
    -

    Passkeys

    +
    -
    Create a new passkey on this device or on another device.
    -
    +

    My Profile

    +
    - Example app Powered by Hanko