diff --git a/backend/dto/admin/identity.go b/backend/dto/admin/identity.go new file mode 100644 index 000000000..5e9315f4f --- /dev/null +++ b/backend/dto/admin/identity.go @@ -0,0 +1,27 @@ +package admin + +import ( + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/persistence/models" + "time" +) + +type Identity struct { + ID uuid.UUID `json:"id"` + ProviderID string `json:"provider_id"` + ProviderName string `json:"provider_name"` + EmailID uuid.UUID `json:"email_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func FromIdentityModel(model models.Identity) Identity { + return Identity{ + ID: model.ID, + ProviderID: model.ProviderID, + ProviderName: model.ProviderName, + EmailID: model.EmailID, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, + } +} diff --git a/backend/dto/admin/password.go b/backend/dto/admin/password.go new file mode 100644 index 000000000..3e35f65ad --- /dev/null +++ b/backend/dto/admin/password.go @@ -0,0 +1,12 @@ +package admin + +import ( + "github.com/gofrs/uuid" + "time" +) + +type PasswordCredential struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/backend/dto/admin/user.go b/backend/dto/admin/user.go index 75d5f18db..d477add79 100644 --- a/backend/dto/admin/user.go +++ b/backend/dto/admin/user.go @@ -11,8 +11,11 @@ type User struct { ID uuid.UUID `json:"id"` WebauthnCredentials []dto.WebauthnCredentialResponse `json:"webauthn_credentials,omitempty"` Emails []Email `json:"emails,omitempty"` + Username *Username `json:"username,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + Password *PasswordCredential `json:"password,omitempty"` + Identities []Identity `json:"identities,omitempty"` } // FromUserModel Converts the DB model to a DTO object @@ -22,20 +25,42 @@ func FromUserModel(model models.User) User { credentials[i] = *dto.FromWebauthnCredentialModel(&model.WebauthnCredentials[i]) } emails := make([]Email, len(model.Emails)) + var identities = make([]Identity, 0) for i := range model.Emails { emails[i] = *FromEmailModel(&model.Emails[i]) + for j := range model.Emails[i].Identities { + identities = append(identities, FromIdentityModel(model.Emails[i].Identities[j])) + } } + var username *Username = nil + if model.Username != nil { + username = FromUsernameModel(model.Username) + } + + var passwordCredential *PasswordCredential = nil + if model.PasswordCredential != nil { + passwordCredential = &PasswordCredential{ + ID: model.PasswordCredential.ID, + CreatedAt: model.PasswordCredential.CreatedAt, + UpdatedAt: model.PasswordCredential.UpdatedAt, + } + } + return User{ ID: model.ID, WebauthnCredentials: credentials, Emails: emails, + Username: username, CreatedAt: model.CreatedAt, UpdatedAt: model.UpdatedAt, + Password: passwordCredential, + Identities: identities, } } type CreateUser struct { ID uuid.UUID `json:"id"` - Emails []CreateEmail `json:"emails" validate:"required,gte=1,unique=Address,dive"` + Emails []CreateEmail `json:"emails" validate:"unique=Address,dive"` + Username *string `json:"username"` CreatedAt time.Time `json:"created_at"` } diff --git a/backend/dto/admin/username.go b/backend/dto/admin/username.go new file mode 100644 index 000000000..44da66dab --- /dev/null +++ b/backend/dto/admin/username.go @@ -0,0 +1,24 @@ +package admin + +import ( + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/persistence/models" + "time" +) + +type Username struct { + ID uuid.UUID `json:"id"` + Username string `json:"username"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// FromEmailModel Converts the DB model to a DTO object +func FromUsernameModel(model *models.Username) *Username { + return &Username{ + ID: model.ID, + Username: model.Username, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, + } +} diff --git a/backend/handler/user_admin.go b/backend/handler/user_admin.go index 0cac0dd39..fc65b4c64 100644 --- a/backend/handler/user_admin.go +++ b/backend/handler/user_admin.go @@ -64,6 +64,7 @@ type UserListRequest struct { Page int `query:"page"` Email string `query:"email"` UserId string `query:"user_id"` + Username string `query:"username"` SortDirection string `query:"sort_direction"` } @@ -101,13 +102,14 @@ func (h *UserHandlerAdmin) List(c echo.Context) error { } email := strings.ToLower(request.Email) + username := strings.ToLower(request.Username) - users, err := h.persister.GetUserPersister().List(request.Page, request.PerPage, userId, email, request.SortDirection) + users, err := h.persister.GetUserPersister().List(request.Page, request.PerPage, userId, email, username, request.SortDirection) if err != nil { return fmt.Errorf("failed to get list of users: %w", err) } - userCount, err := h.persister.GetUserPersister().Count(userId, email) + userCount, err := h.persister.GetUserPersister().Count(userId, email, username) if err != nil { return fmt.Errorf("failed to get total count of users: %w", err) } @@ -154,6 +156,10 @@ func (h *UserHandlerAdmin) Create(c echo.Context) error { return dto.ToHttpError(err) } + if len(body.Emails) == 0 && (body.Username == nil || *body.Username == "") { + return echo.NewHTTPError(http.StatusBadRequest, "at least one of [Emails, Username] must be set") + } + // if no userID is provided, create a new one if body.ID.IsNil() { userId, err := uuid.NewV4() @@ -171,9 +177,7 @@ func (h *UserHandlerAdmin) Create(c echo.Context) error { } } - if primaryEmails == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "at least one primary email must be provided") - } else if primaryEmails > 1 { + if primaryEmails > 1 { return echo.NewHTTPError(http.StatusBadRequest, "only one primary email is allowed") } @@ -238,6 +242,25 @@ func (h *UserHandlerAdmin) Create(c echo.Context) error { } } } + + if body.Username != nil { + username := models.NewUsername(u.ID, *body.Username) + err = tx.Create(username) + if err != nil { + var pgErr *pgconn.PgError + var mysqlErr *mysql.MySQLError + if errors.As(err, &pgErr) { + if pgErr.Code == "23505" { + return echo.NewHTTPError(http.StatusConflict, fmt.Errorf("failed to create username '%s' for user '%v': %w", username.Username, u.ID, fmt.Errorf("username already exists"))) + } + } else if errors.As(err, &mysqlErr) { + if mysqlErr.Number == 1062 { + return echo.NewHTTPError(http.StatusConflict, fmt.Errorf("failed to create username '%s' for user '%v': %w", username.Username, u.ID, fmt.Errorf("username already exists"))) + } + } + return fmt.Errorf("failed to create email '%s' for user '%v': %w", username.Username, u.ID, err) + } + } return nil }) diff --git a/backend/persistence/user_persister.go b/backend/persistence/user_persister.go index 9388fb1fd..8b28c2eb1 100644 --- a/backend/persistence/user_persister.go +++ b/backend/persistence/user_persister.go @@ -16,9 +16,9 @@ type UserPersister interface { Create(models.User) error Update(models.User) error Delete(models.User) error - List(page int, perPage int, userId uuid.UUID, email string, sortDirection string) ([]models.User, error) + List(page int, perPage int, userId uuid.UUID, email string, username string, sortDirection string) ([]models.User, error) All() ([]models.User, error) - Count(userId uuid.UUID, email string) (int, error) + Count(userId uuid.UUID, email string, username string) (int, error) GetByUsername(username string) (*models.User, error) } @@ -129,17 +129,17 @@ func (p *userPersister) Delete(user models.User) error { return nil } -func (p *userPersister) List(page int, perPage int, userId uuid.UUID, email string, sortDirection string) ([]models.User, error) { +func (p *userPersister) List(page int, perPage int, userId uuid.UUID, email string, username string, sortDirection string) ([]models.User, error) { users := []models.User{} query := p.db. Q(). - EagerPreload("Emails", "Emails.PrimaryEmail", "WebauthnCredentials"). + EagerPreload("Emails", "Emails.PrimaryEmail", "WebauthnCredentials", "Username"). LeftJoin("emails", "emails.user_id = users.id"). LeftJoin("usernames", "usernames.user_id = users.id") - query = p.addQueryParamsToSqlQuery(query, userId, email) + query = p.addQueryParamsToSqlQuery(query, userId, email, username) err := query.GroupBy("users.id"). - Having("count(emails.id) > 0"). + Having("count(emails.id) > 0 OR count(usernames.id) > 0"). Order(fmt.Sprintf("users.created_at %s", sortDirection)). Paginate(page, perPage). All(&users) @@ -156,7 +156,7 @@ func (p *userPersister) List(page int, perPage int, userId uuid.UUID, email stri func (p *userPersister) All() ([]models.User, error) { users := []models.User{} - err := p.db.EagerPreload("Emails", "Emails.PrimaryEmail", "Emails.Identities", "WebauthnCredentials", "Usernames").All(&users) + err := p.db.EagerPreload("Emails", "Emails.PrimaryEmail", "Emails.Identities", "WebauthnCredentials", "Username").All(&users) if err != nil && errors.Is(err, sql.ErrNoRows) { return users, nil } @@ -167,13 +167,14 @@ func (p *userPersister) All() ([]models.User, error) { return users, nil } -func (p *userPersister) Count(userId uuid.UUID, email string) (int, error) { +func (p *userPersister) Count(userId uuid.UUID, email string, username string) (int, error) { query := p.db. Q(). - LeftJoin("emails", "emails.user_id = users.id") - query = p.addQueryParamsToSqlQuery(query, userId, email) + LeftJoin("emails", "emails.user_id = users.id"). + LeftJoin("usernames", "usernames.user_id = users.id") + query = p.addQueryParamsToSqlQuery(query, userId, email, username) count, err := query.GroupBy("users.id"). - Having("count(emails.id) > 0"). + Having("count(emails.id) > 0 OR count(usernames.id) > 0"). Count(&models.User{}) if err != nil { return 0, fmt.Errorf("failed to get user count: %w", err) @@ -182,10 +183,15 @@ func (p *userPersister) Count(userId uuid.UUID, email string) (int, error) { return count, nil } -func (p *userPersister) addQueryParamsToSqlQuery(query *pop.Query, userId uuid.UUID, email string) *pop.Query { - if email != "" { +func (p *userPersister) addQueryParamsToSqlQuery(query *pop.Query, userId uuid.UUID, email string, username string) *pop.Query { + if email != "" && username != "" { + query = query.Where("emails.address LIKE ? OR usernames.username LIKE ?", "%"+email+"%", "%"+username+"%") + } else if email != "" { query = query.Where("emails.address LIKE ?", "%"+email+"%") + } else if username != "" { + query = query.Where("usernames.username LIKE ?", "%"+username+"%") } + if !userId.IsNil() { query = query.Where("users.id = ?", userId) }