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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user