fix: an email change drops the address proof

- /update clears email_verified_at in the same write when the email changes (case-only changes keep it)
- the admin Users form clears it in FormBeforeUpdate; storedEmail helper shared with guardCredentials
- README: the proof follows the address, never mass-assigned, no backfill
This commit is contained in:
Jakub Zych
2026-10-21 14:01:52 +02:00
parent add1f7a806
commit a99cb467b7
3 changed files with 48 additions and 14 deletions

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
@@ -211,7 +212,8 @@ func (c usersAdminController) guardPrivilegedMember(ctx context.Context, userID
// FormBeforeUpdate starts with the takeover guard: a changed email or a
// submitted password on a member of a privileged group needs
// golem15.users.manage_privileged_groups. Everything else an update does
// comes after it.
// comes after it, including clearing the proof of the address
// (email_verified_at) when the email changes.
func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
user, ok := model.(*models.User)
if !ok || user == nil {
@@ -223,6 +225,16 @@ func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) e
if err := c.requireFreeEmail(ctx, user); err != nil {
return err
}
// The proof of the address follows the address (D-27): a changed email
// is unproven until its owner proves it; a change of letter case only
// keeps it. cabana's Save writes the cleared column.
stored, err := c.storedEmail(ctx, user)
if err != nil {
return err
}
if !strings.EqualFold(stored, user.Email) {
user.EmailVerifiedAt = nil
}
changed, err := c.storePassword(ctx, user)
if err != nil {
return err
@@ -462,26 +474,36 @@ func (usersAdminController) AdminSetPermissionValues(_ context.Context, field st
return nil
}
// guardCredentials compares the email the save would write with the stored
// one and looks for a submitted password, then asks guardPrivilegedMember
// for the fields that would change.
func (c usersAdminController) guardCredentials(ctx context.Context, user *models.User) error {
// storedEmail reads the stored email of the user being updated, before the
// form's values are saved.
func (c usersAdminController) storedEmail(ctx context.Context, user *models.User) (string, error) {
db := requestDB(ctx, c.db)
if db == nil {
return errors.New("users admin: gorm handle is missing")
return "", errors.New("users admin: gorm handle is missing")
}
var stored []string
err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
Model(&models.User{}).Where("id = ?", user.ID).Pluck("email", &stored).Error
if err != nil {
return err
return "", err
}
if len(stored) != 1 {
return errors.New("users admin: the user being updated was not found")
return "", errors.New("users admin: the user being updated was not found")
}
return stored[0], nil
}
// guardCredentials compares the email the save would write with the stored
// one and looks for a submitted password, then asks guardPrivilegedMember
// for the fields that would change.
func (c usersAdminController) guardCredentials(ctx context.Context, user *models.User) error {
stored, err := c.storedEmail(ctx, user)
if err != nil {
return err
}
const message = usersLang + "privileged_member_forbidden"
details := map[string]any{}
if stored[0] != user.Email {
if stored != user.Email {
details["email"] = []string{message}
}
if values, ok := cabana.VirtualFieldsFromContext(ctx); ok {