diff --git a/backend/audit_log/logger.go b/backend/audit_log/logger.go index 9e92cd85..1409b227 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 d0d486a4..d0d6e70e 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 8358f153..73421421 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 00000000..b5988cae --- /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 53986dac..37bf845e 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 9da20d5e..f6257904 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 b09c879d..aa240fbc 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 00000000..d69da0f6 --- /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 00000000..6b6281fc --- /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 00000000..ec464f3d --- /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 00000000..598578a6 --- /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 f17a23d3..60a05d07 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 f4031e53..33f92a50 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 a248f91c..8d1802fa 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 e7e626dc..f054c583 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) - if err != nil { - return fmt.Errorf("failed to get user: %w", err) - } - - if user != nil { - return dto.NewHTTPError(http.StatusConflict).SetInternal(errors.New(fmt.Sprintf("user with email %s already exists", user.Email))) - } - - newUser := models.NewUser(body.Email) - err = h.persister.GetUserPersisterWithConnection(tx).Create(newUser) + newUser := models.NewUser() + err := h.persister.GetUserPersisterWithConnection(tx).Create(newUser) 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 + email, err := h.persister.GetEmailPersisterWithConnection(tx).FindByAddress(body.Email) + if err != nil { + return fmt.Errorf("failed to get email: %w", err) + } - return c.JSON(http.StatusOK, newUser) + 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) + } + } + + 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) + } + + // 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 8f4e13db..f693235a 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 cc85914b..82cffbcf 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 d3301e0d..5ad178c2 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 deac9d16..fafbdfb2 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 49b7a9cd..75c3ae5c 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 00000000..1ac77693 --- /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 00000000..38a43513 --- /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 00000000..5700b8f2 --- /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 00000000..c02256ce --- /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 00000000..3f16ef47 --- /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 00000000..c12e7456 --- /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 00000000..1b020ad4 --- /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 00000000..cde417f5 --- /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 00000000..4123dcd2 --- /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 86905a8c..4c9e7ce1 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 00000000..e844bdf8 --- /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 ebe9d2b7..a4e29f4c 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 00000000..78f9db5d --- /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 33f99aa9..01c07454 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 00aa5349..e0184b8c 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 c9c31100..1a931173 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 a3dfeb31..80014d57 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 c4f0e8f3..9f2c63c8 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 00000000..766b7819 --- /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 cf3fbc41..d586e829 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 1fbd517e..fac9663e 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 8520542d..f5baab65 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 7ef66508..a80f93ad 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 9e5d7217..56b95914 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 00000000..174516bd --- /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 9760799d..0d7153ea 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 00000000..8c41ce82 --- /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 317cac2d..c9c8fb49 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 167e6a82..30afa0fe 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 6d38ccaf..2ae092f3 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 96ede5e8..06603f7d 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 56d62e88..919e977c 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 @@

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 d73e82cc..b8e1d0ba 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 b410d4d6..87e6b923 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 752c3af7..b7e32a18 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 @@

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 00000000..374e12b0 --- /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 00000000..f5e192c8 --- /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 00000000..8ae896fc --- /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 00000000..303ef2c6 --- /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 00000000..1a2c2e92 --- /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 67a62cc1..87dd0c4b 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 @@

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 659946b9..78b12d96 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 @@ 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. @@ -103,6 +104,7 @@ class Hanko { webauthn: WebauthnClient; password: PasswordClient; passcode: PasscodeClient; + email: EmailClient; // eslint-disable-next-line require-jsdoc constructor(api: string, timeout = 13000) { @@ -131,6 +133,11 @@ class Hanko { * @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 5a0f4b71..927067d2 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 b2460afa..5bcdae01 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 @@

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 b4cc6da4..f11f38a5 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 @@ we can easily return to the fetch API.

View Source - lib/client/HttpClient.ts, line 95 + lib/client/HttpClient.ts, line 111

@@ -326,6 +326,259 @@ we can easily return to the fetch API. +

+ # + + + + 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 @@ we can easily return to the fetch API.

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 @@ we can easily return to the fetch API.

View Source - lib/client/HttpClient.ts, line 226 + lib/client/HttpClient.ts, line 278

@@ -948,7 +1454,7 @@ we can easily return to the fetch API.

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 0c4dfd73..ba75b8eb 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 c42a590f..bdc07acb 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 738d1162..6635a2dd 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 f19d032b..eef92e11 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 ae836a2a..6f7b0b55 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 @@ + + + + emailID + + + + + +emailID + + + + + + + + + <optional>
+ + + + + + + + + The email address ID. + + + @@ -257,7 +288,7 @@

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 8a315cad..bd79e0cf 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 192fa7ed..d456cdb6 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 c2a4744a..20b73248 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 81ae29a0..69a47a7c 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 00000000..84a4fb82 --- /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 4903b31e..84fd61b3 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 eabc6bb7..b5036714 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 c2aa3208..9737512e 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 @@

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 5fb48ae7..6845f30e 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 @@

View Source - lib/client/PasscodeClient.ts, line 138 + lib/client/PasscodeClient.ts, line 167

@@ -783,7 +783,7 @@

View Source - lib/client/PasscodeClient.ts, line 154 + lib/client/PasscodeClient.ts, line 183

@@ -954,7 +954,7 @@

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 @@ Type + Attributes + @@ -1078,6 +1080,14 @@ + + + + + + + + @@ -1086,6 +1096,76 @@ + + + + + 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 @@

View Source - lib/client/PasscodeClient.ts, line 123 + lib/client/PasscodeClient.ts, line 152

@@ -1192,6 +1272,21 @@ +
+ + +
+ +UnauthorizedError + + +
+ + +
+ + +
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/PasscodeExpiredError.html b/docs/static/jsdoc/hanko-frontend-sdk/PasscodeExpiredError.html index 6f990d41..cbcd6c1a 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 440690c2..aaa0b1b1 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 @@

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

View Source - lib/state/PasscodeState.ts, line 187 + lib/state/PasscodeState.ts, line 226

@@ -778,7 +949,7 @@

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

View Source - lib/state/PasscodeState.ts, line 162 + lib/state/PasscodeState.ts, line 201

@@ -1446,7 +1617,203 @@

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

View Source - lib/state/PasscodeState.ts, line 196 + lib/state/PasscodeState.ts, line 235

@@ -1838,7 +2205,7 @@

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 c3eed11e..d1e3ac90 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

@@ -312,16 +312,16 @@ -PasswordState +PasscodeState -

- # +

+ # - state + passcodeState

@@ -368,7 +368,80 @@

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

+ + + + + + + + + +
+ + + + +PasswordState + + + + +

+ # + + + passwordState + + +

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

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

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

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

@@ -716,7 +789,7 @@

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

@@ -975,7 +1048,7 @@

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 2054850c..ee0f6cb8 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 @@ + + + + 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 0af1d172..252af2a1 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 6336e4d7..5cb56a19 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 46ca044a..27e05740 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 7c43caea..8b79c579 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 05d652c4..f26b421e 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 0c0604eb..4d66eeca 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 d0efbf74..c47466ab 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 cf28030d..74c79015 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 @@

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 e2593e9e..f1039020 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 11804032..aed03a6c 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 @@ + + + email_id + + + + + +string + + + + + + + + + + The UUID of the email address. + + + + has_webauthn_credential @@ -231,7 +254,7 @@

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 1da790d8..c3803ab9 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 bd00d2a1..2f33b816 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 635eba5b..4343d102 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

@@ -312,16 +312,16 @@ -WebauthnState +PasscodeState -

- # +

+ # - state + passcodeState

@@ -368,7 +368,80 @@

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

+ + + + + + + + + +
+ + + + +WebauthnState + + + + +

+ # + + + webauthnState + + +

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

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

@@ -393,6 +466,450 @@ +

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

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

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

+ +
+ + + + + + + + + + + + + + + + +
+
+
+ + +
+ + +
+ +NotFoundError + + +
+ + +
+ + + +
+ + +
+ +UnauthorizedError + + +
+ + +
+ + + +
+ + +
+ +RequestTimeoutError + + +
+ + +
+ + + +
+ + +
+ +TechnicalError + + +
+ + +
+ + +
+
+ + + +
+
+
+ + + +
+ + +
+ + +Promise.<void> + + +
+ +
+ + +
+
+ + + + +
+ +
+ + + +

+ # + + + async + + + + + listCredentials() → {Promise.<WebauthnCredentials>} + + +

+ + + + +
+ Returns a list of all WebAuthn credentials assigned to the current user. +
+ + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
See:
+
+ +
+ + + + + +

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

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

# @@ -572,7 +1089,7 @@ allowed credentials and the browser is able to present a list of suitable creden

View Source - lib/client/WebauthnClient.ts, line 180 + lib/client/WebauthnClient.ts, line 253

@@ -774,7 +1291,7 @@ allowed credentials and the browser is able to present a list of suitable creden

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

@@ -1033,7 +1550,7 @@ current browser/device.

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

@@ -1082,6 +1599,300 @@ current browser/device. +

+ +
+ + + +

+ # + + + 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 00000000..6d9b7ae1 --- /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 00000000..9b64401b --- /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 1c8092c5..df08e86d 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 @@

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 7d54b39d..72a807b4 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 a0b24fb4..8ecbbc58 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 4cbef4db..8c34996e 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 00000000..beb117f4 --- /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 12d63c84..50474ba8 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 @@ const hanko = new Hanko("http://localhost:3000")
  • 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 1d209a8e..9dd00dff 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 @@ * @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 @@ interface PasswordConfig { */ interface Config { password: PasswordConfig; + emails: EmailConfig; } /** @@ -125,11 +140,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; } @@ -164,7 +181,7 @@ interface Credential { */ interface User { id: string; - email: string; + email_id: string; webauthn_credentials: Credential[]; } @@ -184,13 +201,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<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 @@ export type { 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 c5e4c2c0..31ab307c 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 @@ 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, @@ -338,7 +377,9 @@ export { 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 8bdd0f24..701af7e0 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 cab9c909..1bc7539f 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 a0cec3f0..9e32d90b 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 00000000..df8fd55b --- /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 fa6cd035..b8e9452e 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 @@ class Response { statusText: string; url: string; _decodedJSON: any; + private xhr: XMLHttpRequest; // eslint-disable-next-line require-jsdoc constructor(xhr: XMLHttpRequest) { @@ -158,7 +159,11 @@ class Response { * @type {string} */ this.url = xhr.responseURL; - this._decodedJSON = JSON.parse(xhr.response); + /** + * @private + * @type {XMLHttpRequest} + */ + this.xhr = xhr; } /** @@ -167,8 +172,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); + } } /** @@ -287,6 +304,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<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 147caf35..dab59b47 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 @@ import { Passcode } from "../Dto"; import { InvalidPasscodeError, MaxNumOfPasscodeAttemptsReachedError, + PasscodeExpiredError, TechnicalError, TooManyRequestsError, + UnauthorizedError, } from "../Errors"; import { Client } from "./Client"; @@ -120,36 +122,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<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 (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); if (response.status === 429) { - const retryAfter = parseInt( - response.headers.get("X-Retry-After") || "0", - 10 - ); - - this.state.read().setResendAfter(userID, retryAfter).write(); + 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; } @@ -168,6 +199,12 @@ class PasscodeClient extends Client { */ 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 ff92b923..629052b9 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 @@
    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 @@ 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) {
    @@ -111,7 +114,12 @@ class PasswordClient extends Client {
          *  @public
          *  @type {PasswordState}
          */
    -    this.state = new PasswordState();
    +    this.passwordState = new PasswordState();
    +    /**
    +     *  @public
    +     *  @type {PasscodeState}
    +     */
    +    this.passcodeState = new PasscodeState();
       }
     
       /**
    @@ -134,18 +142,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;
       }
     
    @@ -166,7 +171,9 @@ class PasswordClient extends Client {
           password,
         });
     
    -    if (!response.ok) {
    +    if (response.status === 401) {
    +      throw new UnauthorizedError();
    +    } else if (!response.ok) {
           throw new TechnicalError();
         }
     
    @@ -180,7 +187,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/docs/static/jsdoc/hanko-frontend-sdk/lib_client_UserClient.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_UserClient.ts.html
    index 3573fe73..2c34551c 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 7bba37dd..517afc53 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 @@
         
         
    -
    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 @@ 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.
    @@ -110,7 +120,8 @@ import { Client } from "./Client";
      * @extends {Client}
      */
     class WebauthnClient extends Client {
    -  state: WebauthnState;
    +  webauthnState: WebauthnState;
    +  passcodeState: PasscodeState;
       controller: AbortController;
     
       _getCredential = getWebauthnCredential;
    @@ -123,7 +134,12 @@ class WebauthnClient extends Client {
          *  @public
          *  @type {WebauthnState}
          */
    -    this.state = new WebauthnState();
    +    this.webauthnState = new WebauthnState();
    +    /**
    +     *  @public
    +     *  @type {PasscodeState}
    +     */
    +    this.passcodeState = new PasscodeState();
       }
     
       /**
    @@ -182,11 +198,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;
       }
     
    @@ -247,7 +265,7 @@ class WebauthnClient extends Client {
         }
     
         const finalizeResponse: WebauthnFinalized = attestationResponse.json();
    -    this.state
    +    this.webauthnState
           .read()
           .addCredential(finalizeResponse.user_id, finalizeResponse.credential_id)
           .write();
    @@ -255,6 +273,81 @@ class WebauthnClient extends Client {
         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 @@ class WebauthnClient extends Client {
           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 0f172469..2ec323a8 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 @@ 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;
     }
     
     /**
    @@ -156,6 +158,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.
        *
    @@ -168,6 +193,7 @@ class PasscodeState extends UserState {
         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 145a0e57..a32145f7 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 ec38f817..d8ca9108 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 3dafebb4..f1fcbbf0 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 fb89a8b8..7d88dfd4 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 b20c7e92..3ad9e964 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 71f01cc1..b3da9e06 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 c1e3e758..5969a1f8 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 1dae39d7..ee23dda2 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 18ab1962..0a297060 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 363ac27d..63438335 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 b6f51fc7..dc548c9f 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 b7839605..ee9442b9 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
    +### <hanko-profile>
     
    -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.
    +A web component that allows to manage emails, passwords and passkeys.
     
    -
    +#### Markup
    +
    +```html
    +
    +```
    +
    +#### 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.
     
     ## 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,..).
    -
    -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 list of all CSS variables including default values can be found below:
    +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:
     
     ```css
    -hanko-auth {
    -  --background-color-h: 0;
    -  --background-color-s: 0%;
    -  --background-color-l: 100%;
    +hanko-auth, hanko-profile {
    +  /* Color Scheme */
    +  --color: #171717
    +  --color-shade-1: #8f9095
    +  --color-shade-2: #e5e6ef
     
    -  --border-radius: 3px;
    -  --border-style: solid;
    -  --border-width: 1.5px;
    +  --brand-color: #506cf0
    +  --brand-color-shade-1: #6b84fb
    +  --brand-contrast-color: white
     
    -  --brand-color-h: 351;
    -  --brand-color-s: 100%;
    -  --brand-color-l: 59%;
    +  --background-color: white
    +  --error-color: #e82020
    +  --link-color: #506cf0
     
    -  --color-h: 0;
    -  --color-s: 0%;
    -  --color-l: 0%;
    +  /* Font Styles */
    +  --font-weight: 400
    +  --font-size: 14px
    +  --font-family: sans-serif
     
    -  --container-padding: 20px;
    -  --container-max-width: 600px;
    +  /* Border Styles */
    +  --border-radius: 4px
    +  --border-style: solid
    +  --border-width: 1px
     
    -  --error-color-h: 351;
    -  --error-color-s: 100%;
    -  --error-color-l: 59%;
    +  /* Item Styles */
    +  --item-height: 34px
    +  --item-margin: .5rem 0
     
    -  --font-family: sans-serif;
    -  --font-size: 16px;
    -  --font-weight: 400;
    +  /* Container Styles */
    +  --container-padding: 0
    +  --container-max-width: 600px
     
    -  --headline-font-size: 30px;
    -  --headline-font-weight: 700;
    +  /* Headline Styles */
    +  --headline1-font-size: 24px
    +  --headline1-font-weight: 600
    +  --headline1-margin: 0 0 .5rem
     
    -  --input-height: 50px;
    +  --headline2-font-size: 14px
    +  --headline2-font-weight: 600
    +  --headline2-margin: 1rem 0 .25rem
     
    -  --item-margin: 15px 0;
    +  /* Divider Styles */
    +  --divider-padding: 0 42px
    +  --divider-display: block
    +  --divider-visibility: visible
     
    -  --lightness-adjust-dark: -30%;
    -  --lightness-adjust-dark-light: -10%;
    -  --lightness-adjust-light: 10%;
    -  --lightness-adjust-light-dark: 30%;
    +  /* 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 4e90e891..00000000
    Binary files a/frontend/elements/demo-ui.png and /dev/null differ
    diff --git a/frontend/elements/demo.gif b/frontend/elements/demo.gif
    deleted file mode 100644
    index af5c2d50..00000000
    Binary files a/frontend/elements/demo.gif and /dev/null differ
    diff --git a/frontend/elements/example.css b/frontend/elements/example.css
    index 5199221b..0e787570 100644
    --- a/frontend/elements/example.css
    +++ b/frontend/elements/example.css
    @@ -1,3 +1,212 @@
    +.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_content {
    +    box-sizing: border-box;
    +    flex: 0 1 auto;
    +    width: 100%;
    +    height: 100%
    +}
    +
    +.hanko_footer {
    +    padding: var(--item-margin, 0.5rem 0);
    +    box-sizing: border-box;
    +    width: 100%
    +}
    +
    +.hanko_footer :nth-child(1) {
    +    float: left
    +}
    +
    +.hanko_footer :nth-child(2) {
    +    float: right
    +}
    +
    +.hanko_form .hanko_ul {
    +    padding-inline-start: 0;
    +    list-style-type: none;
    +    margin: 0
    +}
    +
    +@media screen and (min-width: 450px) {
    +    .hanko_form .hanko_ul {
    +        display: flex
    +    }
    +}
    +
    +.hanko_form .hanko_li {
    +    display: flex
    +}
    +
    +@media screen and (min-width: 450px) {
    +    .hanko_form .hanko_li {
    +        display: inline-flex
    +    }
    +
    +    .hanko_form .hanko_li:first-child {
    +        flex-grow: 1
    +    }
    +
    +    .hanko_form .hanko_li:last-child {
    +        margin: 0 0 0 1rem;
    +        flex-grow: 0;
    +        min-width: 125px
    +    }
    +
    +    .hanko_form .hanko_li:only-child {
    +        margin: 0;
    +        flex-grow: 1
    +    }
    +}
    +
    +.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;
    +    transition: .1s ease-out
    +}
    +
    +.hanko_button:disabled {
    +    cursor: default
    +}
    +
    +.hanko_button.hanko_primary {
    +    color: var(--brand-contrast-color, white);
    +    background: var(--brand-color, #506cf0);
    +    border-color: var(--brand-color, #506cf0)
    +}
    +
    +.hanko_button.hanko_primary:hover {
    +    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: var(--brand-contrast-color, white);
    +    background: var(--brand-color, #506cf0);
    +    border-color: var(--color, #171717)
    +}
    +
    +.hanko_button.hanko_primary:disabled {
    +    color: var(--color-shade-1, #8f9095);
    +    background: var(--color-shade-2, #e5e6ef);
    +    border-color: var(--color-shade-2, #e5e6ef)
    +}
    +
    +.hanko_button.hanko_secondary {
    +    color: var(--color, #171717);
    +    background: var(--background-color, white);
    +    border-color: var(--color, #171717)
    +}
    +
    +.hanko_button.hanko_secondary:hover {
    +    color: var(--color, #171717);
    +    background: var(--color-shade-2, #e5e6ef);
    +    border-color: var(--color, #171717)
    +}
    +
    +.hanko_button.hanko_secondary:focus {
    +    color: var(--color, #171717);
    +    background: var(--background-color, white);
    +    border-color: var(--brand-color, #506cf0)
    +}
    +
    +.hanko_button.hanko_secondary:disabled {
    +    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, 0.5rem 0);
    +    display: flex;
    +    flex-grow: 1
    +}
    +
    +.hanko_input {
    +    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);
    +    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:-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,
    +.hanko_input::-ms-clear {
    +    display: none
    +}
    +
    +.hanko_input::placeholder {
    +    color: var(--color-shade-1, #8f9095)
    +}
    +
    +.hanko_input:focus {
    +    color: var(--color, #171717);
    +    border-color: var(--color, #171717)
    +}
    +
    +.hanko_input:disabled {
    +    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, 0.5rem 0)
    +}
    +
    +.hanko_passcodeInputWrapper .hanko_passcodeDigitWrapper {
    +    flex-grow: 1;
    +    margin: 0 .5rem 0 0
    +}
    +
    +.hanko_passcodeInputWrapper .hanko_passcodeDigitWrapper:last-child {
    +    margin: 0
    +}
    +
    +.hanko_passcodeInputWrapper .hanko_passcodeDigitWrapper .hanko_input {
    +    text-align: center
    +}
    +
     .hanko_checkmark {
         display: inline-block;
         width: 16px;
    @@ -10,7 +219,7 @@
         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%));
    +    border-color: var(--brand-color, #506cf0);
         position: absolute;
         width: 16px;
         height: 16px;
    @@ -20,33 +229,33 @@
     }
     
     .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%))
    +    border-color: var(--color-shade-1, #8f9095)
     }
     
     .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%));
    +    background-color: var(--brand-color, #506cf0);
         left: 8px;
         top: 3px
     }
     
     .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%))
    +    background-color: var(--color-shade-1, #8f9095)
     }
     
     .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%));
    +    background-color: var(--brand-color, #506cf0);
         left: 5px;
         top: 10px
     }
     
     .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%))
    +    background-color: var(--color-shade-1, #8f9095)
     }
     
     .hanko_checkmark.hanko_fadeOut {
    @@ -63,291 +272,17 @@
         }
     }
     
    -.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)
    -    }
    -
    -    100% {
    -        transform: rotate(360deg)
    -    }
    -}
    -
    -.hanko_loadingIndicator {
    -    display: inline-block;
    -    margin: 0 5px
    -}
    -
    -.hanko_button {
    -    flex-grow: 1;
    -    outline: none;
    -    cursor: pointer;
    -    transition: .1s ease-out
    -}
    -
    -.hanko_button:disabled {
    -    cursor: default
    -}
    -
    -.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)
    -}
    -
    -.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%))
    -}
    -
    -.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%)))
    -}
    -
    -.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%)))
    -}
    -
    -.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)
    -}
    -
    -.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%))
    -}
    -
    -.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%)))
    -}
    -
    -.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%)))
    -}
    -
    -.hanko_inputWrapper {
    -    position: relative;
    -    margin: var(--item-margin, 15px 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-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-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%;
    -    outline: none;
    -    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::-ms-reveal,
    -.hanko_input::-ms-clear {
    -    display: none
    -}
    -
    -.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%))
    -}
    -
    -.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%)))
    -}
    -
    -.hanko_passcodeInputWrapper {
    -    display: flex;
    -    justify-content: space-between;
    -    margin: var(--item-margin, 15px 0)
    -}
    -
    -.hanko_passcodeDigitWrapper {
    -    flex-grow: 1;
    -    margin: 0 10px 0 0
    -}
    -
    -.hanko_passcodeDigitWrapper:last-child {
    -    margin: 0
    -}
    -
    -.hanko_passcodeDigitWrapper 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_content {
    -    box-sizing: border-box;
    -    flex: 0 1 auto;
    -    width: 100%;
    -    height: 100%
    -}
    -
    -.hanko_ul {
    -    padding-inline-start: 0;
    -    list-style-type: none;
    -    margin: 0
    -}
    -
    -.hanko_li {
    -    display: flex
    -}
    -
    -.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_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_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_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);
    -    box-sizing: border-box;
    -    width: 100%
    -}
    -
    -.hanko_footer :nth-child(1) {
    -    float: left
    -}
    -
    -.hanko_footer :nth-child(2) {
    -    float: right
    -}
    -
     .hanko_paragraph {
         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: var(--color, #171717);
    +    margin: var(--item-margin, 0.5rem 0);
         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)
    +    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;
    +    display: flex;
    +    align-items: center;
    +    justify-content: space-between;
    +    padding: 0 1rem;
    +    margin: 0;
    +    cursor: pointer;
    +    transition: all .35s
    +}
    +
    +.hanko_accordion .hanko_accordionItem .hanko_label .hanko_labelText {
    +    white-space: nowrap;
    +    overflow: hidden;
    +    text-overflow: ellipsis
    +}
    +
    +.hanko_accordion .hanko_accordionItem .hanko_label .hanko_labelText .hanko_description {
    +    color: var(--color-shade-1, #8f9095)
    +}
    +
    +.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 efad9b65..2e80e722 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 696a175c..c2a34908 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 00000000..2ba2e16c
    --- /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 00000000..b6858ead
    --- /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 00000000..1ff7619c
    --- /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 00000000..ee373546
    --- /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 00000000..bcbddea7
    --- /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 00000000..170ca7ab
    --- /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 (
    +    
    + {data.map((item, itemIndex) => ( +
    + + +
    + {contentSelector(item, itemIndex)} +
    +
    + ))} +
    + ); +}; + +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 00000000..ddba3fa4 --- /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 00000000..1831106a --- /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 00000000..7508c757 --- /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 00000000..2f5972df --- /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 00000000..c5348445 --- /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 00000000..9255b0a3 --- /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 00000000..04275d3f --- /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 a4c34cef..776466be 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 00000000..0dd48a34 --- /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 93d2c307..9c50c0b9 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 00000000..ff16a3e6 --- /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 1f4f6c5e..058cf1e9 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 9ec48fd2..2df3efc6 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 05cdf00b..fddcf1aa 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 00000000..52376f95 --- /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 00000000..9ded7351 --- /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 00000000..e8c045a3 --- /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 00000000..3d99f605 --- /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 00000000..6ebccaae --- /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 24bf1a97..c3ce8b4a 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 de409b23..962057b7 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 Checkmark from "./Checkmark"; -import LoadingWheel from "./LoadingWheel"; +import cx from "classnames"; -import styles from "./LoadingIndicator.sass"; +import Checkmark from "./Checkmark"; + +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 00000000..f0b26f87 --- /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 00000000..54f6b7d2 --- /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 00000000..812f1816 --- /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 6e13aa31..94f7094f 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 00000000..743aca91 --- /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 00000000..236b263b --- /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 af0bec88..a1ae7fa1 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 29ae1153..d3716416 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 00000000..8356794a --- /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 00000000..8322fbc3 --- /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 8f318433..1ad7ab31 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 00000000..7cc40c79 --- /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 00000000..4443c0e0 --- /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 00000000..6422fe1a --- /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 652d4ff6..240d80f7 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 00000000..5d1feff0 --- /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 00000000..f66188d6 --- /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 00000000..8828bb28 --- /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 90f0fe63..bcb5d9e7 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 00000000..b27a5fba --- /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 00000000..c61bd3d9 --- /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 5afe63d1..c82f95f5 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 5c1f4b1b..00000000 --- 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 51e04d95..00000000 --- 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 de2b611f..00000000 --- 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 0f4169b9..00000000 --- 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 b8915f8d..00000000 --- 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 f912508e..00000000 --- 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 21abebac..00000000 --- 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 510274eb..00000000 --- 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 29980a34..00000000 --- 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 e010e7cf..00000000 --- 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 496b74f5..00000000 --- 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 8bd0ad60..00000000 --- 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 9f7d2baa..00000000 --- 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 54b0d5e0..00000000 --- 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 285d3c19..00000000 --- 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 65f93568..00000000 --- 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 1dafa9c4..00000000 --- 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 0c8f85fe..00000000 --- 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 b19679f7..00000000 --- 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 b6f25cd8..00000000 --- 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 b74a6833..00000000 --- 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 189dd92b..00000000 --- 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 802b61e8..00000000 --- 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 4dbf0481..00000000 --- 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 3c1aed1a..00000000 --- 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 fde94b97..00000000 --- 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 bbbe364a..00000000 --- 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 0e6940d1..00000000 --- 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 8e16b08c..00000000 --- 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 b88ee728..00000000 --- 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 59c77904..00000000 --- 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 6b0d1016..00000000 --- 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 965b50d4..00000000 --- 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 91020538..00000000 --- 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 3bc33a21..00000000 --- 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 77c0c318..00000000 --- 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 56cca955..00000000 --- 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 181f224d..00000000 --- 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 0677ccdb..00000000 --- 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 4782d8d8..00000000 --- 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 705af3a1..00000000 --- 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 cec456fd..3e47ff99 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 74142882..4750d6a7 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 ef6406e4..393011d8 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 74f7c517..83615041 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 fa5f76c1..83fa82a4 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 93bb7302..a8a4c548 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 2b5e5c44..809aebba 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 47175ca5..c4f01146 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 8c4febc3..4179bd80 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 00000000..faa4aa15 --- /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 5fb25723..e4528717 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 83c9f03d..252bf88a 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(); + + 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); if (response.status === 429) { - const retryAfter = parseInt( - response.headers.get("X-Retry-After") || "0", - 10 - ); - - this.state.read().setResendAfter(userID, retryAfter).write(); + 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 1a2a98e0..50336299 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 394ff906..9d1d9983 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 0ed2f936..01d65a03 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 37e3a207..d7bfc540 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 00000000..316b86d4 --- /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 c25c1d17..08e0fdcb 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 efeaa6a9..63381519 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 796cd224..38e990d4 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); + 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(TechnicalError); - }); + 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 4227968e..016268b4 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 0f0ea47f..b0a682b3 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 d2ede7c3..6407df60 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 294d4e43..5afdaf6e 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 40079922..29b22349 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 86707fac..00000000 Binary files a/quickstart/public/assets/img/bg.jpg and /dev/null differ diff --git a/quickstart/public/assets/img/exampleApp.svg b/quickstart/public/assets/img/exampleApp.svg deleted file mode 100644 index ccc62d16..00000000 --- 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 3c6c9791..a6a166c3 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