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:
parent
154a96674d
commit
46b07a019c
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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."})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue