feat(19.6.1-01): administrator form with server-side privilege rules
- create and edit administrators: password (min 4, max 72 bytes, bcrypt at admin.password.bcrypt_cost), role picker, radio permission overrides, superuser switch; no mail on create - superusers hidden from non-superusers in list, lookups and schemas; is_superuser projected to superusers so an untouched save keeps it - save-path rules: superuser-only switch, role and permission ceilings, no outranking, no own role/flag/overrides/password change, last active superuser not demoted (advisory lock + count in the write tx) - password set for another admin bumps tokens_valid_after - role, superuser, activation and last sign-in list filters
This commit is contained in:
@@ -2,11 +2,17 @@ package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -19,11 +25,23 @@ type usersAdmin struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ pact.AdminController = usersAdmin{}
|
||||
_ pact.AdminPermissioned = usersAdmin{}
|
||||
_ pact.AdminRecordSource = usersAdmin{}
|
||||
_ pact.FormRecordTitle = usersAdmin{}
|
||||
_ FieldRelationProvider = usersAdmin{}
|
||||
_ pact.AdminController = usersAdmin{}
|
||||
_ pact.AdminPermissioned = usersAdmin{}
|
||||
_ pact.AdminRecordSource = usersAdmin{}
|
||||
_ pact.FormRecordTitle = usersAdmin{}
|
||||
_ FieldRelationProvider = usersAdmin{}
|
||||
_ pact.ListExtendQuery = usersAdmin{}
|
||||
_ pact.FormExtendQuery = usersAdmin{}
|
||||
_ pact.FormVirtualFields = usersAdmin{}
|
||||
_ pact.FormRules = usersAdmin{}
|
||||
_ pact.FilterOptions = usersAdmin{}
|
||||
_ pact.FormBeforeCreate = usersAdmin{}
|
||||
_ pact.FormBeforeUpdate = usersAdmin{}
|
||||
_ pact.FormAfterUpdate = usersAdmin{}
|
||||
_ RelationLockProvider = usersAdmin{}
|
||||
_ PermissionEditorProvider = usersAdmin{}
|
||||
_ recordDataExtender = usersAdmin{}
|
||||
_ schemaFieldHider = usersAdmin{}
|
||||
)
|
||||
|
||||
func (usersAdmin) ID() string { return AdministratorsControllerID }
|
||||
@@ -68,3 +86,526 @@ func (usersAdmin) AdminFieldRelations() []FieldRelationContract {
|
||||
WritableForeignKey: true,
|
||||
}}
|
||||
}
|
||||
|
||||
// usersLang prefixes the phrase keys of the Administrators screen.
|
||||
const usersLang = "backend::lang.user."
|
||||
|
||||
// The Administrators form's protected fields.
|
||||
const (
|
||||
usersRoleField = "role"
|
||||
usersPermissionsField = "permissions"
|
||||
usersSuperuserField = "is_superuser"
|
||||
)
|
||||
|
||||
// db is the application pool, or nil without one.
|
||||
func (c usersAdmin) db() *gorm.DB {
|
||||
app := c.application()
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
gdb, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return gdb
|
||||
}
|
||||
|
||||
// requestDB is the write transaction on ctx, else the application pool.
|
||||
func (c usersAdmin) requestDB(ctx context.Context) *gorm.DB {
|
||||
if tx, ok := TxFromContext(ctx); ok {
|
||||
return tx
|
||||
}
|
||||
return c.db()
|
||||
}
|
||||
|
||||
// translator is the application's translator, or nil.
|
||||
func (c usersAdmin) translator() *phrasebook.Translator {
|
||||
app := c.application()
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
tr, _ := app.Lookup[*phrasebook.Translator]()
|
||||
return tr
|
||||
}
|
||||
|
||||
// asBackendUser asserts a hook's model.
|
||||
func asBackendUser(model any) (*BackendUser, error) {
|
||||
user, ok := model.(*BackendUser)
|
||||
if !ok || user == nil {
|
||||
return nil, errors.New("cabana: administrators: record is not a backend user")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ListExtendQuery hides superusers from an administrator who does not act
|
||||
// as a superuser (D-03): they are never listed, and a bulk selection never
|
||||
// resolves to one.
|
||||
func (usersAdmin) ListExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB {
|
||||
return scopeSuperusers(ctx, db)
|
||||
}
|
||||
|
||||
// FormExtendQuery applies the same scope to every record lookup, so a
|
||||
// superuser's id is not found (404) for anyone else: show, the preview
|
||||
// partial, update, delete and record actions.
|
||||
func (usersAdmin) FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB {
|
||||
return scopeSuperusers(ctx, db)
|
||||
}
|
||||
|
||||
func scopeSuperusers(ctx context.Context, db *gorm.DB) *gorm.DB {
|
||||
if db == nil {
|
||||
return db
|
||||
}
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if actsAsSuperuser(principal) {
|
||||
return db
|
||||
}
|
||||
return db.Where("backend_users.is_superuser = ?", false)
|
||||
}
|
||||
|
||||
// extendRecordData sends the stored superuser flag to a superuser. The
|
||||
// is_superuser switch is a virtual field (the column is protected), and the
|
||||
// admin sends every field on save: without its stored value an untouched
|
||||
// save would demote a superuser.
|
||||
func (usersAdmin) extendRecordData(ctx context.Context, record any, data map[string]any) {
|
||||
user, ok := record.(*BackendUser)
|
||||
if !ok || user == nil {
|
||||
return
|
||||
}
|
||||
if principal, _ := bouncer.User(ctx); actsAsSuperuser(principal) {
|
||||
data[usersSuperuserField] = user.IsSuperuser
|
||||
}
|
||||
}
|
||||
|
||||
// hiddenSchemaFields removes the superuser field, column and filter for an
|
||||
// administrator who does not act as a superuser (D-03).
|
||||
func (usersAdmin) hiddenSchemaFields(ctx context.Context) map[string]bool {
|
||||
if principal, _ := bouncer.User(ctx); actsAsSuperuser(principal) {
|
||||
return nil
|
||||
}
|
||||
return map[string]bool{usersSuperuserField: true}
|
||||
}
|
||||
|
||||
// FormVirtualFields are the password, its confirmation and the superuser
|
||||
// switch: none is filled into the model; the Form hooks write them after
|
||||
// their checks.
|
||||
func (usersAdmin) FormVirtualFields() []string {
|
||||
return []string{"password", "password_confirmation", usersSuperuserField}
|
||||
}
|
||||
|
||||
// FormRules require a confirmed password of at least 4 characters on create
|
||||
// and take one on update only when it is typed (D-11). Login and email
|
||||
// uniqueness is checked by checkIdentity, deleted administrators included.
|
||||
func (usersAdmin) FormRules(_ context.Context, op string) map[string]string {
|
||||
password := "nullable|min:4|confirmed"
|
||||
if op == "create" {
|
||||
password = "required|min:4|confirmed"
|
||||
}
|
||||
return map[string]string{
|
||||
"login": "required|max:255",
|
||||
"email": "required|email|max:255",
|
||||
"password": password,
|
||||
}
|
||||
}
|
||||
|
||||
// FilterOptions serves the role filter's choices: every role by name.
|
||||
func (c usersAdmin) FilterOptions(scope string) []pact.Option {
|
||||
if scope != filterByRole {
|
||||
return nil
|
||||
}
|
||||
db := c.db()
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
var roles []BackendUserRole
|
||||
if err := db.Session(&gorm.Session{NewDB: true}).Order("name ASC, id ASC").Find(&roles).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]pact.Option, 0, len(roles))
|
||||
for _, role := range roles {
|
||||
out = append(out, pact.Option{Value: strconv.FormatUint(uint64(role.ID), 10), Label: role.Name})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AdminRelationLocks locks, for an administrator who does not act as a
|
||||
// superuser, every role whose grants (stored and code-declared) the
|
||||
// administrator does not hold themselves (D-07): it cannot be assigned, and
|
||||
// an administrator who holds one keeps it unchanged.
|
||||
func (c usersAdmin) AdminRelationLocks(ctx context.Context, field string) (RelationLock, error) {
|
||||
if field != usersRoleField {
|
||||
return RelationLock{}, nil
|
||||
}
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if actsAsSuperuser(principal) {
|
||||
return RelationLock{}, nil
|
||||
}
|
||||
db := c.requestDB(ctx)
|
||||
if db == nil {
|
||||
return RelationLock{}, errors.New("cabana: administrators: database handle is missing")
|
||||
}
|
||||
var roles []BackendUserRole
|
||||
if err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Order("id ASC").Find(&roles).Error; err != nil {
|
||||
return RelationLock{}, err
|
||||
}
|
||||
var held map[string]bool
|
||||
if principal != nil {
|
||||
held = principal.PermissionGrants
|
||||
}
|
||||
var ids []uint
|
||||
for _, role := range roles {
|
||||
if !grantsCovered(held, roleGrantSet(c.registry(), role)) {
|
||||
ids = append(ids, role.ID)
|
||||
}
|
||||
}
|
||||
return RelationLock{IDs: ids, Message: usersLang + "role_ceiling"}, nil
|
||||
}
|
||||
|
||||
// permissionComments are the editor comments of the admin's own permissions.
|
||||
var permissionComments = map[string]string{
|
||||
PermissionManageUsers: "backend::lang.permissions.manage_users_comment",
|
||||
PermissionManageRoles: "backend::lang.permissions.manage_roles_comment",
|
||||
}
|
||||
|
||||
// catalogOptions lists the permission catalog in registration order; a
|
||||
// code the administrator on ctx may not grant is locked (D-07).
|
||||
func catalogOptions(ctx context.Context, reg *Registry) []PermissionOption {
|
||||
principal, _ := bouncer.User(ctx)
|
||||
superuser := actsAsSuperuser(principal)
|
||||
var held map[string]bool
|
||||
if principal != nil {
|
||||
held = principal.PermissionGrants
|
||||
}
|
||||
if reg == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]PermissionOption, 0, len(reg.permissionOrder))
|
||||
for _, code := range reg.permissionOrder {
|
||||
permission := reg.permissions[code]
|
||||
label := permission.Label
|
||||
if label == "" {
|
||||
label = code
|
||||
}
|
||||
out = append(out, PermissionOption{
|
||||
Code: code,
|
||||
Label: label,
|
||||
Tab: permission.Tab,
|
||||
Comment: permissionComments[code],
|
||||
Locked: !superuser && !granted(held, code),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AdminPermissionOptions offers the whole permission catalog on the
|
||||
// per-administrator override editor (mode radio).
|
||||
func (c usersAdmin) AdminPermissionOptions(ctx context.Context, field string) ([]PermissionOption, error) {
|
||||
if field != usersPermissionsField {
|
||||
return nil, fmt.Errorf("cabana: administrators: unknown permission field %q", field)
|
||||
}
|
||||
return catalogOptions(ctx, c.registry()), nil
|
||||
}
|
||||
|
||||
// AdminPermissionValues decodes the administrator's stored overrides: 1
|
||||
// (allow) and -1 (deny); any other stored value is left out.
|
||||
func (usersAdmin) AdminPermissionValues(_ context.Context, field string, record any) (map[string]int, error) {
|
||||
user, ok := record.(*BackendUser)
|
||||
if field != usersPermissionsField || !ok || user == nil {
|
||||
return nil, fmt.Errorf("cabana: administrators: cannot read permission field %q", field)
|
||||
}
|
||||
return decodePermissionValues(user.Permissions, true), nil
|
||||
}
|
||||
|
||||
// AdminSetPermissionValues stores the override set on the record; the save
|
||||
// writes the row. On the editor's own record any change is refused (D-08).
|
||||
func (usersAdmin) AdminSetPermissionValues(ctx context.Context, field string, record any, values map[string]int) error {
|
||||
user, ok := record.(*BackendUser)
|
||||
if field != usersPermissionsField || !ok || user == nil {
|
||||
return fmt.Errorf("cabana: administrators: cannot write permission field %q", field)
|
||||
}
|
||||
if principal, _ := bouncer.User(ctx); principal != nil && user.ID != 0 && principal.ID == user.ID {
|
||||
if !samePermissionValues(decodePermissionValues(user.Permissions, true), values) {
|
||||
return &ForbiddenError{Message: usersLang + "self_privileged", Details: map[string]any{usersPermissionsField: []string{usersLang + "self_privileged"}}}
|
||||
}
|
||||
}
|
||||
encoded, err := encodePermissionValues(values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.Permissions = encoded
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodePermissionValues reads a stored permissions JSON object into code
|
||||
// to value: 1 for a grant and, when deny is set, -1 for a deny. Malformed
|
||||
// JSON and any other value read as absent.
|
||||
func decodePermissionValues(raw string, deny bool) map[string]int {
|
||||
out := map[string]int{}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "null" {
|
||||
return out
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
|
||||
return out
|
||||
}
|
||||
for code, value := range decoded {
|
||||
switch {
|
||||
case truthyGrant(value):
|
||||
out[code] = 1
|
||||
case deny && denyGrant(value):
|
||||
out[code] = -1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// denyGrant reports a stored -1.
|
||||
func denyGrant(value any) bool {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
return v == -1
|
||||
case string:
|
||||
return strings.TrimSpace(v) == "-1"
|
||||
case json.Number:
|
||||
return v.String() == "-1"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func samePermissionValues(a, b map[string]int) bool {
|
||||
count := 0
|
||||
for code, n := range b {
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
if a[code] != n {
|
||||
return false
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count == len(a)
|
||||
}
|
||||
|
||||
// encodePermissionValues is the compact JSON object of the non-zero values,
|
||||
// "{}" when there are none.
|
||||
func encodePermissionValues(values map[string]int) (string, error) {
|
||||
out := make(map[string]int, len(values))
|
||||
for code, n := range values {
|
||||
if n != 0 {
|
||||
out[code] = n
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// toggleOn reads a switch value as the admin sends it.
|
||||
func toggleOn(value any) bool {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
return v == "1" || strings.EqualFold(v, "true")
|
||||
case json.Number:
|
||||
return v.String() == "1"
|
||||
case float64:
|
||||
return v == 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FormBeforeCreate checks and writes the protected values of a new
|
||||
// administrator: only a superuser may send the superuser switch, whatever
|
||||
// its value (D-05); the account starts activated, as admin:create creates
|
||||
// it; the password is hashed at admin.password.bcrypt_cost (D-11); and the
|
||||
// login and email must be free. No mail is sent (D-13).
|
||||
func (c usersAdmin) FormBeforeCreate(ctx context.Context, model any) error {
|
||||
user, err := asBackendUser(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
principal, _ := bouncer.User(ctx)
|
||||
values, _ := VirtualFieldsFromContext(ctx)
|
||||
if value, sent := values[usersSuperuserField]; sent {
|
||||
if !actsAsSuperuser(principal) {
|
||||
return &ForbiddenError{Message: usersLang + "superuser_forbidden", Details: map[string]any{usersSuperuserField: []string{usersLang + "superuser_forbidden"}}}
|
||||
}
|
||||
user.IsSuperuser = toggleOn(value)
|
||||
} else {
|
||||
user.IsSuperuser = false
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
user.IsActivated = true
|
||||
user.ActivatedAt = &now
|
||||
if _, err := c.storePassword(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.checkIdentity(ctx, user)
|
||||
}
|
||||
|
||||
// FormBeforeUpdate runs before the submitted role and permissions are
|
||||
// applied, while the model still holds the stored role, flag and overrides
|
||||
// (protected columns are never filled). It snapshots the role for
|
||||
// FormAfterUpdate, refuses a change to an administrator who outranks the
|
||||
// editor (D-19), checks the superuser switch (D-05, D-08, D-09), hashes a
|
||||
// typed password for another administrator and ends their sessions (D-11,
|
||||
// D-12; their own password is changed in My account, D-20), and checks the
|
||||
// login and email.
|
||||
func (c usersAdmin) FormBeforeUpdate(ctx context.Context, model any) error {
|
||||
user, err := asBackendUser(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
principal, _ := bouncer.User(ctx)
|
||||
self := principal != nil && principal.ID == user.ID
|
||||
if user.RoleID != nil {
|
||||
prior := *user.RoleID
|
||||
user.priorRoleID = &prior
|
||||
} else {
|
||||
user.priorRoleID = nil
|
||||
}
|
||||
user.priorLoaded = true
|
||||
if !self {
|
||||
if err := guardOutranked(ctx, c.registry(), principal, user); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
values, _ := VirtualFieldsFromContext(ctx)
|
||||
if value, sent := values[usersSuperuserField]; sent {
|
||||
superuserDetails := func(key string) map[string]any {
|
||||
return map[string]any{usersSuperuserField: []string{key}}
|
||||
}
|
||||
if !actsAsSuperuser(principal) {
|
||||
return &ForbiddenError{Message: usersLang + "superuser_forbidden", Details: superuserDetails(usersLang + "superuser_forbidden")}
|
||||
}
|
||||
want := toggleOn(value)
|
||||
if self && want != user.IsSuperuser {
|
||||
return &ForbiddenError{Message: usersLang + "self_privileged", Details: superuserDetails(usersLang + "self_privileged")}
|
||||
}
|
||||
if user.IsSuperuser && !want {
|
||||
if err := guardLastSuperuser(ctx, user); err != nil {
|
||||
var refused *ForbiddenError
|
||||
if errors.As(err, &refused) {
|
||||
return &ForbiddenError{Message: refused.Message, Details: superuserDetails(refused.Message)}
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
user.IsSuperuser = want
|
||||
}
|
||||
if plain := submittedPassword(ctx); plain != "" && self {
|
||||
return &ForbiddenError{Message: usersLang + "own_password", Details: map[string]any{"password": []string{usersLang + "own_password"}}}
|
||||
}
|
||||
changed, err := c.storePassword(ctx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
cutoff := revocationCutoff()
|
||||
user.TokensValidAfter = &cutoff
|
||||
}
|
||||
return c.checkIdentity(ctx, user)
|
||||
}
|
||||
|
||||
// FormAfterUpdate refuses a change of the editor's own role (D-08). The
|
||||
// submitted role is assigned after FormBeforeUpdate, so it is compared here,
|
||||
// with the row written: the refusal rolls the save back.
|
||||
func (c usersAdmin) FormAfterUpdate(ctx context.Context, model any) error {
|
||||
user, err := asBackendUser(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if !user.priorLoaded || principal == nil || principal.ID != user.ID {
|
||||
return nil
|
||||
}
|
||||
if !sameRoleID(user.priorRoleID, user.RoleID) {
|
||||
return &ForbiddenError{Message: usersLang + "self_privileged", Details: map[string]any{usersRoleField: []string{usersLang + "self_privileged"}}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameRoleID(a, b *uint) bool {
|
||||
switch {
|
||||
case a == nil || *a == 0:
|
||||
return b == nil || *b == 0
|
||||
case b == nil:
|
||||
return false
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
// submittedPassword is the password typed on the form, "" when none.
|
||||
func submittedPassword(ctx context.Context) string {
|
||||
values, _ := VirtualFieldsFromContext(ctx)
|
||||
plain, _ := values["password"].(string)
|
||||
return plain
|
||||
}
|
||||
|
||||
// storePassword hashes a typed password onto user and reports whether one
|
||||
// was typed. A password over bcrypt's 72-byte limit is a 422 on the field.
|
||||
func (c usersAdmin) storePassword(ctx context.Context, user *BackendUser) (bool, error) {
|
||||
plain := submittedPassword(ctx)
|
||||
if plain == "" {
|
||||
return false, nil
|
||||
}
|
||||
if len(plain) > passwordMaxBytes {
|
||||
message := accountMessage(ctx, c.translator(), "max.string", "password", map[string]string{"max": strconv.Itoa(passwordMaxBytes)})
|
||||
return false, &ValidationError{Details: map[string]any{"password": []string{message}}}
|
||||
}
|
||||
hash, err := bouncer.HashPassword(adminBcryptCost(c.application()), plain)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
user.Password = hash
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// checkIdentity refuses a login or email another administrator already
|
||||
// uses, in either field and ignoring case, deleted administrators included
|
||||
// (the database keeps their values unique), as admin:create does. A value
|
||||
// only a deleted administrator holds says so, so the editor can restore
|
||||
// that account instead.
|
||||
func (c usersAdmin) checkIdentity(ctx context.Context, user *BackendUser) error {
|
||||
db := c.requestDB(ctx)
|
||||
if db == nil {
|
||||
return errors.New("cabana: administrators: database handle is missing")
|
||||
}
|
||||
login := strings.ToLower(strings.TrimSpace(user.Login))
|
||||
email := strings.ToLower(strings.TrimSpace(user.Email))
|
||||
var holders []BackendUser
|
||||
err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
|
||||
Select("id", "login", "email", "deleted_at").
|
||||
Where("(lower(login) IN (?, ?) OR lower(email) IN (?, ?)) AND id <> ?", login, email, login, email, user.ID).
|
||||
Find(&holders).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
details := map[string]any{}
|
||||
for _, field := range []struct{ name, value string }{{"login", login}, {"email", email}} {
|
||||
if field.value == "" {
|
||||
continue
|
||||
}
|
||||
held, active := false, false
|
||||
for _, holder := range holders {
|
||||
if strings.ToLower(holder.Login) == field.value || strings.ToLower(holder.Email) == field.value {
|
||||
held = true
|
||||
active = active || !holder.DeletedAt.Valid
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !held:
|
||||
case active:
|
||||
details[field.name] = []string{usersLang + field.name + "_taken"}
|
||||
default:
|
||||
details[field.name] = []string{usersLang + field.name + "_taken_deleted"}
|
||||
}
|
||||
}
|
||||
if len(details) > 0 {
|
||||
return &ValidationError{Details: details}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user