diff --git a/satellite/console/consoleweb/consoleapi/auth.go b/satellite/console/consoleweb/consoleapi/auth.go index f0436e485..f64b7cf29 100644 --- a/satellite/console/consoleweb/consoleapi/auth.go +++ b/satellite/console/consoleweb/consoleapi/auth.go @@ -912,6 +912,54 @@ func (a *Auth) RefreshSession(w http.ResponseWriter, r *http.Request) { } } +// GetUserSettings gets a user's settings. +func (a *Auth) GetUserSettings(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var err error + defer mon.Task()(&ctx)(&err) + + settings, err := a.service.GetUserSettings(ctx) + if err != nil { + a.serveJSONError(w, err) + return + } + + err = json.NewEncoder(w).Encode(settings) + if err != nil { + a.log.Error("could not encode settings", zap.Error(ErrAuthAPI.Wrap(err))) + return + } +} + +// SetOnboardingStatus updates a user's onboarding status. +func (a *Auth) SetOnboardingStatus(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var err error + defer mon.Task()(&ctx)(&err) + + var updateInfo struct { + OnboardingStart *bool `json:"onboardingStart"` + OnboardingEnd *bool `json:"onboardingEnd"` + OnboardingStep *string `json:"onboardingStep"` + } + + err = json.NewDecoder(r.Body).Decode(&updateInfo) + if err != nil { + a.serveJSONError(w, err) + return + } + + err = a.service.SetUserSettings(ctx, console.UpsertUserSettingsRequest{ + OnboardingStart: updateInfo.OnboardingStart, + OnboardingEnd: updateInfo.OnboardingEnd, + OnboardingStep: updateInfo.OnboardingStep, + }) + if err != nil { + a.serveJSONError(w, err) + return + } +} + // serveJSONError writes JSON error to response output stream. func (a *Auth) serveJSONError(w http.ResponseWriter, err error) { status := a.getStatusCode(err) diff --git a/satellite/console/consoleweb/endpoints_test.go b/satellite/console/consoleweb/endpoints_test.go index bab6fad30..4aa638f66 100644 --- a/satellite/console/consoleweb/endpoints_test.go +++ b/satellite/console/consoleweb/endpoints_test.go @@ -97,6 +97,84 @@ func TestAuth(t *testing.T) { require.False(test.t, freezestatus.Frozen) } + { // Test_UserSettings + testGetSettings := func(expected struct { + SessionDuration *string + OnboardingStart bool + OnboardingEnd bool + OnboardingStep *string + }) { + resp, body := test.request(http.MethodGet, "/auth/account/settings", nil) + + var settings struct { + SessionDuration *string + OnboardingStart bool + OnboardingEnd bool + OnboardingStep *string + } + require.Equal(t, http.StatusOK, resp.StatusCode) + require.NoError(test.t, json.Unmarshal([]byte(body), &settings)) + require.Equal(test.t, expected.OnboardingStart, settings.OnboardingStart) + require.Equal(test.t, expected.OnboardingEnd, settings.OnboardingEnd) + require.Equal(test.t, expected.OnboardingStep, settings.OnboardingStep) + require.Equal(test.t, expected.SessionDuration, settings.SessionDuration) + } + + testGetSettings(struct { + SessionDuration *string + OnboardingStart bool + OnboardingEnd bool + OnboardingStep *string + }{ + SessionDuration: nil, + OnboardingStart: false, + OnboardingEnd: false, + OnboardingStep: nil, + }) + + resp, _ := test.request(http.MethodPatch, "/auth/account/onboarding", + test.toJSON(map[string]interface{}{ + "onboardingStart": true, + "onboardingEnd": false, + "onboardingStep": "cli", + })) + + require.Equal(t, http.StatusOK, resp.StatusCode) + step := "cli" + testGetSettings(struct { + SessionDuration *string + OnboardingStart bool + OnboardingEnd bool + OnboardingStep *string + }{ + SessionDuration: nil, + OnboardingStart: true, + OnboardingEnd: false, + OnboardingStep: &step, + }) + + resp, _ = test.request(http.MethodPatch, "/auth/account/onboarding", + test.toJSON(map[string]interface{}{ + "onboardingStart": nil, + "onboardingEnd": nil, + "onboardingStep": nil, + })) + + require.Equal(t, http.StatusOK, resp.StatusCode) + // having passed nil to /auth/account/onboarding shouldn't have changed existing values. + testGetSettings(struct { + SessionDuration *string + OnboardingStart bool + OnboardingEnd bool + OnboardingStep *string + }{ + SessionDuration: nil, + OnboardingStart: true, + OnboardingEnd: false, + OnboardingStep: &step, + }) + } + { // Logout resp, _ := test.request(http.MethodPost, "/auth/logout", nil) cookie := findCookie(resp, "_tokenKey") diff --git a/satellite/console/consoleweb/server.go b/satellite/console/consoleweb/server.go index a88c54543..6d5e8eeb2 100644 --- a/satellite/console/consoleweb/server.go +++ b/satellite/console/consoleweb/server.go @@ -283,6 +283,8 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc authRouter.Handle("/account/change-email", server.withAuth(http.HandlerFunc(authController.ChangeEmail))).Methods(http.MethodPost) authRouter.Handle("/account/change-password", server.withAuth(server.userIDRateLimiter.Limit(http.HandlerFunc(authController.ChangePassword)))).Methods(http.MethodPost) authRouter.Handle("/account/freezestatus", server.withAuth(http.HandlerFunc(authController.IsAccountFrozen))).Methods(http.MethodGet) + authRouter.Handle("/account/settings", server.withAuth(http.HandlerFunc(authController.GetUserSettings))).Methods(http.MethodGet) + authRouter.Handle("/account/onboarding", server.withAuth(http.HandlerFunc(authController.SetOnboardingStatus))).Methods(http.MethodPatch) authRouter.Handle("/account/delete", server.withAuth(http.HandlerFunc(authController.DeleteAccount))).Methods(http.MethodPost) authRouter.Handle("/mfa/enable", server.withAuth(http.HandlerFunc(authController.EnableUserMFA))).Methods(http.MethodPost) authRouter.Handle("/mfa/disable", server.withAuth(http.HandlerFunc(authController.DisableUserMFA))).Methods(http.MethodPost) diff --git a/satellite/console/service.go b/satellite/console/service.go index caa7f2606..85bcf5ffb 100644 --- a/satellite/console/service.go +++ b/satellite/console/service.go @@ -3239,3 +3239,47 @@ func (s *Service) VerifyForgotPasswordCaptcha(ctx context.Context, responseToken } return true, nil } + +// GetUserSettings fetches a user's settings. It creates default settings if none exists. +func (s *Service) GetUserSettings(ctx context.Context) (settings *UserSettings, err error) { + defer mon.Task()(&ctx)(&err) + + user, err := s.getUserAndAuditLog(ctx, "get user settings") + if err != nil { + return nil, Error.Wrap(err) + } + + settings, err = s.store.Users().GetSettings(ctx, user.ID) + if err != nil { + if !errs.Is(err, sql.ErrNoRows) { + return nil, Error.Wrap(err) + } + err = s.store.Users().UpsertSettings(ctx, user.ID, UpsertUserSettingsRequest{}) + if err != nil { + return nil, Error.Wrap(err) + } + settings, err = s.store.Users().GetSettings(ctx, user.ID) + if err != nil { + return nil, Error.Wrap(err) + } + } + + return settings, nil +} + +// SetUserSettings updates a user's settings. +func (s *Service) SetUserSettings(ctx context.Context, request UpsertUserSettingsRequest) (err error) { + defer mon.Task()(&ctx)(&err) + + user, err := s.getUserAndAuditLog(ctx, "get user settings") + if err != nil { + return Error.Wrap(err) + } + + err = s.store.Users().UpsertSettings(ctx, user.ID, request) + if err != nil { + return Error.Wrap(err) + } + + return nil +} diff --git a/satellite/console/service_test.go b/satellite/console/service_test.go index ff530df97..6e0ef6ddc 100644 --- a/satellite/console/service_test.go +++ b/satellite/console/service_test.go @@ -8,6 +8,7 @@ import ( "database/sql" "encoding/json" "fmt" + "math/rand" "sort" "testing" "time" @@ -1069,6 +1070,67 @@ func TestRefreshSessionToken(t *testing.T) { }) } +func TestUserSettings(t *testing.T) { + testplanet.Run(t, testplanet.Config{ + SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1, + }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { + sat := planet.Satellites[0] + srv := sat.API.Console.Service + userDB := sat.DB.Console().Users() + + user, _, err := srv.GetUserByEmailWithUnverified(ctx, planet.Uplinks[0].User[sat.ID()].Email) + require.NoError(t, err) + + userCtx, err := sat.UserContext(ctx, user.ID) + require.NoError(t, err) + + _, err = userDB.GetSettings(userCtx, user.ID) + require.Error(t, err) + + settings, err := srv.GetUserSettings(userCtx) + require.NoError(t, err) + require.Equal(t, false, settings.OnboardingStart) + require.Equal(t, false, settings.OnboardingEnd) + require.Nil(t, settings.OnboardingStep) + require.Nil(t, settings.SessionDuration) + + onboardingBool := true + onboardingStep := "Overview" + sessionDur := time.Duration(rand.Int63()).Round(time.Minute) + sessionDurPtr := &sessionDur + err = srv.SetUserSettings(userCtx, console.UpsertUserSettingsRequest{ + SessionDuration: &sessionDurPtr, + OnboardingStart: &onboardingBool, + OnboardingEnd: &onboardingBool, + OnboardingStep: &onboardingStep, + }) + require.NoError(t, err) + + settings, err = userDB.GetSettings(userCtx, user.ID) + require.NoError(t, err) + require.Equal(t, onboardingBool, settings.OnboardingStart) + require.Equal(t, onboardingBool, settings.OnboardingEnd) + require.Equal(t, &onboardingStep, settings.OnboardingStep) + require.Equal(t, sessionDurPtr, settings.SessionDuration) + + // passing nil should not override existing values + err = srv.SetUserSettings(userCtx, console.UpsertUserSettingsRequest{ + SessionDuration: nil, + OnboardingStart: nil, + OnboardingEnd: nil, + OnboardingStep: nil, + }) + require.NoError(t, err) + + settings, err = userDB.GetSettings(userCtx, user.ID) + require.NoError(t, err) + require.Equal(t, onboardingBool, settings.OnboardingStart) + require.Equal(t, onboardingBool, settings.OnboardingEnd) + require.Equal(t, &onboardingStep, settings.OnboardingStep) + require.Equal(t, sessionDurPtr, settings.SessionDuration) + }) +} + func TestRESTKeys(t *testing.T) { testplanet.Run(t, testplanet.Config{ SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1,