feat(07-03): update profile, change password, and marketing consent

Changing a password invalidates older tokens and leaves the token that made the call signed in.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-09-22 16:32:05 +02:00
parent cb50d673c6
commit 5c1f857d54
3 changed files with 318 additions and 0 deletions

View File

@@ -384,6 +384,201 @@ func ActivateByCode(app *backpack.App) http.HandlerFunc {
}
}
// Update changes the signed-in user's name, surname, and email.
func Update(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, _, ok := sessionDeps(w, app)
if !ok {
return
}
user, ok := authenticate(w, r, app)
if !ok {
return
}
fields, err := readFields(r)
if err != nil {
writeOpaque500(w)
return
}
errs, err := lagoon.Validate(r.Context(), gdb, user, map[string]string{
"name": "required|between:2,255",
"surname": "required|between:2,255",
"email": "required|between:6,255|email|unique:users",
}, fields, nil)
if err != nil {
writeOpaque500(w)
return
}
if len(errs) > 0 {
writeValidation(w, errs)
return
}
name := strField(fields, "name")
surname := strField(fields, "surname")
if err := gdb.WithContext(r.Context()).Model(user).Updates(map[string]any{
"name": name,
"surname": surname,
"email": strField(fields, "email"),
}).Error; err != nil {
writeOpaque500(w)
return
}
user.Name = &name
user.Surname = &surname
user.Email = strField(fields, "email")
payload, err := apiArray(r.Context(), app, user)
if err != nil {
writeOpaque500(w)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Profile updated", "user": payload})
}
}
// ChangePassword replaces a self-set password and invalidates older tokens.
// The token that made this call stays valid.
func ChangePassword(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, secret, ok := sessionDeps(w, app)
if !ok {
return
}
raw := bearerToken(r)
sub, iat, _, _, err := bouncer.VerifyClaims(raw, secret)
if err != nil {
writeUnauthorized(w)
return
}
var bl bouncer.BlacklistStore
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
bl = store
}
if _, err := bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl).Authenticate(r); err != nil {
writeUnauthorized(w)
return
}
id, err := strconv.ParseUint(sub, 10, 64)
if err != nil || id == 0 {
writeUnauthorized(w)
return
}
var user models.User
if err := gdb.WithContext(r.Context()).Where("id = ?", id).Take(&user).Error; err != nil {
writeUnauthorized(w)
return
}
if !user.HasSelfSetPassword {
slog.Default().Error("golem15.user: change-password requires a self-set password", "user_id", user.ID)
writeOpaque500(w)
return
}
fields, err := readFields(r)
if err != nil {
writeOpaque500(w)
return
}
minLen := 8
if app.Config != nil && app.Config.Int("golem15.user.password.min_length") > 0 {
minLen = app.Config.Int("golem15.user.password.min_length")
}
errs, err := lagoon.Validate(r.Context(), gdb, &user, map[string]string{
"current_password": "required",
"password": "required|between:" + strconv.Itoa(minLen) + ",255|confirmed|different:current_password",
}, fields, nil)
if err != nil {
writeOpaque500(w)
return
}
if len(errs) > 0 {
writeValidation(w, errs)
return
}
current := strField(fields, "current_password")
if !bouncer.CheckPassword(user.Password, current) {
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{
"error": "Invalid email or password",
"errors": map[string][]string{
"current_password": {"Invalid email or password"},
},
})
return
}
cost := 10
if app.Config != nil && app.Config.Int("golem15.user.password.bcrypt_cost") > 0 {
cost = app.Config.Int("golem15.user.password.bcrypt_cost")
}
next := strField(fields, "password")
hashed, err := bouncer.HashPassword(cost, next)
if err != nil {
writeOpaque500(w)
return
}
cutoff := iat.Add(-time.Second)
if err := gdb.WithContext(r.Context()).Model(&user).Updates(map[string]any{
"password": hashed,
"must_change_password": false,
"has_self_set_password": true,
"tokens_valid_after": cutoff,
}).Error; err != nil {
writeOpaque500(w)
return
}
user.MustChangePassword = false
user.HasSelfSetPassword = true
user.TokensValidAfter = &cutoff
payload, err := apiArray(r.Context(), app, &user)
if err != nil {
writeOpaque500(w)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Password changed", "user": payload})
}
}
// MarketingConsent sets the signed-in user's marketing_consent flag.
func MarketingConsent(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, _, ok := sessionDeps(w, app)
if !ok {
return
}
user, ok := authenticate(w, r, app)
if !ok {
return
}
fields, err := readFields(r)
if err != nil {
writeOpaque500(w)
return
}
raw, exists := fields["marketing_consent"]
if !exists || raw == nil {
writeValidation(w, map[string][]string{
"marketing_consent": {"The marketing_consent field is required."},
})
return
}
consent, isBool := raw.(bool)
if !isBool {
writeValidation(w, map[string][]string{
"marketing_consent": {"The marketing_consent field must be true or false."},
})
return
}
if err := gdb.WithContext(r.Context()).Model(user).Update("marketing_consent", consent).Error; err != nil {
writeOpaque500(w)
return
}
user.MarketingConsent = consent
payload, err := apiArray(r.Context(), app, user)
if err != nil {
writeOpaque500(w)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Marketing consent updated", "user": payload})
}
}
// Register creates an account and follows activate_mode: auto, user, or admin.
func Register(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -443,6 +638,7 @@ func Register(app *backpack.App) http.HandlerFunc {
writeOpaque500(w)
return
}
user.HasSelfSetPassword = true
if mode == "auto" {
user.IsActivated = true
now := time.Now()