diff --git a/pkg/routes/api/v2/user_settings.go b/pkg/routes/api/v2/user_settings.go new file mode 100644 index 000000000..a1f5bbee4 --- /dev/null +++ b/pkg/routes/api/v2/user_settings.go @@ -0,0 +1,334 @@ +// 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 apiv2 + +import ( + "context" + "net/http" + "time" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes/api/shared" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" + "github.com/tkuchiki/go-timezone" +) + +// userInfoBody is the GET /user response: the public user fields plus the +// computed account facts v1 returned alongside the user object. +type userInfoBody struct { + user.User + Settings *models.UserGeneralSettings `json:"settings" readOnly:"true" doc:"The current user's settings."` + DeletionScheduledAt time.Time `json:"deletion_scheduled_at" readOnly:"true" doc:"When the account is scheduled for deletion, if a deletion was requested."` + IsLocalUser bool `json:"is_local_user" readOnly:"true" doc:"True if the user authenticates locally (not via LDAP or OpenID)."` + AuthProvider string `json:"auth_provider" readOnly:"true" doc:"The name of the source the user authenticated with: 'local', 'ldap', or the configured OpenID provider name."` + IsAdmin bool `json:"is_admin" readOnly:"true" doc:"True if the user is an instance administrator."` +} + +// userAvatarProviderBody is the get/set body for the user's avatar provider. +type userAvatarProviderBody struct { + AvatarProvider string `json:"avatar_provider" doc:"The avatar provider. One of: gravatar (uses the user email), upload, initials, marble (random per user), ldap (synced from LDAP), openid (synced from OpenID), default."` +} + +type userActionMessageBody struct { + Message string `json:"message" readOnly:"true" doc:"A confirmation message."` +} + +// RegisterUserSettingsRoutes wires the current-user account & settings +// endpoints onto the Huma API. These are not CRUDable resources: each operates +// on the authenticated user pulled from the request context. +func RegisterUserSettingsRoutes(api huma.API) { + tags := []string{"user"} + + Register(api, huma.Operation{ + OperationID: "user-show", + Summary: "Get the current user", + Description: "Returns the authenticated user together with their settings and computed account facts (auth_provider, is_local_user, is_admin, deletion_scheduled_at).", + Method: http.MethodGet, + Path: "/user", + Tags: tags, + }, userShow) + + Register(api, huma.Operation{ + OperationID: "user-change-password", + Summary: "Change the current user's password", + Description: "Changes the authenticated user's password after verifying the old one. All of the user's existing sessions are invalidated.", + Method: http.MethodPost, + Path: "/user/password", + // Changes a password, it creates nothing — keep 200 over the wrapper's POST→201. + DefaultStatus: http.StatusOK, + Tags: tags, + }, userChangePassword) + + Register(api, huma.Operation{ + OperationID: "user-update-email", + Summary: "Update the current user's email address", + Description: "Sets a new email address for the authenticated user after verifying their password. If the mailer is enabled the change is pending until the user confirms it via a link sent to the new address; otherwise it takes effect immediately.", + Method: http.MethodPut, + Path: "/user/settings/email", + Tags: tags, + }, userUpdateEmail) + + Register(api, huma.Operation{ + OperationID: "user-update-settings", + Summary: "Update the current user's general settings", + Description: "Replaces the authenticated user's general settings (name, reminders, discoverability, default project, week start, language, timezone, frontend settings).", + Method: http.MethodPut, + Path: "/user/settings/general", + Tags: tags, + }, userUpdateSettings) + + // Path differs from v1's /user/settings/avatar: on v2 that path is the + // binary avatar upload (PUT), so the provider get/set live on a sub-path. + Register(api, huma.Operation{ + OperationID: "user-get-avatar-provider", + Summary: "Get the current user's avatar provider", + Description: "Returns the avatar provider configured for the authenticated user.", + Method: http.MethodGet, + Path: "/user/settings/avatar/provider", + Tags: tags, + }, userGetAvatarProvider) + + Register(api, huma.Operation{ + OperationID: "user-set-avatar-provider", + Summary: "Set the current user's avatar provider", + Description: "Changes the avatar provider for the authenticated user. Valid values: gravatar, upload, initials, marble, ldap, openid, default.", + Method: http.MethodPut, + Path: "/user/settings/avatar/provider", + Tags: tags, + }, userSetAvatarProvider) + + Register(api, huma.Operation{ + OperationID: "user-timezones", + Summary: "List available time zones", + Description: "Returns every time zone this Vikunja instance can handle. The list depends on the host system and is unsorted; sort it client-side.", + Method: http.MethodGet, + Path: "/user/timezones", + Tags: tags, + }, userTimezones) +} + +func init() { AddRouteRegistrar(RegisterUserSettingsRoutes) } + +func userShow(ctx context.Context, _ *struct{}) (*singleBody[userInfoBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + u, err := models.GetUserOrLinkShareUser(s, a) + if err != nil { + return nil, translateDomainError(err) + } + + info := &userInfoBody{ + User: *u, + Settings: models.NewUserGeneralSettings(u), + DeletionScheduledAt: u.DeletionScheduledAt, + IsLocalUser: u.Issuer == user.IssuerLocal, + IsAdmin: u.IsAdmin, + } + + // nolint:contextcheck // openid.GetAllProviders/Issuer (called via shared) take + // no context; threading one would change those signatures across both APIs. + info.AuthProvider, err = shared.GetAuthProviderName(u) + if err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userInfoBody]{Body: info}, nil +} + +func userChangePassword(ctx context.Context, in *struct { + Body struct { + OldPassword string `json:"old_password" doc:"The current password, for confirmation."` + NewPassword string `json:"new_password" valid:"bcrypt_password" minLength:"8" maxLength:"72" doc:"The new password. Max 72 bytes (a bcrypt limit), which may be fewer than 72 characters."` + } +}) (*singleBody[userActionMessageBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + doer, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + s := db.NewSession() + defer s.Close() + + if err := models.ChangeUserPassword(s, doer, in.Body.OldPassword, in.Body.NewPassword); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userActionMessageBody]{Body: &userActionMessageBody{Message: "The password was updated successfully."}}, nil +} + +func userUpdateEmail(ctx context.Context, in *struct { + Body struct { + NewEmail string `json:"new_email" valid:"email,length(0|250),required" maxLength:"250" doc:"The new email address."` + Password string `json:"password" doc:"The current password, for confirmation."` + } +}) (*singleBody[userActionMessageBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + doer, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + s := db.NewSession() + defer s.Close() + + if err := user.ChangeUserEmail(s, doer, in.Body.Password, in.Body.NewEmail); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userActionMessageBody]{Body: &userActionMessageBody{Message: "We sent you email with a link to confirm your email address."}}, nil +} + +func userUpdateSettings(ctx context.Context, in *struct { + Body models.UserGeneralSettings +}) (*singleBody[userActionMessageBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + doer, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + s := db.NewSession() + defer s.Close() + + u, err := user.GetUserWithEmail(s, &user.User{ID: doer.ID}) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := models.UpdateUserGeneralSettings(s, u, &in.Body); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userActionMessageBody]{Body: &userActionMessageBody{Message: "The settings were updated successfully."}}, nil +} + +func userGetAvatarProvider(ctx context.Context, _ *struct{}) (*singleBody[userAvatarProviderBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + doer, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + s := db.NewSession() + defer s.Close() + + u, err := user.GetUserWithEmail(s, &user.User{ID: doer.ID}) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userAvatarProviderBody]{Body: &userAvatarProviderBody{AvatarProvider: u.AvatarProvider}}, nil +} + +func userSetAvatarProvider(ctx context.Context, in *struct { + Body userAvatarProviderBody +}) (*singleBody[userAvatarProviderBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + doer, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + s := db.NewSession() + defer s.Close() + + u, err := user.GetUserWithEmail(s, &user.User{ID: doer.ID}) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := models.UpdateUserAvatarProvider(s, u, in.Body.AvatarProvider); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userAvatarProviderBody]{Body: &userAvatarProviderBody{AvatarProvider: u.AvatarProvider}}, nil +} + +type timezonesBody struct { + Body []string +} + +func userTimezones(ctx context.Context, _ *struct{}) (*timezonesBody, error) { + if _, err := authFromCtx(ctx); err != nil { + return nil, err + } + + timezoneMap := make(map[string]bool) // de-dupe across the per-abbreviation groups + for _, group := range timezone.New().Timezones() { + for _, t := range group { + timezoneMap[t] = true + } + } + + ts := make([]string, 0, len(timezoneMap)) + for t := range timezoneMap { + ts = append(ts, t) + } + + return &timezonesBody{Body: ts}, nil +} diff --git a/pkg/webtests/huma_user_settings_test.go b/pkg/webtests/huma_user_settings_test.go new file mode 100644 index 000000000..24e7469f3 --- /dev/null +++ b/pkg/webtests/huma_user_settings_test.go @@ -0,0 +1,195 @@ +// 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 webtests + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// All subtests in a Test* func share one env: setupTestEnv rotates the JWT +// secret per call, so a token must be issued from the same env it's used +// against. Where a subtest mutates the user, later subtests account for it. + +func TestHumaUserShow(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + body := rec.Body.String() + assert.Contains(t, body, `"id":1`) + assert.Contains(t, body, `"username":"user1"`) + // Like v1, /user does not disclose the email (GetUserByID strips it); the + // json:"email,omitempty" tag then drops the field entirely. + assert.NotContains(t, body, `"email":""`) + // Computed account facts v1 returned alongside the user object. + assert.Contains(t, body, `"auth_provider":"local"`) + assert.Contains(t, body, `"is_local_user":true`) + assert.Contains(t, body, `"is_admin":false`) + // The nested settings use the shared models.UserGeneralSettings shape. + assert.Contains(t, body, `"settings":`) + assert.Contains(t, body, `"frontend_settings":`) + assert.Contains(t, body, `"extra_settings_links":`) + + t.Run("Unauthenticated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) +} + +func TestHumaUserChangePassword(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("Wrong old password", func(t *testing.T) { + // CheckUserCredentials → ErrWrongUsernameOrPassword (403). + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/password", + `{"old_password":"invalid","new_password":"123456789"}`, token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Empty old password", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/password", + `{"old_password":"","new_password":"123456789"}`, token, "") + assert.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("New password too short", func(t *testing.T) { + // v2 maps govalidator failures (bcrypt_password) to 422, not v1's 412. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/password", + `{"old_password":"12345678","new_password":"1234567"}`, token, "") + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Normal - run last, it changes the password", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/password", + `{"old_password":"12345678","new_password":"123456789"}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "The password was updated successfully.") + }) +} + +func TestHumaUserUpdateEmail(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("Wrong password", func(t *testing.T) { + // CheckUserCredentials → ErrWrongUsernameOrPassword (403). + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/email", + `{"new_email":"new@example.com","password":"invalid"}`, token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Missing new email", func(t *testing.T) { + // new_email carries valid:"...,required"; v2 maps the failure to 422. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/email", + `{"password":"12345678"}`, token, "") + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Normal", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/email", + `{"new_email":"new@example.com","password":"12345678"}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "confirm your email address") + }) +} + +func TestHumaUserUpdateSettings(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("Normal", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/general", + `{"name":"New Name","week_start":1,"overdue_tasks_reminders_time":"10:00","timezone":"Europe/Berlin"}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "The settings were updated successfully.") + + // The change is observable through user-show. + show := humaRequest(t, e, http.MethodGet, "/api/v2/user", "", token, "") + require.Equal(t, http.StatusOK, show.Code) + assert.Contains(t, show.Body.String(), `"name":"New Name"`) + }) + t.Run("Frontend settings round-trip as arbitrary JSON", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/general", + `{"overdue_tasks_reminders_time":"09:00","frontend_settings":{"color_schema":"dark","nested":{"a":1}}}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + show := humaRequest(t, e, http.MethodGet, "/api/v2/user", "", token, "") + require.Equal(t, http.StatusOK, show.Code) + var resp struct { + Settings struct { + FrontendSettings map[string]any `json:"frontend_settings"` + } `json:"settings"` + } + require.NoError(t, json.Unmarshal(show.Body.Bytes(), &resp)) + assert.Equal(t, "dark", resp.Settings.FrontendSettings["color_schema"]) + }) + t.Run("Invalid week_start", func(t *testing.T) { + // week_start carries valid:"range(0|6)"; out of range maps to 422. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/general", + `{"week_start":9,"overdue_tasks_reminders_time":"09:00"}`, token, "") + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) +} + +func TestHumaUserAvatarProvider(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("Get", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/avatar/provider", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"avatar_provider":`) + }) + t.Run("Set then get reflects the change", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/avatar/provider", + `{"avatar_provider":"initials"}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"avatar_provider":"initials"`) + + get := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/avatar/provider", "", token, "") + require.Equal(t, http.StatusOK, get.Code) + assert.Contains(t, get.Body.String(), `"avatar_provider":"initials"`) + }) + t.Run("Invalid provider", func(t *testing.T) { + // UpdateUser rejects unknown providers with ErrInvalidAvatarProvider (412). + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/avatar/provider", + `{"avatar_provider":"nonsense"}`, token, "") + assert.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) + }) +} + +func TestHumaUserTimezones(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/timezones", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var zones []string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &zones)) + assert.NotEmpty(t, zones) + assert.Contains(t, zones, "Europe/Berlin") +}