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