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:
Jakub Zych
2026-10-05 13:21:51 +02:00
parent cdea47a178
commit f7ca3c4f59
12 changed files with 1080 additions and 16 deletions

View File

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