From 459f54ac9d67940d6976a7643a5738847075892b Mon Sep 17 00:00:00 2001 From: Nathaniel Allred Date: Tue, 15 Feb 2022 17:46:03 -0600 Subject: [PATCH] Use case onboarding (#19446) * Add use case onboarding feature flag * Add endpoints and storage for whether first admin completed setup * Add migration for FirstAdminSetupComplete Co-authored-by: Michael Kochell <6913320+mickmister@users.noreply.github.com> Co-authored-by: Mattermod Co-authored-by: Ben Schumacher --- api4/system.go | 26 +++++++++++++++- app/app_iface.go | 1 + app/migrations.go | 46 ++++++++++++++++++++++++++++ app/onboarding.go | 29 ++++++++++++++++++ app/opentracing/opentracing_layer.go | 22 +++++++++++++ i18n/en.json | 12 ++++++++ model/feature_flags.go | 4 +++ model/system.go | 1 + 8 files changed, 140 insertions(+), 1 deletion(-) diff --git a/api4/system.go b/api4/system.go index b478f6e05b..0e6e7e50cd 100644 --- a/api4/system.go +++ b/api4/system.go @@ -68,7 +68,8 @@ func (api *API) InitSystem() { api.BaseRoutes.System.Handle("/notices/{team_id:[A-Za-z0-9]+}", api.APISessionRequired(getProductNotices)).Methods("GET") api.BaseRoutes.System.Handle("/notices/view", api.APISessionRequired(updateViewedProductNotices)).Methods("PUT") api.BaseRoutes.System.Handle("/support_packet", api.APISessionRequired(generateSupportPacket)).Methods("GET") - api.BaseRoutes.System.Handle("/onboarding/complete", api.APIHandler(completeOnboarding)).Methods("POST") + api.BaseRoutes.System.Handle("/onboarding/complete", api.APISessionRequired(getOnboarding)).Methods("GET") + api.BaseRoutes.System.Handle("/onboarding/complete", api.APISessionRequired(completeOnboarding)).Methods("POST") } func generateSupportPacket(c *Context, w http.ResponseWriter, r *http.Request) { @@ -881,6 +882,29 @@ func updateViewedProductNotices(c *Context, w http.ResponseWriter, r *http.Reque ReturnStatusOK(w) } +func getOnboarding(c *Context, w http.ResponseWriter, r *http.Request) { + auditRec := c.MakeAuditRecord("getOnboarding", audit.Fail) + defer c.LogAuditRec(auditRec) + c.LogAudit("attempt") + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + firstAdminCompleteSetupObj, err := c.App.GetOnboarding() + + if err != nil { + c.Err = model.NewAppError("getOnboarding", "app.system.get_onboarding_request.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + auditRec.Success() + if err := json.NewEncoder(w).Encode(firstAdminCompleteSetupObj); err != nil { + mlog.Warn("Error while writing response", mlog.Err(err)) + } +} + func completeOnboarding(c *Context, w http.ResponseWriter, r *http.Request) { if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { c.Err = model.NewAppError("completeOnboarding", "app.system.complete_onboarding_request.no_first_user", nil, "", http.StatusForbidden) diff --git a/app/app_iface.go b/app/app_iface.go index fc2402b912..4ea550518b 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -653,6 +653,7 @@ type AppIface interface { GetOAuthLoginEndpoint(w http.ResponseWriter, r *http.Request, service, teamID, action, redirectTo, loginHint string, isMobile bool) (string, *model.AppError) GetOAuthSignupEndpoint(w http.ResponseWriter, r *http.Request, service, teamID string) (string, *model.AppError) GetOAuthStateToken(token string) (*model.Token, *model.AppError) + GetOnboarding() (*model.System, *model.AppError) GetOpenGraphMetadata(requestURL string) ([]byte, error) GetOrCreateDirectChannel(c *request.Context, userID, otherUserID string, channelOptions ...model.ChannelOption) (*model.Channel, *model.AppError) GetOutgoingWebhook(hookID string) (*model.OutgoingWebhook, *model.AppError) diff --git a/app/migrations.go b/app/migrations.go index dc95d15892..53f4c5f56d 100644 --- a/app/migrations.go +++ b/app/migrations.go @@ -17,6 +17,7 @@ const GuestRolesCreationMigrationKey = "GuestRolesCreationMigrationComplete" const SystemConsoleRolesCreationMigrationKey = "SystemConsoleRolesCreationMigrationComplete" const ContentExtractionConfigDefaultTrueMigrationKey = "ContentExtractionConfigDefaultTrueMigrationComplete" const PlaybookRolesCreationMigrationKey = "PlaybookRolesCreationMigrationComplete" +const FirstAdminSetupCompleteKey = "FirstAdminSetupComplete" // This function migrates the default built in roles from code/config to the database. func (a *App) DoAdvancedPermissionsMigration() { @@ -434,6 +435,50 @@ func (s *Server) doPlaybooksRolesCreationMigration() { } +// arbitrary choice, though if there is an longstanding installation with less than 10 messages, +// putting the first admin through onboarding shouldn't be very disruptive. +const existingInstallationPostsThreshold = 10 + +func (s *Server) doFirstAdminSetupCompleteMigration() { + // Don't run the migration until the flag is turned on. + + if !s.Config().FeatureFlags.UseCaseOnboarding { + return + } + + // If the migration is already marked as completed, don't do it again. + if _, err := s.Store.System().GetByName(FirstAdminSetupCompleteKey); err == nil { + return + } + + teams, err := s.Store.Team().GetAll() + if err != nil { + // can not confirm that admin has started in this case. + return + } + + if len(teams) == 0 { + // No teams, and no existing preference. This is most likely a new instance. + // So do not mark that the admin has already done the first time setup. + return + } + + // if there are teams, then if this isn't a new installation, there should be posts + postCount, err := s.Store.Post().AnalyticsPostCount("", false, false) + if err != nil || postCount < existingInstallationPostsThreshold { + return + } + + system := model.System{ + Name: FirstAdminSetupCompleteKey, + Value: "true", + } + + if err := s.Store.System().Save(&system); err != nil { + mlog.Critical("Failed to mark first admin setup migration as completed.", mlog.Err(err)) + } +} + func (a *App) DoAppMigrations() { a.Srv().doAppMigrations() } @@ -451,4 +496,5 @@ func (s *Server) doAppMigrations() { } s.doContentExtractionConfigDefaultTrueMigration() s.doPlaybooksRolesCreationMigration() + s.doFirstAdminSetupCompleteMigration() } diff --git a/app/onboarding.go b/app/onboarding.go index 092a6ef4fe..d621d28b3f 100644 --- a/app/onboarding.go +++ b/app/onboarding.go @@ -7,9 +7,12 @@ import ( "net/http" "sync" + "github.com/pkg/errors" + "github.com/mattermost/mattermost-server/v6/app/request" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/shared/mlog" + "github.com/mattermost/mattermost-server/v6/store" ) func (a *App) CompleteOnboarding(c *request.Context, request *model.CompleteOnboardingRequest) *model.AppError { @@ -56,7 +59,33 @@ func (a *App) CompleteOnboarding(c *request.Context, request *model.CompleteOnbo }(pluginID) } + firstAdminCompleteSetupObj := model.System{ + Name: model.SystemFirstAdminCompleteSetup, + Value: "true", + } + + if err := a.Srv().Store.System().SaveOrUpdate(&firstAdminCompleteSetupObj); err != nil { + return model.NewAppError("setFirstAdminCompleteSetup", "api.error_set_first_admin_complete_setup", nil, err.Error(), http.StatusInternalServerError) + } + wg.Wait() return nil } + +func (a *App) GetOnboarding() (*model.System, *model.AppError) { + firstAdminCompleteSetupObj, err := a.Srv().Store.System().GetByName(model.SystemFirstAdminCompleteSetup) + if err != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + return &model.System{ + Name: model.SystemFirstAdminCompleteSetup, + Value: "false", + }, nil + default: + return nil, model.NewAppError("getFirstAdminCompleteSetup", "api.error_get_first_admin_complete_setup", nil, err.Error(), http.StatusInternalServerError) + } + } + return firstAdminCompleteSetupObj, nil +} diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 8244925a84..dad1e50ca1 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -7121,6 +7121,28 @@ func (a *OpenTracingAppLayer) GetOAuthStateToken(token string) (*model.Token, *m return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetOnboarding() (*model.System, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetOnboarding") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetOnboarding() + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetOpenGraphMetadata(requestURL string) ([]byte, error) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetOpenGraphMetadata") diff --git a/i18n/en.json b/i18n/en.json index ea324d8f1c..cf3c2c38d4 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1681,10 +1681,18 @@ "id": "api.emoji.upload.open.app_error", "translation": "Unable to create the emoji. An error occurred when trying to open the attached image." }, + { + "id": "api.error_get_first_admin_complete_setup", + "translation": "Error trying to retrieve first admin complete setup from the store." + }, { "id": "api.error_get_first_admin_visit_marketplace_status", "translation": "Error trying to retrieve the first admin visit marketplace status from the store." }, + { + "id": "api.error_set_first_admin_complete_setup", + "translation": "Error trying to save first admin complete setup in the store." + }, { "id": "api.error_set_first_admin_visit_marketplace_status", "translation": "Error trying to save the first admin visit marketplace status in the store." @@ -6091,6 +6099,10 @@ "id": "app.system.get_by_name.app_error", "translation": "Unable to find the system variable." }, + { + "id": "app.system.get_onboarding_request.app_error", + "translation": "Failed to get onboarding completion status." + }, { "id": "app.system.permanent_delete_by_name.app_error", "translation": "We could not permanently delete the system table entry." diff --git a/model/feature_flags.go b/model/feature_flags.go index d4fc17016a..fa462462e8 100644 --- a/model/feature_flags.go +++ b/model/feature_flags.go @@ -67,6 +67,9 @@ type FeatureFlags struct { NormalizeLdapDNs bool + // Enable special onboarding flow for first admin + UseCaseOnboarding bool + // Enable Workspace optimization dashboard WorkspaceOptimizationDashboard bool @@ -95,6 +98,7 @@ func (f *FeatureFlags) SetDefaults() { f.InlinePostEditing = false f.BoardsDataRetention = false f.NormalizeLdapDNs = false + f.UseCaseOnboarding = false f.WorkspaceOptimizationDashboard = false f.GraphQL = false } diff --git a/model/system.go b/model/system.go index c8bcaba2bb..c8a4210118 100644 --- a/model/system.go +++ b/model/system.go @@ -32,6 +32,7 @@ const ( SystemWarnMetricLastRunTimestampKey = "LastWarnMetricRunTimestamp" SystemMetricSupportEmailNotConfigured = "warn_metric_support_email_not_configured" SystemFirstAdminVisitMarketplace = "FirstAdminVisitMarketplace" + SystemFirstAdminCompleteSetup = "FirstAdminCompleteSetup" AwsMeteringReportInterval = 1 AwsMeteringDimensionUsageHrs = "UsageHrs" UserLimitOverageCycleEndDate = "UserLimitOverageCycleEndDate"