refactor(user): extract shared account orchestration into models/user/shared for v1+v2

Pull the business logic out of the v1 current-user account/settings handlers
into reusable functions so both v1 and the upcoming v2 handlers call one
implementation. No behavior change — the v1 handlers keep their HTTP-layer
quirks (input binding, validation, error mapping); only orchestration moves.

Homes are forced by the import graph:
- shared.GetAuthProviderName  (new pkg/routes/api/shared, above openid+user so it
                              can combine both without a cycle; routes-only helper)
- user.ChangeUserEmail        (CheckUserCredentials + UpdateEmail, both in user)
- models.ChangeUserPassword   (needs models.DeleteAllUserSessions; user can't import models)
- models.UpdateUserGeneralSettings / UpdateUserAvatarProvider
                              (need avatar.FlushAllCaches; user can't import avatar)

The general settings get a single shared wire struct, models.UserGeneralSettings
(tagged for both swaggo/govalidator and Huma): it is the update request body and
the nested settings on GET /user for v1 (replacing v1's UserSettings) and v2.
ExtraSettingsLinks is readOnly — populated from the user on read, ignored on
write. A dedicated struct is required because user.User's settings fields are
json:"-" so they don't leak when it is embedded in other responses.
This commit is contained in:
kolaente 2026-06-10 22:16:53 +02:00 committed by kolaente
parent 154a96674d
commit 46b07a019c
8 changed files with 212 additions and 143 deletions

View File

@ -145,6 +145,10 @@ linters:
- revive
path: pkg/utils/*
text: 'var-naming: avoid meaningless package names'
- linters:
- revive
path: pkg/routes/api/shared/*
text: 'var-naming: avoid meaningless package names'
- linters:
- revive
text: 'var-naming: avoid package names that conflict with Go standard library package names'

128
pkg/models/user_settings.go Normal file
View File

@ -0,0 +1,128 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"code.vikunja.io/api/pkg/modules/avatar"
"code.vikunja.io/api/pkg/user"
"xorm.io/xorm"
)
// UserGeneralSettings is the single user-settings wire struct shared by v1 and
// v2 — both the update request body and the nested settings on GET /user. A
// dedicated struct (not user.User) is required: user.User's settings fields are
// json:"-" so they don't leak when it is embedded in other responses
// (assignees, created_by, members …).
type UserGeneralSettings struct {
Name string `json:"name" doc:"The full name of the user."`
EmailRemindersEnabled bool `json:"email_reminders_enabled" doc:"If enabled, sends email reminders of tasks to the user."`
DiscoverableByName bool `json:"discoverable_by_name" doc:"If true, this user can be found by their name or parts of it when searching."`
DiscoverableByEmail bool `json:"discoverable_by_email" doc:"If true, the user can be found when searching for their exact email."`
OverdueTasksRemindersEnabled bool `json:"overdue_tasks_reminders_enabled" doc:"If enabled, the user gets an email for their overdue tasks each morning."`
OverdueTasksRemindersTime string `json:"overdue_tasks_reminders_time" valid:"time,required" doc:"The time the daily overdue-tasks summary is sent, as HH:MM."`
DefaultProjectID int64 `json:"default_project_id" doc:"Project a task is filed under when created without an explicit project."`
WeekStart int `json:"week_start" valid:"range(0|6)" minimum:"0" maximum:"6" doc:"The day the week starts on: 0=sunday, 1=monday, … 6=saturday."`
Language string `json:"language" doc:"The user's language."`
Timezone string `json:"timezone" doc:"The user's time zone, used to send task reminders in their local time."`
FrontendSettings any `json:"frontend_settings" doc:"Arbitrary settings used only by the frontend. Any JSON value; stored and returned verbatim."`
// Server/OpenID-provided; populated on read, ignored on write.
ExtraSettingsLinks map[string]any `json:"extra_settings_links" readOnly:"true" doc:"Additional settings links provided by the OpenID provider. Server-controlled."`
}
// NewUserGeneralSettings projects a user's stored settings into the shared wire
// struct for GET /user. Used by both the v1 and v2 user-show handlers.
func NewUserGeneralSettings(u *user.User) *UserGeneralSettings {
return &UserGeneralSettings{
Name: u.Name,
EmailRemindersEnabled: u.EmailRemindersEnabled,
DiscoverableByName: u.DiscoverableByName,
DiscoverableByEmail: u.DiscoverableByEmail,
OverdueTasksRemindersEnabled: u.OverdueTasksRemindersEnabled,
OverdueTasksRemindersTime: u.OverdueTasksRemindersTime,
DefaultProjectID: u.DefaultProjectID,
WeekStart: u.WeekStart,
Language: u.Language,
Timezone: u.Timezone,
FrontendSettings: u.FrontendSettings,
ExtraSettingsLinks: u.ExtraSettingsLinks,
}
}
// ChangeUserPassword verifies the old password, sets the new one, and
// invalidates all of the user's sessions. Lives here (not in pkg/user) because
// it needs DeleteAllUserSessions, which pkg/user cannot import.
func ChangeUserPassword(s *xorm.Session, u *user.User, oldPassword, newPassword string) error {
if oldPassword == "" {
return user.ErrEmptyOldPassword{}
}
if _, err := user.CheckUserCredentials(s, &user.Login{Username: u.Username, Password: oldPassword}); err != nil {
return err
}
if err := user.UpdateUserPassword(s, u, newPassword); err != nil {
return err
}
return DeleteAllUserSessions(s, u.ID)
}
// UpdateUserGeneralSettings copies the general settings onto the user, persists
// them, and flushes the avatar cache when an initials avatar's name changed.
// Lives here (not in pkg/user) because the avatar flush needs pkg/modules/avatar,
// which pkg/user cannot import.
func UpdateUserGeneralSettings(s *xorm.Session, u *user.User, settings *UserGeneralSettings) error {
invalidateAvatar := u.AvatarProvider == "initials" && u.Name != settings.Name
u.Name = settings.Name
u.EmailRemindersEnabled = settings.EmailRemindersEnabled
u.DiscoverableByEmail = settings.DiscoverableByEmail
u.DiscoverableByName = settings.DiscoverableByName
u.OverdueTasksRemindersEnabled = settings.OverdueTasksRemindersEnabled
u.DefaultProjectID = settings.DefaultProjectID
u.WeekStart = settings.WeekStart
u.Language = settings.Language
u.Timezone = settings.Timezone
u.OverdueTasksRemindersTime = settings.OverdueTasksRemindersTime
u.FrontendSettings = settings.FrontendSettings
if _, err := user.UpdateUser(s, u, true); err != nil {
return err
}
if invalidateAvatar {
avatar.FlushAllCaches(u)
}
return nil
}
// UpdateUserAvatarProvider sets the user's avatar provider, persists it, and
// flushes the avatar cache when the provider changes (or is set to initials).
func UpdateUserAvatarProvider(s *xorm.Session, u *user.User, provider string) error {
oldProvider := u.AvatarProvider
u.AvatarProvider = provider
if _, err := user.UpdateUser(s, u, false); err != nil {
return err
}
if u.AvatarProvider == "initials" || oldProvider != u.AvatarProvider {
avatar.FlushAllCaches(u)
}
return nil
}

View File

@ -0,0 +1,54 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package shared holds helpers used by both the v1 and v2 route packages. It
// sits above the auth/user modules in the import graph, so it can combine them
// without creating a cycle.
package shared
import (
"code.vikunja.io/api/pkg/modules/auth/openid"
"code.vikunja.io/api/pkg/user"
)
// GetAuthProviderName resolves the human-readable name of the source a user
// authenticated with: "local"/"ldap" for those issuers, otherwise the
// configured OpenID provider whose issuer URL matches the user's. Returns ""
// when no provider matches.
func GetAuthProviderName(u *user.User) (string, error) {
switch u.Issuer {
case user.IssuerLocal:
return "local", nil
case user.IssuerLDAP:
return "ldap", nil
}
providers, err := openid.GetAllProviders()
if err != nil {
return "", err
}
for _, provider := range providers {
issuerURL, err := provider.Issuer()
if err != nil {
return "", err
}
if issuerURL == u.Issuer {
return provider.Name, nil
}
}
return "", nil
}

View File

@ -26,7 +26,6 @@ import (
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/avatar"
user2 "code.vikunja.io/api/pkg/user"
)
@ -36,35 +35,6 @@ type UserAvatarProvider struct {
AvatarProvider string `json:"avatar_provider"`
}
// UserSettings holds all user settings
type UserSettings struct {
// The new name of the current user.
Name string `json:"name"`
// If enabled, sends email reminders of tasks to the user.
EmailRemindersEnabled bool `json:"email_reminders_enabled"`
// If true, this user can be found by their name or parts of it when searching for it.
DiscoverableByName bool `json:"discoverable_by_name"`
// If true, the user can be found when searching for their exact email.
DiscoverableByEmail bool `json:"discoverable_by_email"`
// If enabled, the user will get an email for their overdue tasks each morning.
OverdueTasksRemindersEnabled bool `json:"overdue_tasks_reminders_enabled"`
// The time when the daily summary of overdue tasks will be sent via email.
OverdueTasksRemindersTime string `json:"overdue_tasks_reminders_time" valid:"time,required"`
// If a task is created without a specified project this value should be used. Applies
// to tasks made directly in API and from clients.
DefaultProjectID int64 `json:"default_project_id"`
// The day when the week starts for this user. 0 = sunday, 1 = monday, etc.
WeekStart int `json:"week_start" valid:"range(0|6)"`
// The user's language
Language string `json:"language"`
// The user's time zone. Used to send task reminders in the time zone of the user.
Timezone string `json:"timezone"`
// Additional settings only used by the frontend
FrontendSettings interface{} `json:"frontend_settings"`
// Additional settings links as provided by openid
ExtraSettingsLinks map[string]any `json:"extra_settings_links"`
}
// GetUserAvatarProvider returns the currently set user avatar
// @Summary Return user avatar setting
// @Description Returns the current user's avatar setting.
@ -135,29 +105,16 @@ func ChangeUserAvatarProvider(c *echo.Context) error {
return err
}
oldProvider := user.AvatarProvider
user.AvatarProvider = uap.AvatarProvider
_, err = user2.UpdateUser(s, user, false)
if err != nil {
if err := models.UpdateUserAvatarProvider(s, user, uap.AvatarProvider); err != nil {
_ = s.Rollback()
return err
}
if user.AvatarProvider == "initials" {
avatar.FlushAllCaches(user)
}
if err := s.Commit(); err != nil {
_ = s.Rollback()
return err
}
if oldProvider != user.AvatarProvider {
avatar.FlushAllCaches(user)
}
return c.JSON(http.StatusOK, &models.Message{Message: "Avatar was changed successfully."})
}
@ -167,13 +124,13 @@ func ChangeUserAvatarProvider(c *echo.Context) error {
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param avatar body UserSettings true "The updated user settings"
// @Param avatar body models.UserGeneralSettings true "The updated user settings"
// @Success 200 {object} models.Message
// @Failure 400 {object} web.HTTPError "Something's invalid."
// @Failure 500 {object} models.Message "Internal server error."
// @Router /user/settings/general [post]
func UpdateGeneralUserSettings(c *echo.Context) error {
us := &UserSettings{}
us := &models.UserGeneralSettings{}
err := c.Bind(us)
if err != nil {
var he *echo.HTTPError
@ -202,22 +159,7 @@ func UpdateGeneralUserSettings(c *echo.Context) error {
return err
}
invalidateAvatar := user.AvatarProvider == "initials" && user.Name != us.Name
user.Name = us.Name
user.EmailRemindersEnabled = us.EmailRemindersEnabled
user.DiscoverableByEmail = us.DiscoverableByEmail
user.DiscoverableByName = us.DiscoverableByName
user.OverdueTasksRemindersEnabled = us.OverdueTasksRemindersEnabled
user.DefaultProjectID = us.DefaultProjectID
user.WeekStart = us.WeekStart
user.Language = us.Language
user.Timezone = us.Timezone
user.OverdueTasksRemindersTime = us.OverdueTasksRemindersTime
user.FrontendSettings = us.FrontendSettings
_, err = user2.UpdateUser(s, user, true)
if err != nil {
if err := models.UpdateUserGeneralSettings(s, user, us); err != nil {
_ = s.Rollback()
return err
}
@ -227,10 +169,6 @@ func UpdateGeneralUserSettings(c *echo.Context) error {
return err
}
if invalidateAvatar {
avatar.FlushAllCaches(user)
}
return c.JSON(http.StatusOK, &models.Message{Message: "The settings were updated successfully."})
}

View File

@ -20,7 +20,7 @@ import (
"net/http"
"time"
"code.vikunja.io/api/pkg/modules/auth/openid"
"code.vikunja.io/api/pkg/routes/api/shared"
"code.vikunja.io/api/pkg/user"
@ -34,11 +34,11 @@ import (
type UserWithSettings struct {
user.User
Settings *UserSettings `json:"settings"`
DeletionScheduledAt time.Time `json:"deletion_scheduled_at"`
IsLocalUser bool `json:"is_local_user"`
AuthProvider string `json:"auth_provider"`
IsAdmin bool `json:"is_admin"`
Settings *models.UserGeneralSettings `json:"settings"`
DeletionScheduledAt time.Time `json:"deletion_scheduled_at"`
IsLocalUser bool `json:"is_local_user"`
AuthProvider string `json:"auth_provider"`
IsAdmin bool `json:"is_admin"`
}
// UserShow gets all information about the current user
@ -67,57 +67,17 @@ func UserShow(c *echo.Context) error {
}
us := &UserWithSettings{
User: *u,
Settings: &UserSettings{
Name: u.Name,
EmailRemindersEnabled: u.EmailRemindersEnabled,
DiscoverableByName: u.DiscoverableByName,
DiscoverableByEmail: u.DiscoverableByEmail,
OverdueTasksRemindersEnabled: u.OverdueTasksRemindersEnabled,
DefaultProjectID: u.DefaultProjectID,
WeekStart: u.WeekStart,
Language: u.Language,
Timezone: u.Timezone,
OverdueTasksRemindersTime: u.OverdueTasksRemindersTime,
FrontendSettings: u.FrontendSettings,
ExtraSettingsLinks: u.ExtraSettingsLinks,
},
User: *u,
Settings: models.NewUserGeneralSettings(u),
DeletionScheduledAt: u.DeletionScheduledAt,
IsLocalUser: u.Issuer == user.IssuerLocal,
IsAdmin: u.IsAdmin,
}
us.AuthProvider, err = getAuthProviderName(u)
us.AuthProvider, err = shared.GetAuthProviderName(u)
if err != nil {
return err
}
return c.JSON(http.StatusOK, us)
}
func getAuthProviderName(u *user.User) (name string, err error) {
if u.Issuer == user.IssuerLocal {
return "local", nil
}
if u.Issuer == user.IssuerLDAP {
return "ldap", nil
}
providers, err := openid.GetAllProviders()
if err != nil {
return "", err
}
for _, provider := range providers {
issuerURL, err := provider.Issuer()
if err != nil {
return "", err
}
if issuerURL == u.Issuer {
return provider.Name, nil
}
}
return
}

View File

@ -62,17 +62,7 @@ func UpdateUserEmail(c *echo.Context) (err error) {
s := db.NewSession()
defer s.Close()
emailUpdate.User, err = user.CheckUserCredentials(s, &user.Login{
Username: emailUpdate.User.Username,
Password: emailUpdate.Password,
})
if err != nil {
_ = s.Rollback()
return err
}
err = user.UpdateEmail(s, emailUpdate)
if err != nil {
if err := user.ChangeUserEmail(s, emailUpdate.User, emailUpdate.Password, emailUpdate.NewEmail); err != nil {
_ = s.Rollback()
return err
}

View File

@ -63,26 +63,10 @@ func UserChangePassword(c *echo.Context) error {
return err
}
if newPW.OldPassword == "" {
return user.ErrEmptyOldPassword{}
}
s := db.NewSession()
defer s.Close()
// Check the current password
if _, err = user.CheckUserCredentials(s, &user.Login{Username: doer.Username, Password: newPW.OldPassword}); err != nil {
_ = s.Rollback()
return err
}
// Update the password
if err = user.UpdateUserPassword(s, doer, newPW.NewPassword); err != nil {
_ = s.Rollback()
return err
}
if err := models.DeleteAllUserSessions(s, doer.ID); err != nil {
if err := models.ChangeUserPassword(s, doer, newPW.OldPassword, newPW.NewPassword); err != nil {
_ = s.Rollback()
return err
}

View File

@ -31,6 +31,17 @@ type EmailUpdate struct {
Password string `json:"password"`
}
// ChangeUserEmail verifies the user's password, then sets a new email address
// (kicking off confirmation when the mailer is enabled). Shared by the v1 and
// v2 email-update handlers; only HTTP input binding stays in the handlers.
func ChangeUserEmail(s *xorm.Session, u *User, password, newEmail string) error {
verified, err := CheckUserCredentials(s, &Login{Username: u.Username, Password: password})
if err != nil {
return err
}
return UpdateEmail(s, &EmailUpdate{User: verified, NewEmail: newEmail})
}
// UpdateEmail lets a user update their email address
func UpdateEmail(s *xorm.Session, update *EmailUpdate) (err error) {