feat: create users with a password and an invitation in the admin
- password with confirmation on create, optional reset on update that ends the user's sessions; the hash is never returned - send_invite sends one invitation with an activation link after the create commits - organisation picker, avatar upload shared with the user API and the frontend permission editor on the user form - an email held by a deactivated user answers 422 instead of failing on the unique constraint - the privileged-member guard now also refuses a submitted password
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gocloud.dev/blob"
|
||||
"gorm.io/gorm"
|
||||
@@ -125,13 +127,39 @@ func (c usersAdminController) ListRowStates(ctx context.Context, db *gorm.DB, re
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FormVirtualFields are the form fields that are not columns of the form:
|
||||
// the password with its confirmation, and the create-only invitation
|
||||
// checkbox. The framework never fills or returns them; the Form hooks read
|
||||
// them with cabana.VirtualFieldsFromContext.
|
||||
func (usersAdminController) FormVirtualFields() []string {
|
||||
return []string{"password", "password_confirmation", "send_invite"}
|
||||
}
|
||||
|
||||
// FormRules are the rules of an admin save. They replace User.Rules(), which
|
||||
// is the register contract and would ask every admin update for a confirmed
|
||||
// password.
|
||||
func (usersAdminController) FormRules(_ context.Context, _ string) map[string]string {
|
||||
return map[string]string{
|
||||
"email": "required|between:6,255|email|unique:users",
|
||||
// password: a create needs a confirmed password, an update takes one only
|
||||
// when the administrator types it (empty means unchanged). The minimum
|
||||
// length is golem15.user.password.min_length.
|
||||
func (c usersAdminController) FormRules(_ context.Context, op string) map[string]string {
|
||||
length := "between:" + strconv.Itoa(c.configInt("golem15.user.password.min_length", 8)) + ",255"
|
||||
password := "nullable|" + length + "|confirmed"
|
||||
if op == "create" {
|
||||
password = "required|" + length + "|confirmed"
|
||||
}
|
||||
return map[string]string{
|
||||
"email": "required|between:6,255|email|unique:users",
|
||||
"password": password,
|
||||
}
|
||||
}
|
||||
|
||||
// configInt reads a positive integer config value, else fallback.
|
||||
func (c usersAdminController) configInt(key string, fallback int) int {
|
||||
if cfg := c.config(); cfg != nil {
|
||||
if n := cfg.Int(key); n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// FilterOptions serves the choices of the groups filter (scope
|
||||
@@ -192,6 +220,191 @@ func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) e
|
||||
if err := c.guardCredentials(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.requireFreeEmail(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
changed, err := c.storePassword(ctx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
// An admin password reset ends every session of the user, as the
|
||||
// user's own change-password does.
|
||||
now := time.Now()
|
||||
user.TokensValidAfter = &now
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormBeforeCreate checks the email against deactivated users too and stores
|
||||
// the hash of the submitted password. A user created here starts not
|
||||
// activated: is_activated is never written from the form, and only the
|
||||
// activate actions (or the invitation link) set it.
|
||||
func (c usersAdminController) FormBeforeCreate(ctx context.Context, model any) error {
|
||||
user, ok := model.(*models.User)
|
||||
if !ok || user == nil {
|
||||
return errors.New("users admin: form model is not a user")
|
||||
}
|
||||
if err := c.requireFreeEmail(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.storePassword(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
user.IsActivated = false
|
||||
// The column defaults to true; only an account created through a social
|
||||
// login starts without a password of its own.
|
||||
user.HasSelfSetPassword = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireFreeEmail refuses an email another user already has, deactivated
|
||||
// users included: the unique rule leaves deactivated rows out, and the
|
||||
// database constraint does not.
|
||||
func (c usersAdminController) requireFreeEmail(ctx context.Context, user *models.User) error {
|
||||
db := requestDB(ctx, c.db)
|
||||
if db == nil {
|
||||
return errors.New("users admin: gorm handle is missing")
|
||||
}
|
||||
var taken int64
|
||||
err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
|
||||
Model(&models.User{}).Where("email = ? AND id <> ?", user.Email, user.ID).Count(&taken).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if taken == 0 {
|
||||
return nil
|
||||
}
|
||||
message := "The email has already been taken."
|
||||
if tr := appTranslator(c.application()); tr != nil {
|
||||
const key = "lagoon::validate.unique"
|
||||
if text := tr.Get(ctx, key, map[string]string{"attribute": "email"}); text != "" && text != key {
|
||||
message = text
|
||||
}
|
||||
}
|
||||
return &cabana.ValidationError{Details: map[string]any{"email": []string{message}}}
|
||||
}
|
||||
|
||||
// storePassword hashes a submitted password onto the user with
|
||||
// golem15.user.password.bcrypt_cost and reports whether one was submitted.
|
||||
// An absent or empty password changes nothing.
|
||||
func (c usersAdminController) storePassword(ctx context.Context, user *models.User) (bool, error) {
|
||||
values, _ := cabana.VirtualFieldsFromContext(ctx)
|
||||
plain, _ := values["password"].(string)
|
||||
if plain == "" {
|
||||
return false, nil
|
||||
}
|
||||
hashed, err := bouncer.HashPassword(c.configInt("golem15.user.password.bcrypt_cost", 10), plain)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
user.Password = hashed
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// FormAfterCreate sends the invitation when the send_invite checkbox was
|
||||
// set: an activation code is issued inside the create's transaction, and
|
||||
// exactly one golem15.user::mail.invite message with the activation link
|
||||
// leaves after the commit. A create that rolls back sends nothing.
|
||||
func (c usersAdminController) FormAfterCreate(ctx context.Context, model any) error {
|
||||
user, ok := model.(*models.User)
|
||||
if !ok || user == nil {
|
||||
return errors.New("users admin: form model is not a user")
|
||||
}
|
||||
values, _ := cabana.VirtualFieldsFromContext(ctx)
|
||||
if invite, _ := values["send_invite"].(bool); !invite {
|
||||
return nil
|
||||
}
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok {
|
||||
return errors.New("users admin: a create needs its transaction")
|
||||
}
|
||||
code, err := classes.IssueActivationCode(ctx, tx.Session(&gorm.Session{NewDB: true}), user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app := c.application()
|
||||
id, email := user.ID, user.Email
|
||||
vars := mailVars(displayName(user), activationLink(app, id, code), strconv.FormatUint(uint64(id), 10)+"!"+code)
|
||||
vars["login"], vars["Login"] = email, email
|
||||
hours := c.configInt("golem15.user.activation.activation_ttl_hours", 72)
|
||||
vars["hours"], vars["Hours"] = hours, hours
|
||||
template := mailName(ctx, user, "golem15.user::mail.invite")
|
||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||
sendTemplateVars(ctx, app, template, email, vars)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdminFieldRelations binds the organisation picker. organisation_id is a
|
||||
// protected column, so the contract opts in to writing it through this one
|
||||
// field; the submitted id is still checked against the options.
|
||||
func (usersAdminController) AdminFieldRelations() []cabana.FieldRelationContract {
|
||||
return []cabana.FieldRelationContract{
|
||||
{
|
||||
Field: "organisation",
|
||||
Kind: "belongsTo",
|
||||
NewRelated: func() any { return &models.Organisation{} },
|
||||
ForeignKey: "organisation_id",
|
||||
LabelColumn: "name",
|
||||
WritableForeignKey: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// permissionsField is the form's permission editor field.
|
||||
const permissionsField = "permissions"
|
||||
|
||||
// AdminPermissionOptions offers the rows of
|
||||
// golem15_user_frontend_permissions, by tab and code. None is locked.
|
||||
func (c usersAdminController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
|
||||
if field != permissionsField {
|
||||
return nil, fmt.Errorf("users admin: unknown permission field %q", field)
|
||||
}
|
||||
db := requestDB(ctx, c.db)
|
||||
if db == nil {
|
||||
return nil, errors.New("users admin: gorm handle is missing")
|
||||
}
|
||||
rows, err := classes.FrontendPermissionOptions(ctx, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options := make([]cabana.PermissionOption, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
option := cabana.PermissionOption{Code: row.Code, Label: row.Label, Tab: row.Tab}
|
||||
if row.Comment != nil {
|
||||
option.Comment = *row.Comment
|
||||
}
|
||||
options = append(options, option)
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// AdminPermissionValues returns the user's own stored permissions.
|
||||
func (usersAdminController) AdminPermissionValues(_ context.Context, field string, record any) (map[string]int, error) {
|
||||
user, ok := record.(*models.User)
|
||||
if field != permissionsField || !ok || user == nil {
|
||||
return nil, fmt.Errorf("users admin: cannot read permission field %q", field)
|
||||
}
|
||||
values := make(map[string]int, len(user.Permissions))
|
||||
for code, value := range user.Permissions {
|
||||
values[code] = value
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// AdminSetPermissionValues puts the validated set on the user; the save
|
||||
// writes the row. Zero values are left out on write (PermissionSet.Value).
|
||||
func (usersAdminController) AdminSetPermissionValues(_ context.Context, field string, record any, values map[string]int) error {
|
||||
user, ok := record.(*models.User)
|
||||
if field != permissionsField || !ok || user == nil {
|
||||
return fmt.Errorf("users admin: cannot write permission field %q", field)
|
||||
}
|
||||
set := make(models.PermissionSet, len(values))
|
||||
for code, value := range values {
|
||||
set[code] = value
|
||||
}
|
||||
user.Permissions = set
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user