diff --git a/.golangci.yml b/.golangci.yml
index 6f1a759f2..552e13cb7 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -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'
diff --git a/pkg/models/user_settings.go b/pkg/models/user_settings.go
new file mode 100644
index 000000000..cba87cdb5
--- /dev/null
+++ b/pkg/models/user_settings.go
@@ -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 .
+
+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
+}
diff --git a/pkg/routes/api/shared/auth_provider.go b/pkg/routes/api/shared/auth_provider.go
new file mode 100644
index 000000000..042a5567d
--- /dev/null
+++ b/pkg/routes/api/shared/auth_provider.go
@@ -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 .
+
+// 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
+}
diff --git a/pkg/routes/api/v1/user_settings.go b/pkg/routes/api/v1/user_settings.go
index 2efa9c0f0..049330411 100644
--- a/pkg/routes/api/v1/user_settings.go
+++ b/pkg/routes/api/v1/user_settings.go
@@ -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."})
}
diff --git a/pkg/routes/api/v1/user_show.go b/pkg/routes/api/v1/user_show.go
index d5a391267..655b0fb5c 100644
--- a/pkg/routes/api/v1/user_show.go
+++ b/pkg/routes/api/v1/user_show.go
@@ -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
-}
diff --git a/pkg/routes/api/v1/user_update_email.go b/pkg/routes/api/v1/user_update_email.go
index ea1077075..e73ba6f89 100644
--- a/pkg/routes/api/v1/user_update_email.go
+++ b/pkg/routes/api/v1/user_update_email.go
@@ -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
}
diff --git a/pkg/routes/api/v1/user_update_password.go b/pkg/routes/api/v1/user_update_password.go
index 0172a21ec..52941a48a 100644
--- a/pkg/routes/api/v1/user_update_password.go
+++ b/pkg/routes/api/v1/user_update_password.go
@@ -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
}
diff --git a/pkg/user/update_email.go b/pkg/user/update_email.go
index 73e104682..b721ba518 100644
--- a/pkg/user/update_email.go
+++ b/pkg/user/update_email.go
@@ -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) {