From c57bd5a8466ffb943748b02e8ce27d56f40df569 Mon Sep 17 00:00:00 2001 From: Ben Schumacher Date: Mon, 31 Aug 2026 10:04:41 +0200 Subject: [PATCH 1/5] [MM-70230] Migrate App.GetUser to request context (#37923) * [MM-70230] Migrate App.GetUser to request context Adds rctx request.CTX as the first parameter of App.GetUser and threads the caller's existing request context through instead of using request.EmptyContext. Updates internal callers across app, api4, and cmd/mmctl to pass their in-scope rctx, and updates the plugin API's GetUser and GetUserCustomStatus implementations to forward the plugin's own request context (PluginAPI.ctx) instead of dropping it. A handful of App methods that previously called GetUser without a context of their own (SessionHasPermissionToUser, ValidateDesktopToken, GenerateMfaSecret, ActivateMfa, DeactivateMfa, UpdateHashedPasswordByUserId, VerifyUserEmail, CreateTermsOfService, GetCustomStatus, and a few private helpers) gained an rctx parameter too, threaded from their own callers. HasPermissionTo is frozen by the public plugin API, so it falls back to request.EmptyContext internally instead. Also removes a few stray unused "context" imports left over from the prior UserStore.Get migration (MM-70222) that were breaking `go build ./...` on master. Part of the MM-56633 store-context campaign. Co-Authored-By: Claude Sonnet 5 * [MM-70230] Add request context to App.HasPermissionTo Adds rctx request.CTX as the first parameter of App.HasPermissionTo, letting its internal GetUser call use the real caller context instead of the request.EmptyContext fallback added earlier in this branch. Threads rctx through all internal callers (access_control.go, authorization.go permission wrappers, user.go, slashcommands), which all already had it in scope. The one caller without a request lifecycle (a permission-check closure registered during server bootstrap in server.go) falls back to request.EmptyContext, same as HasPermissionTo itself does for the frozen plugin API. PluginAPI.HasPermissionTo is frozen by the public plugin.API interface, so its signature is unchanged; it now forwards the plugin's own request context (PluginAPI.ctx) instead of dropping it, matching the pattern already used for GetUser and GetUserCustomStatus. Co-Authored-By: Claude Sonnet 5 * [MM-70230] Thread request context through getUserFromDB test helper All 13 callers already have th in scope, so passing th.Context is simpler than duplicating the helper's GetUser + require.Nil body at each call site. Co-Authored-By: Claude Sonnet 5 * [MM-70230] Reuse the tagged rctx for the user lookup in getSessionExpiredPushMessage The ack_id/session_id/user_id/deviceId/post_id fields the per-session rctx is tagged with were all already available before the getSessionExpiredPushMessage call (set on the two lines above it, or from the loop var/outer msg) -- the rctx was just built after it by ordering, not because of a real dependency. Moving the construction up lets the user lookup and the push send share one correlated, tagged context instead of the lookup using a bare request.EmptyContext. Co-Authored-By: Claude Sonnet 5 * [MM-70230] Thread real request context through PermissionChecker The permChecker closure registered in NewServer wasn't actually bootstrap-only: it's invoked later, per-request, from AccessControlAttributeValidationHook.enforceGroupPermissions, which already receives a real rctx from the property service's PreCreatePropertyField/PreUpdatePropertyField(s) hook calls (traced back to c.App.CreatePropertyField(rctx, ...) in api4). The PermissionChecker function type just didn't have a way to carry it, so the closure fell back to request.EmptyContext, discarding context that was available the whole time. Adds rctx request.CTX as PermissionChecker's first parameter and threads it from enforceGroupPermissions's own rctx through to permChecker's call to App.HasPermissionTo. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- server/channels/api4/apitestlib.go | 12 +-- server/channels/api4/bot.go | 4 +- server/channels/api4/channel.go | 32 +++---- server/channels/api4/channel_bookmark.go | 8 +- server/channels/api4/channel_category.go | 10 +-- server/channels/api4/channel_local.go | 2 +- server/channels/api4/channel_test.go | 2 +- server/channels/api4/cloud.go | 4 +- server/channels/api4/command.go | 2 +- server/channels/api4/ldap.go | 2 +- server/channels/api4/oauth.go | 2 +- server/channels/api4/post.go | 14 +-- server/channels/api4/post_test.go | 2 +- server/channels/api4/post_utils.go | 2 +- server/channels/api4/preference.go | 10 +-- server/channels/api4/recap.go | 2 +- server/channels/api4/remote_cluster.go | 2 +- server/channels/api4/shared_channel.go | 4 +- server/channels/api4/status.go | 8 +- server/channels/api4/team.go | 12 +-- server/channels/api4/terms_of_service.go | 2 +- server/channels/api4/terms_of_service_test.go | 2 +- server/channels/api4/user.go | 86 +++++++++---------- server/channels/api4/user_local.go | 4 +- server/channels/api4/user_test.go | 60 ++++++------- server/channels/api4/webhook.go | 4 +- server/channels/api4/webhook_local.go | 4 +- server/channels/api4/websocket_test.go | 2 +- server/channels/app/access_control.go | 10 +-- server/channels/app/admin.go | 2 +- server/channels/app/authentication.go | 6 +- server/channels/app/authentication_test.go | 52 +++++------ server/channels/app/authorization.go | 26 +++--- server/channels/app/authorization_test.go | 22 ++--- server/channels/app/auto_responder.go | 4 +- server/channels/app/auto_responder_test.go | 6 +- server/channels/app/bot.go | 4 +- server/channels/app/bot_test.go | 16 ++-- server/channels/app/channel.go | 24 +++--- .../app/channel_discoverable_visibility.go | 4 +- server/channels/app/channel_join_request.go | 2 +- server/channels/app/channel_test.go | 4 +- server/channels/app/content_flagging.go | 18 ++-- .../content_flagging_exposure_report_test.go | 2 +- .../channels/app/content_flagging_report.go | 16 ++-- server/channels/app/desktop_login.go | 5 +- server/channels/app/desktop_login_test.go | 8 +- .../app/expired_access_token_notify.go | 2 +- server/channels/app/expirynotify.go | 7 +- server/channels/app/file.go | 2 +- server/channels/app/helper_test.go | 8 +- server/channels/app/import_functions.go | 4 +- .../channels/app/integration_action_test.go | 2 +- server/channels/app/login.go | 2 +- server/channels/app/notification_test.go | 8 +- .../app/notify_expiring_access_tokens.go | 2 +- server/channels/app/oauth.go | 2 +- server/channels/app/oauth_test.go | 10 +-- server/channels/app/plugin_access_control.go | 18 ++-- server/channels/app/plugin_api.go | 18 ++-- server/channels/app/plugin_api_test.go | 10 +-- server/channels/app/plugin_hooks_test.go | 10 +-- server/channels/app/post.go | 6 +- server/channels/app/post_metadata.go | 2 +- server/channels/app/post_permission_utils.go | 6 +- .../app/post_persistent_notification_test.go | 4 +- server/channels/app/post_test.go | 24 +++--- .../access_control_attribute_validation.go | 4 +- ...ccess_control_attribute_validation_test.go | 7 +- server/channels/app/recap.go | 12 +-- server/channels/app/recap_limits.go | 13 +-- server/channels/app/report.go | 4 +- server/channels/app/scheduled_post_job.go | 6 +- server/channels/app/scheduled_recap.go | 2 +- server/channels/app/server.go | 4 +- server/channels/app/session.go | 2 +- ..._global_user_sync_self_referential_test.go | 4 +- ...l_membership_sync_self_referential_test.go | 2 +- server/channels/app/shared_channel_test.go | 6 +- .../app/slashcommands/command_groupmsg.go | 2 +- .../app/slashcommands/command_leave.go | 2 +- .../app/slashcommands/command_mobile_logs.go | 6 +- .../channels/app/slashcommands/command_msg.go | 2 +- .../app/slashcommands/command_remote.go | 4 +- .../app/slashcommands/command_share.go | 4 +- .../app/slashcommands/command_share_test.go | 12 +-- server/channels/app/status.go | 8 +- server/channels/app/status_test.go | 6 +- server/channels/app/team.go | 6 +- server/channels/app/team_access_control.go | 4 +- .../channels/app/team_directory_visibility.go | 4 +- server/channels/app/terms_of_service.go | 5 +- server/channels/app/user.go | 63 +++++++------- .../app/user_terms_of_service_test.go | 2 +- server/channels/app/user_test.go | 74 ++++++++-------- server/channels/jobs/helper_test.go | 6 +- server/cmd/mmctl/commands/ldap_e2e_test.go | 2 +- .../commands/permissions_role_e2e_test.go | 4 +- server/cmd/mmctl/commands/user_e2e_test.go | 42 ++++----- 99 files changed, 503 insertions(+), 499 deletions(-) diff --git a/server/channels/api4/apitestlib.go b/server/channels/api4/apitestlib.go index 46c9df472562..adce5353aed1 100644 --- a/server/channels/api4/apitestlib.go +++ b/server/channels/api4/apitestlib.go @@ -490,31 +490,31 @@ func (th *TestHelper) InitLogin(tb testing.TB) *TestHelper { systemAdminPassword := th.SystemAdminUser.Password _, appErr := th.App.UpdateUserRoles(th.Context, th.SystemAdminUser.Id, model.SystemUserRoleId+" "+model.SystemAdminRoleId, false) require.Nil(tb, appErr) - th.SystemAdminUser, appErr = th.App.GetUser(th.SystemAdminUser.Id) + th.SystemAdminUser, appErr = th.App.GetUser(th.Context, th.SystemAdminUser.Id) require.Nil(tb, appErr) th.SystemManagerUser = th.CreateUser(tb) systemManagerPassword := th.SystemManagerUser.Password _, appErr = th.App.UpdateUserRoles(th.Context, th.SystemManagerUser.Id, model.SystemUserRoleId+" "+model.SystemManagerRoleId, false) require.Nil(tb, appErr) - th.SystemManagerUser, appErr = th.App.GetUser(th.SystemManagerUser.Id) + th.SystemManagerUser, appErr = th.App.GetUser(th.Context, th.SystemManagerUser.Id) require.Nil(tb, appErr) th.TeamAdminUser = th.CreateUser(tb) teamAdminPassword := th.TeamAdminUser.Password _, appErr = th.App.UpdateUserRoles(th.Context, th.TeamAdminUser.Id, model.SystemUserRoleId, false) require.Nil(tb, appErr) - th.TeamAdminUser, appErr = th.App.GetUser(th.TeamAdminUser.Id) + th.TeamAdminUser, appErr = th.App.GetUser(th.Context, th.TeamAdminUser.Id) require.Nil(tb, appErr) th.BasicUser = th.CreateUser(tb) basicUserPassword := th.BasicUser.Password - th.BasicUser, appErr = th.App.GetUser(th.BasicUser.Id) + th.BasicUser, appErr = th.App.GetUser(th.Context, th.BasicUser.Id) require.Nil(tb, appErr) th.BasicUser2 = th.CreateUser(tb) basicUser2Password := th.BasicUser2.Password - th.BasicUser2, appErr = th.App.GetUser(th.BasicUser2.Id) + th.BasicUser2, appErr = th.App.GetUser(th.Context, th.BasicUser2.Id) require.Nil(tb, appErr) // restore non-hashed password for login @@ -1429,7 +1429,7 @@ func (th *TestHelper) SetUserRemoteID(tb testing.TB, userID, remoteID string) *m require.NoError(tb, err) th.App.InvalidateCacheForUser(userID) - user, appErr := th.App.GetUser(userID) + user, appErr := th.App.GetUser(th.Context, userID) require.Nil(tb, appErr) return user } diff --git a/server/channels/api4/bot.go b/server/channels/api4/bot.go index 9ff145cba287..d846625559dc 100644 --- a/server/channels/api4/bot.go +++ b/server/channels/api4/bot.go @@ -45,7 +45,7 @@ func createBot(c *Context, w http.ResponseWriter, r *http.Request) { return } - if user, err := c.App.GetUser(c.AppContext.Session().UserId); err == nil { + if user, err := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId); err == nil { if user.IsBot { c.SetPermissionError(model.PermissionCreateBot) return @@ -249,7 +249,7 @@ func assignBot(c *Context, w http.ResponseWriter, _ *http.Request) { return } - if user, err := c.App.GetUser(userId); err == nil { + if user, err := c.App.GetUser(c.AppContext, userId); err == nil { if user.IsBot { c.SetPermissionError(model.PermissionAssignBot) return diff --git a/server/channels/api4/channel.go b/server/channels/api4/channel.go index 58117bffd8da..3055922455f5 100644 --- a/server/channels/api4/channel.go +++ b/server/channels/api4/channel.go @@ -368,7 +368,7 @@ func updateChannelPrivacy(c *Context, w http.ResponseWriter, r *http.Request) { return } - user, err := c.App.GetUser(c.AppContext.Session().UserId) + user, err := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId) if err != nil { c.Err = err return @@ -713,7 +713,7 @@ func readAllMessages(c *Context, w http.ResponseWriter, r *http.Request) { defer c.LogAuditRec(auditRec) model.AddEventParameterToAuditRec(auditRec, "user_id", c.Params.UserId) - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -937,7 +937,7 @@ func discoverableNonMemberView(c *Context, channel *model.Channel) (*model.Chann if !c.App.Config().FeatureFlags.DiscoverableChannels { return nil, nil } - user, userErr := c.App.GetUser(c.AppContext.Session().UserId) + user, userErr := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId) if userErr != nil { return nil, userErr } @@ -982,7 +982,7 @@ func getChannelUnread(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -1388,7 +1388,7 @@ func getChannelsForTeamForUser(c *Context, w http.ResponseWriter, r *http.Reques return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -1439,7 +1439,7 @@ func getChannelsForUser(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -1755,7 +1755,7 @@ func deleteChannel(c *Context, w http.ResponseWriter, r *http.Request) { if *c.App.Config().ServiceSettings.EnableAPIChannelDeletion { err = c.App.PermanentDeleteChannel(c.AppContext, channel) } else { - user, usrErr := c.App.GetUser(c.AppContext.Session().UserId) + user, usrErr := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId) if usrErr == nil && user != nil && user.IsSystemAdmin() { // More verbose error message for system admins err = model.NewAppError("deleteChannel", "api.user.delete_channel.not_enabled.for_admin.app_error", nil, "channelId="+c.Params.ChannelId, http.StatusUnauthorized) @@ -2015,7 +2015,7 @@ func viewChannel(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -2076,7 +2076,7 @@ func readMultipleChannels(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -2113,7 +2113,7 @@ func readAllInTeam(c *Context, w http.ResponseWriter, r *http.Request) { model.AddEventParameterToAuditRec(auditRec, "user_id", c.Params.UserId) model.AddEventParameterToAuditRec(auditRec, "team_id", c.Params.TeamId) - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -2247,7 +2247,7 @@ func updateChannelMemberNotifyProps(c *Context, w http.ResponseWriter, r *http.R model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId) model.AddEventParameterToAuditRec(auditRec, "props", props) - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -2298,7 +2298,7 @@ func updateChannelMemberAutotranslation(c *Context, w http.ResponseWriter, r *ht model.AddEventParameterToAuditRec(auditRec, "autotranslation_disabled", props.AutoTranslationDisabled) model.AddEventParameterToAuditRec(auditRec, "user_id", c.Params.UserId) - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -2772,7 +2772,7 @@ func removeChannelMember(c *Context, w http.ResponseWriter, r *http.Request) { return } - user, err := c.App.GetUser(c.Params.UserId) + user, err := c.App.GetUser(c.AppContext, c.Params.UserId) if err != nil { c.Err = err return @@ -3114,7 +3114,7 @@ func moveChannel(c *Context, w http.ResponseWriter, r *http.Request) { return } - user, err := c.App.GetUser(c.AppContext.Session().UserId) + user, err := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId) if err != nil { c.Err = err return @@ -3158,7 +3158,7 @@ func getDirectOrGroupMessageMembersCommonTeams(c *Context, w http.ResponseWriter return } - user, err := c.App.GetUser(c.AppContext.Session().UserId) + user, err := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId) if err != nil { c.Err = err return @@ -3204,7 +3204,7 @@ func convertGroupMessageToChannel(c *Context, w http.ResponseWriter, r *http.Req return } - user, err := c.App.GetUser(c.AppContext.Session().UserId) + user, err := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId) if err != nil { c.Err = err return diff --git a/server/channels/api4/channel_bookmark.go b/server/channels/api4/channel_bookmark.go index d2ce53f04e98..111028e6031a 100644 --- a/server/channels/api4/channel_bookmark.go +++ b/server/channels/api4/channel_bookmark.go @@ -85,7 +85,7 @@ func createChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) { return } - user, gAppErr := c.App.GetUser(c.AppContext.Session().UserId) + user, gAppErr := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId) if gAppErr != nil { c.Err = gAppErr return @@ -178,7 +178,7 @@ func updateChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) { } isMember = true - user, gAppErr := c.App.GetUser(c.AppContext.Session().UserId) + user, gAppErr := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId) if gAppErr != nil { c.Err = gAppErr return @@ -298,7 +298,7 @@ func updateChannelBookmarkSortOrder(c *Context, w http.ResponseWriter, r *http.R } isMember = true - user, gAppErr := c.App.GetUser(c.AppContext.Session().UserId) + user, gAppErr := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId) if gAppErr != nil { c.Err = gAppErr return @@ -391,7 +391,7 @@ func deleteChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) { } isMember = true - user, gAppErr := c.App.GetUser(c.AppContext.Session().UserId) + user, gAppErr := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId) if gAppErr != nil { c.Err = gAppErr return diff --git a/server/channels/api4/channel_category.go b/server/channels/api4/channel_category.go index e13fd9cf0660..12f82e47cd73 100644 --- a/server/channels/api4/channel_category.go +++ b/server/channels/api4/channel_category.go @@ -17,7 +17,7 @@ func getCategoriesForTeamForUser(c *Context, w http.ResponseWriter, r *http.Requ return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -50,7 +50,7 @@ func createCategoryForTeamForUser(c *Context, w http.ResponseWriter, r *http.Req return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -100,7 +100,7 @@ func getCategoryOrderForTeamForUser(c *Context, w http.ResponseWriter, r *http.R return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -128,7 +128,7 @@ func updateCategoryOrderForTeamForUser(c *Context, w http.ResponseWriter, r *htt return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -206,7 +206,7 @@ func updateCategoriesForTeamForUser(c *Context, w http.ResponseWriter, r *http.R return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } diff --git a/server/channels/api4/channel_local.go b/server/channels/api4/channel_local.go index c0bb5911dcec..225487ab286c 100644 --- a/server/channels/api4/channel_local.go +++ b/server/channels/api4/channel_local.go @@ -247,7 +247,7 @@ func localRemoveChannelMember(c *Context, w http.ResponseWriter, r *http.Request return } - user, err := c.App.GetUser(c.Params.UserId) + user, err := c.App.GetUser(c.AppContext, c.Params.UserId) if err != nil { c.Err = err return diff --git a/server/channels/api4/channel_test.go b/server/channels/api4/channel_test.go index 04d080431056..574b59910f58 100644 --- a/server/channels/api4/channel_test.go +++ b/server/channels/api4/channel_test.go @@ -250,7 +250,7 @@ func TestCreateChannel(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GuestAccountsSettings.Enable = true }) guestUser := th.CreateUser(t) - appErr := th.App.VerifyUserEmail(guestUser.Id, guestUser.Email) + appErr := th.App.VerifyUserEmail(th.Context, guestUser.Id, guestUser.Email) require.Nil(t, appErr) appErr = th.App.DemoteUserToGuest(th.Context, guestUser) diff --git a/server/channels/api4/cloud.go b/server/channels/api4/cloud.go index 9b0f6db23b92..cc8b03cebcd9 100644 --- a/server/channels/api4/cloud.go +++ b/server/channels/api4/cloud.go @@ -157,7 +157,7 @@ func validateBusinessEmail(c *Context, w http.ResponseWriter, r *http.Request) { return } - user, appErr := c.App.GetUser(c.AppContext.Session().UserId) + user, appErr := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId) if appErr != nil { c.Err = model.NewAppError("Api4.validateBusinessEmail", "api.cloud.request_error", nil, "", http.StatusForbidden).Wrap(appErr) return @@ -207,7 +207,7 @@ func validateWorkspaceBusinessEmail(c *Context, w http.ResponseWriter, r *http.R return } - user, userErr := c.App.GetUser(c.AppContext.Session().UserId) + user, userErr := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId) if userErr != nil { c.Err = userErr return diff --git a/server/channels/api4/command.go b/server/channels/api4/command.go index 4588a62c309a..4b82d8722d6e 100644 --- a/server/channels/api4/command.go +++ b/server/channels/api4/command.go @@ -53,7 +53,7 @@ func createCommand(c *Context, w http.ResponseWriter, r *http.Request) { return } - if _, err := c.App.GetUser(cmd.CreatorId); err != nil { + if _, err := c.App.GetUser(c.AppContext, cmd.CreatorId); err != nil { c.Err = err return } diff --git a/server/channels/api4/ldap.go b/server/channels/api4/ldap.go index 0cb22310638c..78a2b54ad714 100644 --- a/server/channels/api4/ldap.go +++ b/server/channels/api4/ldap.go @@ -489,7 +489,7 @@ func addUserToGroupSyncables(c *Context, w http.ResponseWriter, r *http.Request) return } - user, appErr := c.App.GetUser(c.Params.UserId) + user, appErr := c.App.GetUser(c.AppContext, c.Params.UserId) if appErr != nil { c.Err = appErr return diff --git a/server/channels/api4/oauth.go b/server/channels/api4/oauth.go index ba6960f944c1..5286b3fd9355 100644 --- a/server/channels/api4/oauth.go +++ b/server/channels/api4/oauth.go @@ -316,7 +316,7 @@ func getAuthorizedOAuthApps(c *Context, w http.ResponseWriter, r *http.Request) return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } diff --git a/server/channels/api4/post.go b/server/channels/api4/post.go index d0bfb9e4e2ab..ce0c7cdc6613 100644 --- a/server/channels/api4/post.go +++ b/server/channels/api4/post.go @@ -399,7 +399,7 @@ func getPostsForChannelAroundLastUnread(c *Context, w http.ResponseWriter, r *ht } userId := c.Params.UserId - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), userId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), userId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -483,7 +483,7 @@ func getFlaggedPostsForUser(c *Context, w http.ResponseWriter, r *http.Request) return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -1308,7 +1308,7 @@ func setPostUnread(c *Context, w http.ResponseWriter, r *http.Request) { props := model.MapBoolFromJSON(r.Body) collapsedThreadsSupported := props["collapsed_threads_supported"] - if c.AppContext.Session().UserId != c.Params.UserId && !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if c.AppContext.Session().UserId != c.Params.UserId && !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -1333,7 +1333,7 @@ func setPostReminder(c *Context, w http.ResponseWriter, r *http.Request) { return } - if c.AppContext.Session().UserId != c.Params.UserId && !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if c.AppContext.Session().UserId != c.Params.UserId && !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -1443,7 +1443,7 @@ func acknowledgePost(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -1482,7 +1482,7 @@ func unacknowledgePost(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -1529,7 +1529,7 @@ func moveThread(c *Context, w http.ResponseWriter, r *http.Request) { model.AddEventParameterToAuditRec(auditRec, "original_post_id", c.Params.PostId) model.AddEventParameterToAuditRec(auditRec, "to_channel_id", moveThreadParams.ChannelId) - user, err := c.App.GetUser(c.AppContext.Session().UserId) + user, err := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId) if err != nil { c.Err = err return diff --git a/server/channels/api4/post_test.go b/server/channels/api4/post_test.go index af7b4bbe2012..7c50ce986b00 100644 --- a/server/channels/api4/post_test.go +++ b/server/channels/api4/post_test.go @@ -1687,7 +1687,7 @@ func TestCreatePostSilentQueryParam(t *testing.T) { *cfg.ServiceSettings.EnableBotAccountCreation = true }) bot := th.CreateBotWithSystemAdminClient(t) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) _, appErr = th.App.UpdateUserRoles(th.Context, bot.UserId, model.TeamUserRoleId+" "+model.SystemUserAccessTokenRoleId, false) require.Nil(t, appErr) diff --git a/server/channels/api4/post_utils.go b/server/channels/api4/post_utils.go index 54e6c5928d93..46dc06f53d60 100644 --- a/server/channels/api4/post_utils.go +++ b/server/channels/api4/post_utils.go @@ -36,7 +36,7 @@ func postHardenedModeCheckWithContext(where string, c *Context, props model.Stri } func postPriorityCheckWithContext(where string, c *Context, priority *model.PostPriority, rootId string) { - appErr := app.PostPriorityCheckWithApp(where, c.App, c.AppContext.Session().UserId, priority, rootId) + appErr := app.PostPriorityCheckWithApp(where, c.App, c.AppContext, c.AppContext.Session().UserId, priority, rootId) if appErr != nil { appErr.Where = where c.Err = appErr diff --git a/server/channels/api4/preference.go b/server/channels/api4/preference.go index 0bd11fd9773e..fea6ca6cee58 100644 --- a/server/channels/api4/preference.go +++ b/server/channels/api4/preference.go @@ -27,7 +27,7 @@ func getPreferences(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -49,7 +49,7 @@ func getPreferencesByCategory(c *Context, w http.ResponseWriter, r *http.Request return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -71,7 +71,7 @@ func getPreferenceByCategoryAndName(c *Context, w http.ResponseWriter, r *http.R return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -96,7 +96,7 @@ func updatePreferences(c *Context, w http.ResponseWriter, r *http.Request) { auditRec := c.MakeAuditRecord(model.AuditEventUpdatePreferences, model.AuditStatusFail) defer c.LogAuditRec(auditRec) - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -158,7 +158,7 @@ func deletePreferences(c *Context, w http.ResponseWriter, r *http.Request) { auditRec := c.MakeAuditRecord(model.AuditEventDeletePreferences, model.AuditStatusFail) defer c.LogAuditRec(auditRec) - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } diff --git a/server/channels/api4/recap.go b/server/channels/api4/recap.go index a5367736d3e0..6ef2bd801f56 100644 --- a/server/channels/api4/recap.go +++ b/server/channels/api4/recap.go @@ -51,7 +51,7 @@ func getRecapLimitStatus(c *Context, w http.ResponseWriter, r *http.Request) { userID := c.AppContext.Session().UserId - status, appErr := c.App.GetRecapLimitStatus(userID) + status, appErr := c.App.GetRecapLimitStatus(c.AppContext, userID) if appErr != nil { c.Err = appErr return diff --git a/server/channels/api4/remote_cluster.go b/server/channels/api4/remote_cluster.go index 01aba674274c..47b4a22c2926 100644 --- a/server/channels/api4/remote_cluster.go +++ b/server/channels/api4/remote_cluster.go @@ -289,7 +289,7 @@ func remoteSetProfileImage(c *Context, w http.ResponseWriter, r *http.Request) { model.AddEventParameterToAuditRec(auditRec, "filename", imageArray[0].Filename) } - user, err := c.App.GetUser(c.Params.UserId) + user, err := c.App.GetUser(c.AppContext, c.Params.UserId) if err != nil || !user.IsRemote() { c.SetInvalidURLParam("user_id") return diff --git a/server/channels/api4/shared_channel.go b/server/channels/api4/shared_channel.go index d0a3b82d868d..6761c852502a 100644 --- a/server/channels/api4/shared_channel.go +++ b/server/channels/api4/shared_channel.go @@ -46,7 +46,7 @@ func getSharedChannels(c *Context, w http.ResponseWriter, r *http.Request) { } // only return channels the user is a member of, unless they are a shared channels manager. - if !c.App.HasPermissionTo(c.AppContext.Session().UserId, model.PermissionManageSharedChannels) { + if !c.App.HasPermissionTo(c.AppContext, c.AppContext.Session().UserId, model.PermissionManageSharedChannels) { opts.MemberId = c.AppContext.Session().UserId } @@ -329,7 +329,7 @@ func canUserDirectMessage(c *Context, w http.ResponseWriter, r *http.Request) { // Get shared channel sync service for remote user checks scs := c.App.Srv().GetSharedChannelSyncService() if scs != nil { - otherUser, otherErr := c.App.GetUser(c.Params.OtherUserId) + otherUser, otherErr := c.App.GetUser(c.AppContext, c.Params.OtherUserId) if otherErr != nil { canDM = false } else { diff --git a/server/channels/api4/status.go b/server/channels/api4/status.go index 00f70ca490f3..d85211298dec 100644 --- a/server/channels/api4/status.go +++ b/server/channels/api4/status.go @@ -101,7 +101,7 @@ func updateUserStatus(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -151,7 +151,7 @@ func updateUserCustomStatus(c *Context, w http.ResponseWriter, r *http.Request) return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -177,7 +177,7 @@ func removeUserCustomStatus(c *Context, w http.ResponseWriter, r *http.Request) return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -207,7 +207,7 @@ func removeUserRecentCustomStatus(c *Context, w http.ResponseWriter, r *http.Req return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } diff --git a/server/channels/api4/team.go b/server/channels/api4/team.go index 1ee579bd07d2..f8b6ec6f10cb 100644 --- a/server/channels/api4/team.go +++ b/server/channels/api4/team.go @@ -703,7 +703,7 @@ func deleteTeam(c *Context, w http.ResponseWriter, r *http.Request) { if *c.App.Config().ServiceSettings.EnableAPITeamDeletion { err = c.App.PermanentDeleteTeamId(c.AppContext, c.Params.TeamId) } else { - user, usrErr := c.App.GetUser(c.AppContext.Session().UserId) + user, usrErr := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId) if usrErr == nil && user != nil && user.IsSystemAdmin() { // More verbose error message for system admins err = model.NewAppError("deleteTeam", "api.user.delete_team.not_enabled.for_admin.app_error", nil, "teamId="+c.Params.TeamId, http.StatusUnauthorized) @@ -879,7 +879,7 @@ func getTeamMembersForUser(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) && !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionReadOtherUsersTeams) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) && !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionReadOtherUsersTeams) { c.SetPermissionError(model.PermissionReadOtherUsersTeams) return } @@ -1028,7 +1028,7 @@ func addTeamMember(c *Context, w http.ResponseWriter, r *http.Request) { canInviteGuests := c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionInviteGuest) if !canInviteGuests { - user, err := c.App.GetUser(member.UserId) + user, err := c.App.GetUser(c.AppContext, member.UserId) if err != nil { c.Err = model.NewAppError("addTeamMembers", "api.team.user.missing_account", nil, "", http.StatusNotFound).Wrap(err) return @@ -1203,7 +1203,7 @@ func addTeamMembers(c *Context, w http.ResponseWriter, r *http.Request) { // if user cannot invite guests, check if any users are guest users. if !canInviteGuests { - user, err := c.App.GetUser(member.UserId) + user, err := c.App.GetUser(c.AppContext, member.UserId) if err != nil { c.Err = model.NewAppError("addTeamMembers", "api.team.user.missing_account", nil, "", http.StatusNotFound).Wrap(err) return @@ -1290,7 +1290,7 @@ func removeTeamMember(c *Context, w http.ResponseWriter, r *http.Request) { } model.AddEventParameterAuditableToAuditRec(auditRec, "team", team) - user, err := c.App.GetUser(c.Params.UserId) + user, err := c.App.GetUser(c.AppContext, c.Params.UserId) if err != nil { c.Err = err return @@ -1317,7 +1317,7 @@ func getTeamUnread(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } diff --git a/server/channels/api4/terms_of_service.go b/server/channels/api4/terms_of_service.go index 0fc09c3c6a97..cfbfb24e98e9 100644 --- a/server/channels/api4/terms_of_service.go +++ b/server/channels/api4/terms_of_service.go @@ -59,7 +59,7 @@ func createTermsOfService(c *Context, w http.ResponseWriter, r *http.Request) { } if oldTermsOfService == nil || oldTermsOfService.Text != text { - termsOfService, err := c.App.CreateTermsOfService(text, userId) + termsOfService, err := c.App.CreateTermsOfService(c.AppContext, text, userId) if err != nil { c.Err = err return diff --git a/server/channels/api4/terms_of_service_test.go b/server/channels/api4/terms_of_service_test.go index 6a9340a34a3b..cd130bae0782 100644 --- a/server/channels/api4/terms_of_service_test.go +++ b/server/channels/api4/terms_of_service_test.go @@ -18,7 +18,7 @@ func TestGetTermsOfService(t *testing.T) { th := Setup(t).InitBasic(t) client := th.Client - _, appErr := th.App.CreateTermsOfService("abc", th.BasicUser.Id) + _, appErr := th.App.CreateTermsOfService(th.Context, "abc", th.BasicUser.Id) require.Nil(t, appErr) termsOfService, _, err := client.GetTermsOfService(context.Background(), "") diff --git a/server/channels/api4/user.go b/server/channels/api4/user.go index 4800e96f9bd2..04de459e6809 100644 --- a/server/channels/api4/user.go +++ b/server/channels/api4/user.go @@ -208,7 +208,7 @@ func loginSSOCodeExchange(c *Context, w http.ResponseWriter, r *http.Request) { } // Create session for this user - user, err := c.App.GetUser(userID) + user, err := c.App.GetUser(c.AppContext, userID) if err != nil { c.Err = err return @@ -319,7 +319,7 @@ func getUser(c *Context, w http.ResponseWriter, r *http.Request) { return } - user, err := c.App.GetUser(c.Params.UserId) + user, err := c.App.GetUser(c.AppContext, c.Params.UserId) if err != nil { c.Err = err return @@ -541,7 +541,7 @@ func getDefaultProfileImage(c *Context, w http.ResponseWriter, r *http.Request) return } - user, err := c.App.GetUser(c.Params.UserId) + user, err := c.App.GetUser(c.AppContext, c.Params.UserId) if err != nil { c.Err = err return @@ -577,7 +577,7 @@ func getProfileImage(c *Context, w http.ResponseWriter, r *http.Request) { return } - user, err := c.App.GetUser(c.Params.UserId) + user, err := c.App.GetUser(c.AppContext, c.Params.UserId) if err != nil { c.Err = err return @@ -657,7 +657,7 @@ func setProfileImage(c *Context, w http.ResponseWriter, r *http.Request) { model.AddEventParameterToAuditRec(auditRec, "filename", imageArray[0].Filename) } - user, err := c.App.GetUser(c.Params.UserId) + user, err := c.App.GetUser(c.AppContext, c.Params.UserId) if err != nil { c.SetInvalidURLParam("user_id") return @@ -711,7 +711,7 @@ func setDefaultProfileImage(c *Context, w http.ResponseWriter, r *http.Request) model.AddEventParameterToAuditRec(auditRec, "user_id", c.Params.UserId) defer c.LogAuditRec(auditRec) - user, err := c.App.GetUser(c.Params.UserId) + user, err := c.App.GetUser(c.AppContext, c.Params.UserId) if err != nil { c.Err = err return @@ -1126,7 +1126,7 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { if sort == "display_name" { var user *model.User - user, appErr = c.App.GetUser(c.AppContext.Session().UserId) + user, appErr = c.App.GetUser(c.AppContext, c.AppContext.Session().UserId) if appErr != nil { c.Err = appErr return @@ -1506,7 +1506,7 @@ func updateUser(c *Context, w http.ResponseWriter, r *http.Request) { return } - ouser, err := c.App.GetUser(user.Id) + ouser, err := c.App.GetUser(c.AppContext, user.Id) if err != nil { c.Err = err return @@ -1592,7 +1592,7 @@ func patchUser(c *Context, w http.ResponseWriter, r *http.Request) { return } - ouser, err := c.App.GetUser(c.Params.UserId) + ouser, err := c.App.GetUser(c.AppContext, c.Params.UserId) if err != nil { c.SetInvalidParam("user_id") return @@ -1685,7 +1685,7 @@ func deleteUser(c *Context, w http.ResponseWriter, r *http.Request) { return } - user, err := c.App.GetUser(userId) + user, err := c.App.GetUser(c.AppContext, userId) if err != nil { c.Err = err return @@ -1703,7 +1703,7 @@ func deleteUser(c *Context, w http.ResponseWriter, r *http.Request) { if *c.App.Config().ServiceSettings.EnableAPIUserDeletion { err = c.App.PermanentDeleteUser(c.AppContext, user) } else { - loggedUser, usrErr := c.App.GetUser(c.AppContext.Session().UserId) + loggedUser, usrErr := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId) if usrErr == nil && loggedUser != nil && loggedUser.IsSystemAdmin() { // More verbose error message for system admins err = model.NewAppError("deleteUser", "api.user.delete_user.not_enabled.for_admin.app_error", nil, "userId="+c.Params.UserId, http.StatusUnauthorized) @@ -1804,7 +1804,7 @@ func updateUserActive(c *Context, w http.ResponseWriter, r *http.Request) { return } - user, err := c.App.GetUser(c.Params.UserId) + user, err := c.App.GetUser(c.AppContext, c.Params.UserId) if err != nil { c.Err = err return @@ -1887,7 +1887,7 @@ func updateUserAuth(c *Context, w http.ResponseWriter, r *http.Request) { userAuth.AuthService = "" } - if user, err := c.App.GetUser(c.Params.UserId); err == nil { + if user, err := c.App.GetUser(c.AppContext, c.Params.UserId); err == nil { auditRec.AddEventPriorState(user) } @@ -1922,7 +1922,7 @@ func updateUserMfa(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -1932,7 +1932,7 @@ func updateUserMfa(c *Context, w http.ResponseWriter, r *http.Request) { return } - if user, appErr := c.App.GetUser(c.Params.UserId); appErr == nil { + if user, appErr := c.App.GetUser(c.AppContext, c.Params.UserId); appErr == nil { model.AddEventParameterAuditableToAuditRec(auditRec, "user", user) } @@ -1978,12 +1978,12 @@ func generateMfaSecret(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } - secret, err := c.App.GenerateMfaSecret(c.Params.UserId) + secret, err := c.App.GenerateMfaSecret(c.AppContext, c.Params.UserId) if err != nil { c.Err = err return @@ -2011,7 +2011,7 @@ func updatePassword(c *Context, w http.ResponseWriter, r *http.Request) { c.LogAudit("attempted") var canUpdatePassword bool - if user, err := c.App.GetUser(c.Params.UserId); err == nil { + if user, err := c.App.GetUser(c.AppContext, c.Params.UserId); err == nil { model.AddEventParameterAuditableToAuditRec(auditRec, "user", user) if user.IsSystemAdmin() { @@ -2029,7 +2029,7 @@ func updatePassword(c *Context, w http.ResponseWriter, r *http.Request) { // is already hashed or not. if props["already_hashed"] == "true" { if canUpdatePassword { - err = c.App.UpdateHashedPasswordByUserId(c.Params.UserId, newPassword) + err = c.App.UpdateHashedPasswordByUserId(c.AppContext, c.Params.UserId, newPassword) } else if c.Params.UserId == c.AppContext.Session().UserId { err = model.NewAppError("updatePassword", "api.user.update_password.user_and_hashed.app_error", nil, "", http.StatusUnauthorized) } else { @@ -2307,7 +2307,7 @@ func loginWithDesktopToken(c *Context, w http.ResponseWriter, r *http.Request) { auditRec.AddMeta("login_method", "desktop_token") model.AddEventParameterToAuditRec(auditRec, "device_id", model.RedactDeviceId(deviceId)) - user, err := c.App.ValidateDesktopToken(token, time.Now().Add(-model.DesktopTokenTTL).Unix()) + user, err := c.App.ValidateDesktopToken(c.AppContext, token, time.Now().Add(-model.DesktopTokenTTL).Unix()) if err != nil { c.Err = err return @@ -2573,7 +2573,7 @@ func getSessions(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -2834,11 +2834,11 @@ func getUserAudits(c *Context, w http.ResponseWriter, r *http.Request) { model.AddEventParameterToAuditRec(auditRec, "user_id", c.Params.UserId) defer c.LogAuditRec(auditRec) - if user, err := c.App.GetUser(c.Params.UserId); err == nil { + if user, err := c.App.GetUser(c.AppContext, c.Params.UserId); err == nil { model.AddEventParameterAuditableToAuditRec(auditRec, "user", user) } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -2977,7 +2977,7 @@ func createUserAccessToken(c *Context, w http.ResponseWriter, r *http.Request) { model.AddEventParameterToAuditRec(auditRec, "user_id", c.Params.UserId) defer c.LogAuditRec(auditRec) - user, err := c.App.GetUser(c.Params.UserId) + user, err := c.App.GetUser(c.AppContext, c.Params.UserId) if err != nil { c.Err = err return @@ -3242,7 +3242,7 @@ func revokeUserAccessToken(c *Context, w http.ResponseWriter, r *http.Request) { return } - if user, errGet := c.App.GetUser(accessToken.UserId); errGet == nil { + if user, errGet := c.App.GetUser(c.AppContext, accessToken.UserId); errGet == nil { model.AddEventParameterAuditableToAuditRec(auditRec, "user", user) } @@ -3293,7 +3293,7 @@ func disableUserAccessToken(c *Context, w http.ResponseWriter, r *http.Request) return } - if user, errGet := c.App.GetUser(accessToken.UserId); errGet == nil { + if user, errGet := c.App.GetUser(c.AppContext, accessToken.UserId); errGet == nil { model.AddEventParameterAuditableToAuditRec(auditRec, "user", user) } @@ -3344,7 +3344,7 @@ func enableUserAccessToken(c *Context, w http.ResponseWriter, r *http.Request) { return } - if user, errGet := c.App.GetUser(accessToken.UserId); errGet == nil { + if user, errGet := c.App.GetUser(c.AppContext, accessToken.UserId); errGet == nil { model.AddEventParameterAuditableToAuditRec(auditRec, "user", user) } @@ -3401,7 +3401,7 @@ func rotateUserAccessToken(c *Context, w http.ResponseWriter, r *http.Request) { return } - user, errGet := c.App.GetUser(accessToken.UserId) + user, errGet := c.App.GetUser(c.AppContext, accessToken.UserId) if errGet != nil { c.Err = errGet return @@ -3464,7 +3464,7 @@ func saveUserTermsOfService(c *Context, w http.ResponseWriter, r *http.Request) } model.AddEventParameterToAuditRec(auditRec, "accepted", accepted) - if user, err := c.App.GetUser(userId); err == nil { + if user, err := c.App.GetUser(c.AppContext, userId); err == nil { model.AddEventParameterAuditableToAuditRec(auditRec, "user", user) } @@ -3511,7 +3511,7 @@ func promoteGuestToUser(c *Context, w http.ResponseWriter, r *http.Request) { return } - user, err := c.App.GetUser(c.Params.UserId) + user, err := c.App.GetUser(c.AppContext, c.Params.UserId) if err != nil { c.Err = err return @@ -3569,7 +3569,7 @@ func demoteUserToGuest(c *Context, w http.ResponseWriter, r *http.Request) { return } - user, err := c.App.GetUser(c.Params.UserId) + user, err := c.App.GetUser(c.AppContext, c.Params.UserId) if err != nil { c.Err = err return @@ -3632,7 +3632,7 @@ func verifyUserEmailWithoutToken(c *Context, w http.ResponseWriter, r *http.Requ return } - user, err := c.App.GetUser(c.Params.UserId) + user, err := c.App.GetUser(c.AppContext, c.Params.UserId) if err != nil { c.Err = err return @@ -3648,7 +3648,7 @@ func verifyUserEmailWithoutToken(c *Context, w http.ResponseWriter, r *http.Requ return } - if err := c.App.VerifyUserEmail(user.Id, user.Email); err != nil { + if err := c.App.VerifyUserEmail(c.AppContext, user.Id, user.Email); err != nil { c.Err = err return } @@ -3668,7 +3668,7 @@ func convertUserToBot(c *Context, w http.ResponseWriter, r *http.Request) { return } - user, appErr := c.App.GetUser(c.Params.UserId) + user, appErr := c.App.GetUser(c.AppContext, c.Params.UserId) if appErr != nil { c.Err = appErr return @@ -3740,7 +3740,7 @@ func getChannelMembersForUser(c *Context, w http.ResponseWriter, r *http.Request return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -3936,7 +3936,7 @@ func getThreadForUser(c *Context, w http.ResponseWriter, r *http.Request) { if c.Err != nil { return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -3979,7 +3979,7 @@ func getThreadsForUser(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -4062,7 +4062,7 @@ func updateReadStateThreadByUser(c *Context, w http.ResponseWriter, r *http.Requ model.AddEventParameterToAuditRec(auditRec, "thread_id", c.Params.ThreadId) model.AddEventParameterToAuditRec(auditRec, "team_id", c.Params.TeamId) model.AddEventParameterToAuditRec(auditRec, "timestamp", c.Params.Timestamp) - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -4102,7 +4102,7 @@ func setUnreadThreadByPostId(c *Context, w http.ResponseWriter, r *http.Request) model.AddEventParameterToAuditRec(auditRec, "team_id", c.Params.TeamId) model.AddEventParameterToAuditRec(auditRec, "post_id", c.Params.PostId) - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -4149,7 +4149,7 @@ func unfollowThreadByUser(c *Context, w http.ResponseWriter, r *http.Request) { model.AddEventParameterToAuditRec(auditRec, "thread_id", c.Params.ThreadId) model.AddEventParameterToAuditRec(auditRec, "team_id", c.Params.TeamId) - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -4181,7 +4181,7 @@ func followThreadByUser(c *Context, w http.ResponseWriter, r *http.Request) { model.AddEventParameterToAuditRec(auditRec, "thread_id", c.Params.ThreadId) model.AddEventParameterToAuditRec(auditRec, "team_id", c.Params.TeamId) - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -4212,7 +4212,7 @@ func updateReadStateAllThreadsByUser(c *Context, w http.ResponseWriter, r *http. model.AddEventParameterToAuditRec(auditRec, "user_id", c.Params.UserId) model.AddEventParameterToAuditRec(auditRec, "team_id", c.Params.TeamId) - if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PermissionEditOtherUsers) return } @@ -4269,7 +4269,7 @@ func resetPasswordFailedAttempts(c *Context, w http.ResponseWriter, r *http.Requ return } - user, err := c.App.GetUser(c.Params.UserId) + user, err := c.App.GetUser(c.AppContext, c.Params.UserId) if err != nil { c.Err = err return diff --git a/server/channels/api4/user_local.go b/server/channels/api4/user_local.go index 2747a92d6194..e157082629ec 100644 --- a/server/channels/api4/user_local.go +++ b/server/channels/api4/user_local.go @@ -284,7 +284,7 @@ func localGetUser(c *Context, w http.ResponseWriter, r *http.Request) { return } - user, err := c.App.GetUser(c.Params.UserId) + user, err := c.App.GetUser(c.AppContext, c.Params.UserId) if err != nil { c.Err = err return @@ -325,7 +325,7 @@ func localDeleteUser(c *Context, w http.ResponseWriter, r *http.Request) { auditRec := c.MakeAuditRecord(model.AuditEventLocalDeleteUser, model.AuditStatusFail) defer c.LogAuditRec(auditRec) - user, err := c.App.GetUser(userId) + user, err := c.App.GetUser(c.AppContext, userId) if err != nil { c.Err = err return diff --git a/server/channels/api4/user_test.go b/server/channels/api4/user_test.go index f23ab512efa3..dd0597cc5664 100644 --- a/server/channels/api4/user_test.go +++ b/server/channels/api4/user_test.go @@ -1173,7 +1173,7 @@ func TestGetUserWithAcceptedTermsOfServiceForOtherUser(t *testing.T) { user := th.CreateUser(t) - tos, _ := th.App.CreateTermsOfService("Dummy TOS", user.Id) + tos, _ := th.App.CreateTermsOfService(th.Context, "Dummy TOS", user.Id) _, appErr := th.App.UpdateUser(th.Context, user, false) require.Nil(t, appErr) @@ -1205,7 +1205,7 @@ func TestGetUserWithAcceptedTermsOfService(t *testing.T) { user := th.BasicUser - tos, _ := th.App.CreateTermsOfService("Dummy TOS", user.Id) + tos, _ := th.App.CreateTermsOfService(th.Context, "Dummy TOS", user.Id) ruser, _, err := th.Client.GetUser(context.Background(), user.Id, "") require.NoError(t, err) @@ -1235,7 +1235,7 @@ func TestGetUserWithAcceptedTermsOfServiceWithAdminUser(t *testing.T) { th.LoginSystemAdmin(t) user := th.BasicUser - tos, appErr := th.App.CreateTermsOfService("Dummy TOS", user.Id) + tos, appErr := th.App.CreateTermsOfService(th.Context, "Dummy TOS", user.Id) require.Nil(t, appErr) ruser, _, err := th.SystemAdminClient.GetUser(context.Background(), user.Id, "") @@ -1357,7 +1357,7 @@ func TestGetUserByUsernameWithAcceptedTermsOfService(t *testing.T) { require.Equal(t, user.Email, ruser.Email) - tos, appErr := th.App.CreateTermsOfService("Dummy TOS", user.Id) + tos, appErr := th.App.CreateTermsOfService(th.Context, "Dummy TOS", user.Id) require.Nil(t, appErr) appErr = th.App.SaveUserTermsOfService(ruser.Id, tos.Id, true) require.Nil(t, appErr) @@ -1556,7 +1556,7 @@ func TestGetUserByAuthData(t *testing.T) { }) t.Run("returns accepted terms of service for system admin", func(t *testing.T) { - tos, appErr := th.App.CreateTermsOfService("Dummy TOS", user.Id) + tos, appErr := th.App.CreateTermsOfService(th.Context, "Dummy TOS", user.Id) require.Nil(t, appErr) appErr = th.App.SaveUserTermsOfService(user.Id, tos.Id, true) require.Nil(t, appErr) @@ -2533,7 +2533,7 @@ func TestUpdateUserRemoteIdIgnored(t *testing.T) { require.Equal(t, "updated-nickname", ruser.Nickname) require.Empty(t, model.SafeDereference(ruser.RemoteId), "remote_id should remain empty") - dbUser, appErr := th.App.GetUser(user.Id) + dbUser, appErr := th.App.GetUser(th.Context, user.Id) require.Nil(t, appErr) require.Empty(t, model.SafeDereference(dbUser.RemoteId), "remote_id should not be persisted") }) @@ -2656,7 +2656,7 @@ func TestPatchUser(t *testing.T) { require.NotNil(t, appErr, "Password should not match") currentPassword := user.Password - user, appErr = th.App.GetUser(ruser.Id) + user, appErr = th.App.GetUser(th.Context, ruser.Id) require.Nil(t, appErr) appErr = th.App.CheckPasswordAndAllCriteria(th.Context, user.Id, currentPassword, "") @@ -2737,7 +2737,7 @@ func TestPatchUserRemoteIdIgnored(t *testing.T) { require.Equal(t, "new-nickname", ruser.Nickname) require.Empty(t, model.SafeDereference(ruser.RemoteId), "remote_id should remain empty") - dbUser, appErr := th.App.GetUser(user.Id) + dbUser, appErr := th.App.GetUser(th.Context, user.Id) require.Nil(t, appErr) require.Empty(t, model.SafeDereference(dbUser.RemoteId), "remote_id should not be persisted") }) @@ -2754,7 +2754,7 @@ func TestPatchUserRemoteIdIgnored(t *testing.T) { require.Equal(t, "admin-patched", ruser.Nickname) require.Empty(t, model.SafeDereference(ruser.RemoteId), "remote_id should remain empty even for admin") - dbUser, appErr := th.App.GetUser(user.Id) + dbUser, appErr := th.App.GetUser(th.Context, user.Id) require.Nil(t, appErr) require.Empty(t, model.SafeDereference(dbUser.RemoteId), "remote_id should not be persisted even for admin") }) @@ -2919,7 +2919,7 @@ func TestUpdateUserAuth(t *testing.T) { _, resp, err := th.SystemAdminClient.UpdateUserAuth(context.Background(), user.Id, userAuth) require.Error(t, err) CheckBadRequestStatus(t, resp) - storedUser, appErr := th.App.GetUser(user.Id) + storedUser, appErr := th.App.GetUser(th.Context, user.Id) require.Nil(t, appErr) require.Equal(t, model.UserAuthServiceSaml, storedUser.AuthService) require.NotNil(t, storedUser.AuthData) @@ -2930,7 +2930,7 @@ func TestUpdateUserAuth(t *testing.T) { _, resp, err = th.SystemAdminClient.UpdateUserAuth(context.Background(), user.Id, userAuth) require.Error(t, err) CheckBadRequestStatus(t, resp) - storedUser, appErr = th.App.GetUser(user.Id) + storedUser, appErr = th.App.GetUser(th.Context, user.Id) require.Nil(t, appErr) require.Equal(t, model.UserAuthServiceSaml, storedUser.AuthService) require.NotNil(t, storedUser.AuthData) @@ -2943,7 +2943,7 @@ func TestUpdateUserAuth(t *testing.T) { CheckOKStatus(t, resp) require.Nil(t, ruser.AuthData) require.Empty(t, ruser.AuthService) - storedUser, appErr = th.App.GetUser(user.Id) + storedUser, appErr = th.App.GetUser(th.Context, user.Id) require.Nil(t, appErr) require.Nil(t, storedUser.AuthData) require.Empty(t, storedUser.AuthService) @@ -4187,7 +4187,7 @@ func TestUserLoginMFAFlow(t *testing.T) { }) t.Run("WithInvalidMFA", func(t *testing.T) { - secret, appErr := th.App.GenerateMfaSecret(th.BasicUser.Id) + secret, appErr := th.App.GenerateMfaSecret(th.Context, th.BasicUser.Id) assert.Nil(t, appErr) // Fake user has MFA enabled @@ -4212,7 +4212,7 @@ func TestUserLoginMFAFlow(t *testing.T) { CheckErrorID(t, err, "mfa.validate_token.authenticate.app_error") assert.Nil(t, user) - secret2, appErr := th.App.GenerateMfaSecret(th.BasicUser2.Id) + secret2, appErr := th.App.GenerateMfaSecret(th.Context, th.BasicUser2.Id) assert.Nil(t, appErr) user, _, err = th.Client.LoginWithMFA(context.Background(), th.BasicUser.Email, th.BasicUser.Password, secret2.Secret) CheckErrorID(t, err, "mfa.validate_token.authenticate.app_error") @@ -4220,7 +4220,7 @@ func TestUserLoginMFAFlow(t *testing.T) { }) t.Run("WithCorrectMFA", func(t *testing.T) { - secret, appErr := th.App.GenerateMfaSecret(th.BasicUser.Id) + secret, appErr := th.App.GenerateMfaSecret(th.Context, th.BasicUser.Id) assert.Nil(t, appErr) // Fake user has MFA enabled @@ -5133,13 +5133,13 @@ func TestSetProfileImage(t *testing.T) { require.Fail(t, "Should have failed either forbidden or unauthorized") } - buser, appErr := th.App.GetUser(user.Id) + buser, appErr := th.App.GetUser(th.Context, user.Id) require.Nil(t, appErr) _, err = th.SystemAdminClient.SetProfileImage(context.Background(), user.Id, data) require.NoError(t, err) - ruser, appErr := th.App.GetUser(user.Id) + ruser, appErr := th.App.GetUser(th.Context, user.Id) require.Nil(t, appErr) assert.True(t, buser.LastPictureUpdate == ruser.LastPictureUpdate, "Same picture should not have updated") @@ -5149,7 +5149,7 @@ func TestSetProfileImage(t *testing.T) { _, err = th.SystemAdminClient.SetProfileImage(context.Background(), user.Id, data2) require.NoError(t, err) - ruser, appErr = th.App.GetUser(user.Id) + ruser, appErr = th.App.GetUser(th.Context, user.Id) require.Nil(t, appErr) assert.True(t, buser.LastPictureUpdate < ruser.LastPictureUpdate, "Picture should have updated for user") @@ -5170,7 +5170,7 @@ func TestSetDefaultProfileImage(t *testing.T) { _, err := th.Client.SetDefaultProfileImage(context.Background(), user.Id) require.NoError(t, err) - iuser, getUserErr := th.App.GetUser(user.Id) + iuser, getUserErr := th.App.GetUser(th.Context, user.Id) require.Nil(t, getUserErr) assert.Less(t, iuser.LastPictureUpdate, -startTime, "LastPictureUpdate should be set to -(current time in milliseconds)") @@ -5205,7 +5205,7 @@ func TestSetDefaultProfileImage(t *testing.T) { _, err = th.SystemAdminClient.SetDefaultProfileImage(context.Background(), anotherAdmin.Id) require.NoError(t, err) - ruser, appErr := th.App.GetUser(user.Id) + ruser, appErr := th.App.GetUser(th.Context, user.Id) require.Nil(t, appErr) assert.Less(t, ruser.LastPictureUpdate, iuser.LastPictureUpdate, "LastPictureUpdate should be updated to a lower negative number") @@ -5273,7 +5273,7 @@ func TestLogin(t *testing.T) { }) t.Run("login with terms_of_service set", func(t *testing.T) { - termsOfService, appErr := th.App.CreateTermsOfService("terms of service", th.BasicUser.Id) + termsOfService, appErr := th.App.CreateTermsOfService(th.Context, "terms of service", th.BasicUser.Id) require.Nil(t, appErr) _, err := th.Client.RegisterTermsOfServiceAction(context.Background(), th.BasicUser.Id, termsOfService.Id, true) @@ -5563,7 +5563,7 @@ func TestSwitchAccount(t *testing.T) { _, err := th.App.Srv().Store().User().UpdateAuthData(th.BasicUser.Id, "", nil, "", true) require.NoError(t, err) - user, appErr := th.App.GetUser(th.BasicUser.Id) + user, appErr := th.App.GetUser(th.Context, th.BasicUser.Id) require.Nil(t, appErr) appErr = th.App.UpdatePassword(th.Context, user, th.BasicUser.Password) require.Nil(t, appErr) @@ -5718,7 +5718,7 @@ func TestSwitchAccount(t *testing.T) { // The account must remain attached to its login provider th.App.InvalidateCacheForUser(th.BasicUser.Id) - user, appErr := th.App.GetUser(th.BasicUser.Id) + user, appErr := th.App.GetUser(th.Context, th.BasicUser.Id) require.Nil(t, appErr) require.Equal(t, model.UserAuthServiceGitlab, user.AuthService) }) @@ -7648,13 +7648,13 @@ func TestRegisterTermsOfServiceAction(t *testing.T) { _, err := th.Client.RegisterTermsOfServiceAction(context.Background(), th.BasicUser.Id, "st_1", true) CheckErrorID(t, err, "app.terms_of_service.get.no_rows.app_error") - termsOfService, appErr := th.App.CreateTermsOfService("terms of service", th.BasicUser.Id) + termsOfService, appErr := th.App.CreateTermsOfService(th.Context, "terms of service", th.BasicUser.Id) require.Nil(t, appErr) _, err = th.Client.RegisterTermsOfServiceAction(context.Background(), th.BasicUser.Id, termsOfService.Id, true) require.NoError(t, err) - _, appErr = th.App.GetUser(th.BasicUser.Id) + _, appErr = th.App.GetUser(th.Context, th.BasicUser.Id) require.Nil(t, appErr) } @@ -7666,7 +7666,7 @@ func TestGetUserTermsOfService(t *testing.T) { _, _, err := th.Client.GetUserTermsOfService(context.Background(), th.BasicUser.Id, "") CheckErrorID(t, err, "app.user_terms_of_service.get_by_user.no_rows.app_error") - termsOfService, appErr := th.App.CreateTermsOfService("terms of service", th.BasicUser.Id) + termsOfService, appErr := th.App.CreateTermsOfService(th.Context, "terms of service", th.BasicUser.Id) require.Nil(t, appErr) _, err = th.Client.RegisterTermsOfServiceAction(context.Background(), th.BasicUser.Id, termsOfService.Id, true) @@ -7985,13 +7985,13 @@ func TestVerifyUserEmailWithoutToken(t *testing.T) { require.Nil(t, appErr) // Set up MFA secret for the user - secret, appErr := th.App.GenerateMfaSecret(ruser.Id) + secret, appErr := th.App.GenerateMfaSecret(th.Context, ruser.Id) require.Nil(t, appErr) err = th.Server.Store().User().UpdateMfaSecret(ruser.Id, secret.Secret) require.NoError(t, err) // Verify the user has a password hash and MFA secret in the database - dbUser, appErr := th.App.GetUser(ruser.Id) + dbUser, appErr := th.App.GetUser(th.Context, ruser.Id) require.Nil(t, appErr) require.NotEmpty(t, dbUser.Password, "User should have a password hash in database") require.NotEmpty(t, dbUser.MfaSecret, "User should have MFA secret in database") @@ -10022,7 +10022,7 @@ func TestLockProfileFieldsForEmailUsers(t *testing.T) { setLock(model.TeamSettingsLockProfileFieldsNameAndUsername) setNames("First", "Last") - user, appErr := th.App.GetUser(th.BasicUser.Id) + user, appErr := th.App.GetUser(th.Context, th.BasicUser.Id) require.Nil(t, appErr) user.Nickname = "UpdatedNick" _, _, err := th.Client.UpdateUser(context.Background(), user) @@ -11338,7 +11338,7 @@ func TestSearchUsersWithMfaEnforced(t *testing.T) { t.Run("user with MFA active can search users", func(t *testing.T) { userWithMFAOK := th.BasicUser - secret, appErr := th.App.GenerateMfaSecret(userWithMFAOK.Id) + secret, appErr := th.App.GenerateMfaSecret(th.Context, userWithMFAOK.Id) assert.Nil(t, appErr) // Fake user has MFA enabled diff --git a/server/channels/api4/webhook.go b/server/channels/api4/webhook.go index 37d0b6f0e32e..0caa107212d8 100644 --- a/server/channels/api4/webhook.go +++ b/server/channels/api4/webhook.go @@ -65,7 +65,7 @@ func createIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) { } var hookUser *model.User - if hookUser, err = c.App.GetUser(hook.UserId); err != nil { + if hookUser, err = c.App.GetUser(c.AppContext, hook.UserId); err != nil { c.Err = err return } @@ -484,7 +484,7 @@ func createOutgoingHook(c *Context, w http.ResponseWriter, r *http.Request) { return } - _, err := c.App.GetUser(hook.CreatorId) + _, err := c.App.GetUser(c.AppContext, hook.CreatorId) if err != nil { c.Err = err return diff --git a/server/channels/api4/webhook_local.go b/server/channels/api4/webhook_local.go index de39f90311e4..c668defec955 100644 --- a/server/channels/api4/webhook_local.go +++ b/server/channels/api4/webhook_local.go @@ -43,7 +43,7 @@ func localCreateIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) return } - if _, err = c.App.GetUser(hook.UserId); err != nil { + if _, err = c.App.GetUser(c.AppContext, hook.UserId); err != nil { c.Err = err return } @@ -88,7 +88,7 @@ func localCreateOutgoingHook(c *Context, w http.ResponseWriter, r *http.Request) return } - _, err := c.App.GetUser(hook.CreatorId) + _, err := c.App.GetUser(c.AppContext, hook.CreatorId) if err != nil { c.Err = err return diff --git a/server/channels/api4/websocket_test.go b/server/channels/api4/websocket_test.go index ab1418c76524..2d92a759af44 100644 --- a/server/channels/api4/websocket_test.go +++ b/server/channels/api4/websocket_test.go @@ -540,7 +540,7 @@ func enableMFAEnforcement(th *TestHelper) { // Helper function to set up MFA for a user func setupUserWithMFA(t *testing.T, th *TestHelper, user *model.User) string { // Setup MFA properly - following authentication_test.go pattern - secret, appErr := th.App.GenerateMfaSecret(user.Id) + secret, appErr := th.App.GenerateMfaSecret(th.Context, user.Id) require.Nil(t, appErr) err := th.Server.Store().User().UpdateMfaActive(user.Id, true) require.NoError(t, err) diff --git a/server/channels/app/access_control.go b/server/channels/app/access_control.go index e704a5e41b2e..f95f2bece958 100644 --- a/server/channels/app/access_control.go +++ b/server/channels/app/access_control.go @@ -163,7 +163,7 @@ func (a *App) CreateOrUpdateAccessControlPolicy(rctx request.CTX, policy *model. // callers may intentionally set rules they don't match, mirroring the // masking self-inclusion exemption below. if policy.Type == model.AccessControlPolicyTypeTeam { - if session := rctx.Session(); session != nil && session.UserId != "" && !a.HasPermissionTo(session.UserId, model.PermissionManageSystem) { + if session := rctx.Session(); session != nil && session.UserId != "" && !a.HasPermissionTo(rctx, session.UserId, model.PermissionManageSystem) { for _, rule := range policy.Rules { if appErr := a.ValidateTeamAdminSelfInclusion(rctx, session.UserId, rule.Expression); appErr != nil { return nil, appErr @@ -212,7 +212,7 @@ func (a *App) CreateOrUpdateAccessControlPolicy(rctx request.CTX, policy *model. // (e.g., creating a "Clearance == Top Secret" rule without holding that // clearance themselves). Masking and write-path value validation still // apply to system admins above. - if !a.HasPermissionTo(callerID, model.PermissionManageSystem) { + if !a.HasPermissionTo(rctx, callerID, model.PermissionManageSystem) { if appErr := a.checkSelfInclusion(rctx, policy, callerID, mergedHidden); appErr != nil { return nil, appErr } @@ -2381,7 +2381,7 @@ type ValidateAccessControlPolicyPermissionOptions struct { func (a *App) ValidateAccessControlPolicyPermissionWithOptions(rctx request.CTX, userID, policyID string, opts ValidateAccessControlPolicyPermissionOptions) *model.AppError { // System admins can manage any policy - if a.HasPermissionTo(userID, model.PermissionManageSystem) { + if a.HasPermissionTo(rctx, userID, model.PermissionManageSystem) { return nil } @@ -2460,7 +2460,7 @@ func (a *App) isSystemPolicyAppliedToChannel(rctx request.CTX, policyID, channel // ValidateChannelAccessControlPolicyCreation validates if a user can create a channel-specific access control policy func (a *App) ValidateChannelAccessControlPolicyCreation(rctx request.CTX, userID string, policy *model.AccessControlPolicy) *model.AppError { // System admins can create any type of policy - if a.HasPermissionTo(userID, model.PermissionManageSystem) { + if a.HasPermissionTo(rctx, userID, model.PermissionManageSystem) { return nil } @@ -2614,7 +2614,7 @@ func (a *App) BuildAccessControlSubject(rctx request.CTX, userID string, roles s // user.isbot / user.createat) for runtime PDP evaluation. a.GetUser is the // cached user read and already resolves IsBot via the Bots join, so this is // a single (usually cache-hit) lookup per subject build. - user, appErr := a.GetUser(userID) + user, appErr := a.GetUser(rctx, userID) if appErr != nil { // Fail closed: a native-attribute policy must not silently evaluate // against zero-valued natives if the user read fails. The caller diff --git a/server/channels/app/admin.go b/server/channels/app/admin.go index d956a40e23c7..3aef4566d4ec 100644 --- a/server/channels/app/admin.go +++ b/server/channels/app/admin.go @@ -186,7 +186,7 @@ func (a *App) TestEmail(rctx request.CTX, userID string, cfg *model.Config) *mod return model.NewAppError("testEmail", "api.admin.test_email.reenter_password", nil, "", http.StatusBadRequest) } } - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { return err } diff --git a/server/channels/app/authentication.go b/server/channels/app/authentication.go index 73e7668f8566..05c96facedbc 100644 --- a/server/channels/app/authentication.go +++ b/server/channels/app/authentication.go @@ -108,7 +108,7 @@ func (a *App) migratePassword(user *model.User, password string) *model.AppError } func (a *App) CheckPasswordAndAllCriteria(rctx request.CTX, userID string, password string, mfaToken string) *model.AppError { - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { if err.Id != MissingAccountError { err.StatusCode = http.StatusInternalServerError @@ -202,7 +202,7 @@ func (a *App) checkLdapUserPasswordAndAllCriteria(rctx request.CTX, user *model. // We need to get the latest value of the user from the database. user.Id is empty for first-time LDAP users. if user.Id != "" { var err *model.AppError - user, err = a.GetUser(user.Id) + user, err = a.GetUser(rctx, user.Id) if err != nil { if err.Id != MissingAccountError { err.StatusCode = http.StatusInternalServerError @@ -391,7 +391,7 @@ func (a *App) MFARequired(rctx request.CTX) *model.AppError { return nil } - user, err := a.GetUser(session.UserId) + user, err := a.GetUser(rctx, session.UserId) if err != nil { return model.NewAppError("MfaRequired", "api.context.get_user.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } diff --git a/server/channels/app/authentication_test.go b/server/channels/app/authentication_test.go index a581af0e4e97..7a8dbd6467f3 100644 --- a/server/channels/app/authentication_test.go +++ b/server/channels/app/authentication_test.go @@ -82,7 +82,7 @@ func TestCheckPasswordAndAllCriteria(t *testing.T) { require.Nil(t, appErr) // setup MFA - secret, appErr := th.App.GenerateMfaSecret(th.BasicUser.Id) + secret, appErr := th.App.GenerateMfaSecret(th.Context, th.BasicUser.Id) require.Nil(t, appErr) err := th.Server.Store().User().UpdateMfaActive(th.BasicUser.Id, true) require.NoError(t, err) @@ -98,7 +98,7 @@ func TestCheckPasswordAndAllCriteria(t *testing.T) { appErr = th.App.CheckPasswordAndAllCriteria(th.Context, th.BasicUser.Id, password, token) require.Nil(t, appErr) - updatedUser, appErr := th.App.GetUser(th.BasicUser.Id) + updatedUser, appErr := th.App.GetUser(th.Context, th.BasicUser.Id) require.Nil(t, appErr) require.Equal(t, 0, updatedUser.FailedAttempts, "successful login must reset FailedAttempts") }) @@ -114,7 +114,7 @@ func TestCheckPasswordAndAllCriteria(t *testing.T) { require.NotNil(t, appErr) require.Equal(t, "mfa.validate_token.authenticate.app_error", appErr.Id) - updatedUser, appErr := th.App.GetUser(th.BasicUser.Id) + updatedUser, appErr := th.App.GetUser(th.Context, th.BasicUser.Id) require.Nil(t, appErr) require.Equal(t, 0, updatedUser.FailedAttempts, "MFA probe must not consume a slot") }) @@ -129,7 +129,7 @@ func TestCheckPasswordAndAllCriteria(t *testing.T) { require.NotNil(t, appErr) require.Equal(t, "api.user.check_user_mfa.bad_code.app_error", appErr.Id) - updatedUser, appErr := th.App.GetUser(th.BasicUser.Id) + updatedUser, appErr := th.App.GetUser(th.Context, th.BasicUser.Id) require.Nil(t, appErr) require.Equal(t, 1, updatedUser.FailedAttempts, "real MFA failure must consume a slot") }) @@ -151,7 +151,7 @@ func TestCheckPasswordAndAllCriteria(t *testing.T) { require.NotNil(t, appErr) require.Equal(t, "api.user.check_user_password.invalid_hash.app_error", appErr.Id) - updatedUser, appErr := th.App.GetUser(badHashUser.Id) + updatedUser, appErr := th.App.GetUser(th.Context, badHashUser.Id) require.Nil(t, appErr) require.Equal(t, 0, updatedUser.FailedAttempts, "backend error must not consume a slot") }) @@ -232,7 +232,7 @@ func TestDoubleCheckPassword(t *testing.T) { // DoubleCheckPassword does not re-fetch the user; it inspects user.Password // directly. Pull a fresh struct that reflects the hash we just wrote. - user, appErr := th.App.GetUser(th.BasicUser.Id) + user, appErr := th.App.GetUser(th.Context, th.BasicUser.Id) require.Nil(t, appErr) t.Run("correct password succeeds and resets the counter", func(t *testing.T) { @@ -242,7 +242,7 @@ func TestDoubleCheckPassword(t *testing.T) { appErr := th.App.DoubleCheckPassword(th.Context, user, password) require.Nil(t, appErr) - updatedUser, appErr := th.App.GetUser(user.Id) + updatedUser, appErr := th.App.GetUser(th.Context, user.Id) require.Nil(t, appErr) require.Equal(t, 0, updatedUser.FailedAttempts) }) @@ -264,14 +264,14 @@ func TestDoubleCheckPassword(t *testing.T) { err = th.App.Srv().Store().User().UpdateFailedPasswordAttempts(badHashUser.Id, 0) require.NoError(t, err) - user, appErr := th.App.GetUser(badHashUser.Id) + user, appErr := th.App.GetUser(th.Context, badHashUser.Id) require.Nil(t, appErr) appErr = th.App.DoubleCheckPassword(th.Context, user, "any-password") require.NotNil(t, appErr) require.Equal(t, "api.user.check_user_password.invalid_hash.app_error", appErr.Id) - updatedUser, appErr := th.App.GetUser(badHashUser.Id) + updatedUser, appErr := th.App.GetUser(th.Context, badHashUser.Id) require.Nil(t, appErr) require.Equal(t, 0, updatedUser.FailedAttempts, "backend error must not consume a slot") }) @@ -368,7 +368,7 @@ func TestCheckLdapUserPasswordAndAllCriteria(t *testing.T) { } if tc.expectedErrID == "api.user.check_user_login_attempts.too_many_ldap.app_error" { - updatedUser, err := th.App.GetUser(ldapUser.Id) + updatedUser, err := th.App.GetUser(th.Context, ldapUser.Id) require.Nil(t, err) require.Equal(t, maxFailedLoginAttempts, updatedUser.FailedAttempts) } @@ -392,12 +392,12 @@ func TestCheckLdapUserPasswordAndAllCriteria(t *testing.T) { EmailVerified: true, }) require.Nil(t, appErr) - secret, appErr := th.App.GenerateMfaSecret(created.Id) + secret, appErr := th.App.GenerateMfaSecret(th.Context, created.Id) require.Nil(t, appErr) require.NoError(t, th.Server.Store().User().UpdateMfaActive(created.Id, true)) require.NoError(t, th.Server.Store().User().UpdateMfaSecret(created.Id, secret.Secret)) require.NoError(t, th.App.Srv().Store().User().UpdateFailedPasswordAttempts(created.Id, 0)) - created, appErr = th.App.GetUser(created.Id) + created, appErr = th.App.GetUser(th.Context, created.Id) require.Nil(t, appErr) created.AuthData = &userAuthData return created, &userAuthData @@ -429,7 +429,7 @@ func TestCheckLdapUserPasswordAndAllCriteria(t *testing.T) { require.NotNil(t, appErr) require.Equal(t, "ent.ldap.do_login.invalid_password.app_error", appErr.Id) - updatedUser, appErr := th.App.GetUser(preCreated.Id) + updatedUser, appErr := th.App.GetUser(th.Context, preCreated.Id) require.Nil(t, appErr) require.Equal(t, 1, updatedUser.FailedAttempts, "first-time LDAP wrong password must be counted") }) @@ -451,7 +451,7 @@ func TestCheckLdapUserPasswordAndAllCriteria(t *testing.T) { require.NotNil(t, appErr) require.Equal(t, "api.user.check_user_mfa.bad_code.app_error", appErr.Id) - updatedUser, appErr := th.App.GetUser(preCreated.Id) + updatedUser, appErr := th.App.GetUser(th.Context, preCreated.Id) require.Nil(t, appErr) require.Equal(t, 1, updatedUser.FailedAttempts, "first-time LDAP wrong MFA must be counted") }) @@ -471,7 +471,7 @@ func TestCheckLdapUserPasswordAndAllCriteria(t *testing.T) { require.NotNil(t, appErr) require.Equal(t, "ent.ldap.do_login.unable_to_connect.app_error", appErr.Id) - updatedUser, appErr := th.App.GetUser(user.Id) + updatedUser, appErr := th.App.GetUser(th.Context, user.Id) require.Nil(t, appErr) require.Equal(t, 0, updatedUser.FailedAttempts, "LDAP backend error must refund the slot") }) @@ -490,7 +490,7 @@ func TestCheckLdapUserPasswordAndAllCriteria(t *testing.T) { require.NotNil(t, appErr) require.Equal(t, "mfa.validate_token.authenticate.app_error", appErr.Id) - updatedUser, appErr := th.App.GetUser(preCreated.Id) + updatedUser, appErr := th.App.GetUser(th.Context, preCreated.Id) require.Nil(t, appErr) require.Equal(t, 0, updatedUser.FailedAttempts, "MFA probe on existing LDAP user must not consume a slot") }) @@ -536,7 +536,7 @@ func TestCheckLdapUserPasswordAndAllCriteria(t *testing.T) { close(start) require.NoError(t, g.Wait()) - updatedUser, appErr := th.App.GetUser(preCreated.Id) + updatedUser, appErr := th.App.GetUser(th.Context, preCreated.Id) require.Nil(t, appErr) require.Equal(t, maxFailedLoginAttempts, updatedUser.FailedAttempts, "concurrent first-time attempts must not lose increments and must cap at maxAttempts") }) @@ -567,14 +567,14 @@ func TestCheckLdapUserPasswordConcurrency(t *testing.T) { require.Nil(t, appErr) // setup MFA - secret, appErr := th.App.GenerateMfaSecret(user.Id) + secret, appErr := th.App.GenerateMfaSecret(th.Context, user.Id) require.Nil(t, appErr) err := th.Server.Store().User().UpdateMfaActive(user.Id, true) require.NoError(t, err) err = th.Server.Store().User().UpdateMfaSecret(user.Id, secret.Secret) require.NoError(t, err) - user, appErr = th.App.GetUser(user.Id) + user, appErr = th.App.GetUser(th.Context, user.Id) require.Nil(t, appErr) user.AuthData = &authData @@ -675,7 +675,7 @@ func TestCheckUserPassword(t *testing.T) { require.NoError(t, err) th.App.InvalidateCacheForUser(user.Id) - updatedUser, appErr := th.App.GetUser(user.Id) + updatedUser, appErr := th.App.GetUser(th.Context, user.Id) require.Nil(t, appErr) require.Equal(t, hash, updatedUser.Password) @@ -706,7 +706,7 @@ func TestCheckUserPassword(t *testing.T) { err := th.App.checkUserPassword(user, pwd) require.Nil(t, err) - updatedUser, err := th.App.GetUser(user.Id) + updatedUser, err := th.App.GetUser(th.Context, user.Id) require.Nil(t, err) require.NotEqual(t, pwdBcrypt, updatedUser.Password) require.Contains(t, updatedUser.Password, "$pbkdf2") @@ -727,7 +727,7 @@ func TestCheckUserPassword(t *testing.T) { t.Run("empty password", func(t *testing.T) { user := createUserWithHash(pwdPBKDF2) - user, err := th.App.GetUser(user.Id) + user, err := th.App.GetUser(th.Context, user.Id) require.Nil(t, err) err = th.App.checkUserPassword(user, "") @@ -738,7 +738,7 @@ func TestCheckUserPassword(t *testing.T) { t.Run("user with empty password hash", func(t *testing.T) { user := createUserWithHash("") - user, err := th.App.GetUser(user.Id) + user, err := th.App.GetUser(th.Context, user.Id) require.Nil(t, err) err = th.App.checkUserPassword(user, pwd) @@ -762,7 +762,7 @@ func TestCheckUserPassword(t *testing.T) { appErr := th.App.checkUserPassword(user, pwd) require.Nil(t, appErr) - updatedUser, appErr := th.App.GetUser(user.Id) + updatedUser, appErr := th.App.GetUser(th.Context, user.Id) require.Nil(t, appErr) require.NotEqual(t, pwdBcrypt, updatedUser.Password) require.Contains(t, updatedUser.Password, "$pbkdf2") @@ -793,7 +793,7 @@ func TestMigratePassword(t *testing.T) { require.NoError(t, err) th.App.InvalidateCacheForUser(user.Id) - updatedUser, appErr := th.App.GetUser(user.Id) + updatedUser, appErr := th.App.GetUser(th.Context, user.Id) require.Nil(t, appErr) require.Equal(t, hash, updatedUser.Password) @@ -806,7 +806,7 @@ func TestMigratePassword(t *testing.T) { err := th.App.migratePassword(user, pwd) require.Nil(t, err) - updatedUser, err := th.App.GetUser(user.Id) + updatedUser, err := th.App.GetUser(th.Context, user.Id) require.Nil(t, err) require.NotEqual(t, pwdBcrypt, updatedUser.Password) require.Contains(t, updatedUser.Password, "$pbkdf2") diff --git a/server/channels/app/authorization.go b/server/channels/app/authorization.go index d0e6b5b6e6b3..506b7148b93c 100644 --- a/server/channels/app/authorization.go +++ b/server/channels/app/authorization.go @@ -247,7 +247,7 @@ func (a *App) SessionHasPermissionToCategory(rctx request.CTX, session model.Ses return err == nil && category != nil && category.UserId == session.UserId && category.UserId == userID && category.TeamId == teamID } -func (a *App) SessionHasPermissionToUser(session model.Session, userID string) bool { +func (a *App) SessionHasPermissionToUser(rctx request.CTX, session model.Session, userID string) bool { if userID == "" { return false } @@ -263,7 +263,7 @@ func (a *App) SessionHasPermissionToUser(session model.Session, userID string) b return false } - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { return false } @@ -285,15 +285,15 @@ func (a *App) SessionHasPermissionToUserOrBot(rctx request.CTX, session model.Se return true } if err.Id == "store.sql_bot.get.missing.app_error" && err.Where == "SqlBotStore.Get" { - if a.SessionHasPermissionToUser(session, userID) { + if a.SessionHasPermissionToUser(rctx, session, userID) { return true } } return false } -func (a *App) HasPermissionTo(askingUserId string, permission *model.Permission) bool { - user, err := a.GetUser(askingUserId) +func (a *App) HasPermissionTo(rctx request.CTX, askingUserId string, permission *model.Permission) bool { + user, err := a.GetUser(rctx, askingUserId) if err != nil { return false } @@ -313,7 +313,7 @@ func (a *App) HasPermissionToTeam(rctx request.CTX, askingUserId string, teamID return true } } - return a.HasPermissionTo(askingUserId, permission) + return a.HasPermissionTo(rctx, askingUserId, permission) } // HasPermissionToChannel determines if the specified user has the given permission on the provided channel. @@ -351,7 +351,7 @@ func (a *App) HasPermissionToChannel(rctx request.CTX, askingUserId string, chan return a.HasPermissionToTeam(rctx, askingUserId, channel.TeamId, permission), isMember } - return a.HasPermissionTo(askingUserId, permission), isMember + return a.HasPermissionTo(rctx, askingUserId, permission), isMember } func (a *App) HasPermissionToChannelByPost(rctx request.CTX, askingUserId string, postID string, permission *model.Permission) bool { @@ -365,15 +365,15 @@ func (a *App) HasPermissionToChannelByPost(rctx request.CTX, askingUserId string return a.HasPermissionToTeam(rctx, askingUserId, channel.TeamId, permission) } - return a.HasPermissionTo(askingUserId, permission) + return a.HasPermissionTo(rctx, askingUserId, permission) } -func (a *App) HasPermissionToUser(askingUserId string, userID string) bool { +func (a *App) HasPermissionToUser(rctx request.CTX, askingUserId string, userID string) bool { if askingUserId == userID { return true } - if a.HasPermissionTo(askingUserId, model.PermissionEditOtherUsers) { + if a.HasPermissionTo(rctx, askingUserId, model.PermissionEditOtherUsers) { return true } @@ -614,13 +614,13 @@ func (a *App) hasPropertyFieldPermissionLevel(rctx request.CTX, userID string, f case model.PermissionLevelNone: return false case model.PermissionLevelSysadmin: - return a.HasPermissionTo(userID, model.PermissionManageSystem) + return a.HasPermissionTo(rctx, userID, model.PermissionManageSystem) case model.PermissionLevelMember: return a.hasPropertyFieldScopeAccess(rctx, userID, field) case model.PermissionLevelAdmin: switch field.TargetType { case string(model.PropertyFieldTargetLevelSystem): - return a.HasPermissionTo(userID, model.PermissionManageSystem) + return a.HasPermissionTo(rctx, userID, model.PermissionManageSystem) case string(model.PropertyFieldTargetLevelTeam): return a.HasPermissionToTeam(rctx, userID, field.TargetID, model.PermissionManageTeam) case string(model.PropertyFieldTargetLevelChannel): @@ -640,7 +640,7 @@ func (a *App) hasPropertyFieldPermissionLevel(rctx request.CTX, userID string, f func (a *App) hasPropertyFieldValuePermissionLevel(rctx request.CTX, userID string, field *model.PropertyField, valueTargetID string, level model.PermissionLevel) bool { switch level { case model.PermissionLevelSysadmin: - return a.HasPermissionTo(userID, model.PermissionManageSystem) + return a.HasPermissionTo(rctx, userID, model.PermissionManageSystem) case model.PermissionLevelAdmin: return a.hasPropertyFieldValueAdmin(rctx, userID, field, valueTargetID) case model.PermissionLevelMember: diff --git a/server/channels/app/authorization_test.go b/server/channels/app/authorization_test.go index 1aaf96e3a30c..52c9b636f5be 100644 --- a/server/channels/app/authorization_test.go +++ b/server/channels/app/authorization_test.go @@ -392,9 +392,9 @@ func TestHasPermissionToUser(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic(t) - assert.True(t, th.App.HasPermissionToUser(th.SystemAdminUser.Id, th.BasicUser.Id)) - assert.True(t, th.App.HasPermissionToUser(th.BasicUser.Id, th.BasicUser.Id)) - assert.False(t, th.App.HasPermissionToUser(th.BasicUser.Id, th.BasicUser2.Id)) + assert.True(t, th.App.HasPermissionToUser(th.Context, th.SystemAdminUser.Id, th.BasicUser.Id)) + assert.True(t, th.App.HasPermissionToUser(th.Context, th.BasicUser.Id, th.BasicUser.Id)) + assert.False(t, th.App.HasPermissionToUser(th.Context, th.BasicUser.Id, th.BasicUser2.Id)) } func TestSessionHasPermissionToManageBot(t *testing.T) { @@ -517,8 +517,8 @@ func TestSessionHasPermissionToUser(t *testing.T) { UserId: th.BasicUser.Id, Roles: model.SystemUserRoleId, } - assert.True(t, th.App.SessionHasPermissionToUser(session, th.BasicUser.Id)) - assert.False(t, th.App.SessionHasPermissionToUser(session, th.BasicUser2.Id)) + assert.True(t, th.App.SessionHasPermissionToUser(th.Context, session, th.BasicUser.Id)) + assert.False(t, th.App.SessionHasPermissionToUser(th.Context, session, th.BasicUser2.Id)) }) t.Run("test user manager access", func(t *testing.T) { @@ -526,11 +526,11 @@ func TestSessionHasPermissionToUser(t *testing.T) { UserId: th.BasicUser.Id, Roles: model.SystemUserManagerRoleId, } - assert.False(t, th.App.SessionHasPermissionToUser(session, th.BasicUser2.Id)) + assert.False(t, th.App.SessionHasPermissionToUser(th.Context, session, th.BasicUser2.Id)) th.AddPermissionToRole(t, model.PermissionEditOtherUsers.Id, model.SystemUserManagerRoleId) - assert.True(t, th.App.SessionHasPermissionToUser(session, th.BasicUser2.Id)) - assert.False(t, th.App.SessionHasPermissionToUser(session, th.SystemAdminUser.Id)) + assert.True(t, th.App.SessionHasPermissionToUser(th.Context, session, th.BasicUser2.Id)) + assert.False(t, th.App.SessionHasPermissionToUser(th.Context, session, th.SystemAdminUser.Id)) th.RemovePermissionFromRole(t, model.PermissionEditOtherUsers.Id, model.SystemUserManagerRoleId) bot, err := th.App.CreateBot(th.Context, &model.Bot{ @@ -545,7 +545,7 @@ func TestSessionHasPermissionToUser(t *testing.T) { assert.Nil(t, appErr) }() - assert.False(t, th.App.SessionHasPermissionToUser(session, bot.UserId)) + assert.False(t, th.App.SessionHasPermissionToUser(th.Context, session, bot.UserId)) }) t.Run("test admin user access", func(t *testing.T) { @@ -553,8 +553,8 @@ func TestSessionHasPermissionToUser(t *testing.T) { UserId: th.SystemAdminUser.Id, Roles: model.SystemAdminRoleId, } - assert.True(t, th.App.SessionHasPermissionToUser(session, th.BasicUser.Id)) - assert.True(t, th.App.SessionHasPermissionToUser(session, th.BasicUser2.Id)) + assert.True(t, th.App.SessionHasPermissionToUser(th.Context, session, th.BasicUser.Id)) + assert.True(t, th.App.SessionHasPermissionToUser(th.Context, session, th.BasicUser2.Id)) }) } diff --git a/server/channels/app/auto_responder.go b/server/channels/app/auto_responder.go index fd7be6fd29db..e1d709753439 100644 --- a/server/channels/app/auto_responder.go +++ b/server/channels/app/auto_responder.go @@ -36,7 +36,7 @@ func (a *App) SendAutoResponseIfNecessary(rctx request.CTX, channel *model.Chann receiverId = sender.Id } - receiver, aErr := a.GetUser(receiverId) + receiver, aErr := a.GetUser(rctx, receiverId) if aErr != nil { return false, aErr } @@ -99,7 +99,7 @@ func (a *App) SetAutoResponderStatus(rctx request.CTX, user *model.User, oldNoti } func (a *App) DisableAutoResponder(rctx request.CTX, userID string, asAdmin bool) *model.AppError { - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { return err } diff --git a/server/channels/app/auto_responder_test.go b/server/channels/app/auto_responder_test.go index 471dcaef54f4..19b07652582e 100644 --- a/server/channels/app/auto_responder_test.go +++ b/server/channels/app/auto_responder_test.go @@ -77,13 +77,13 @@ func TestDisableAutoResponder(t *testing.T) { err := th.App.DisableAutoResponder(th.Context, user.Id, true) require.Nil(t, err) - userUpdated1, err := th.App.GetUser(user.Id) + userUpdated1, err := th.App.GetUser(th.Context, user.Id) require.Nil(t, err) assert.Equal(t, userUpdated1.NotifyProps["auto_responder_active"], "false") err = th.App.DisableAutoResponder(th.Context, user.Id, true) require.Nil(t, err) - userUpdated2, err := th.App.GetUser(user.Id) + userUpdated2, err := th.App.GetUser(th.Context, user.Id) require.Nil(t, err) assert.Equal(t, userUpdated2.NotifyProps["auto_responder_active"], "false") @@ -191,7 +191,7 @@ func TestSendAutoResponseIfNecessary(t *testing.T) { }) assert.Nil(t, err) - botUser, err := th.App.GetUser(bot.UserId) + botUser, err := th.App.GetUser(th.Context, bot.UserId) assert.Nil(t, err) savedPost, _, _ := th.App.CreatePost(th.Context, &model.Post{ diff --git a/server/channels/app/bot.go b/server/channels/app/bot.go index fd211ffe34e1..59e97198198e 100644 --- a/server/channels/app/bot.go +++ b/server/channels/app/bot.go @@ -607,7 +607,7 @@ func (a *App) notifySysadminsBotOwnerDeactivated(rctx request.CTX, userID string } // user being disabled - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { return err } @@ -679,7 +679,7 @@ func (a *App) ConvertUserToBot(rctx request.CTX, user *model.User) (*model.Bot, } // Refresh user data - updatedUser, err := a.GetUser(user.Id) + updatedUser, err := a.GetUser(rctx, user.Id) if err != nil { return nil, err } diff --git a/server/channels/app/bot_test.go b/server/channels/app/bot_test.go index 9a6b618f4fd0..bb9dce805355 100644 --- a/server/channels/app/bot_test.go +++ b/server/channels/app/bot_test.go @@ -86,7 +86,7 @@ func TestCreateBot(t *testing.T) { assert.Equal(t, "a bot", bot.Description) assert.Equal(t, th.BasicUser.Id, bot.OwnerId) - user, err := th.App.GetUser(bot.UserId) + user, err := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, err) // Check that a post was created to add bot to team and channels @@ -658,7 +658,7 @@ func TestUpdateBotActive(t *testing.T) { require.Nil(t, err) require.Zero(t, refetched.DeleteAt) - botUser, err := th.App.GetUser(protectedBot.UserId) + botUser, err := th.App.GetUser(th.Context, protectedBot.UserId) require.Nil(t, err) require.Zero(t, botUser.DeleteAt) }) @@ -833,7 +833,7 @@ func TestDisableUserBots(t *testing.T) { require.Nil(t, err) require.Zero(t, bot.DeleteAt) - user, err := th.App.GetUser(u2bot1.UserId) + user, err := th.App.GetUser(th.Context, u2bot1.UserId) require.Nil(t, err) require.Zero(t, user.DeleteAt) @@ -1059,7 +1059,7 @@ func TestConvertUserToBot(t *testing.T) { require.Nil(t, err) // Verify OAuth credentials are set - oauthUser, appErr := th.App.GetUser(oauthUser.Id) + oauthUser, appErr := th.App.GetUser(th.Context, oauthUser.Id) require.Nil(t, appErr) require.Equal(t, "google", oauthUser.AuthService) require.NotNil(t, oauthUser.AuthData) @@ -1073,7 +1073,7 @@ func TestConvertUserToBot(t *testing.T) { }() // Get updated user and verify OAuth credentials are cleared - updatedUser, err := th.App.GetUser(oauthUser.Id) + updatedUser, err := th.App.GetUser(th.Context, oauthUser.Id) require.Nil(t, err) assert.Empty(t, updatedUser.AuthService) // AuthData may be empty string instead of nil in the database @@ -1127,7 +1127,7 @@ func TestGetSystemBot(t *testing.T) { // before the protection guard existed: deactivate the underlying user // and mark the bot record deleted directly in the store (bypassing // UpdateBotActive's guard). - botUser, err := th.App.GetUser(bot.UserId) + botUser, err := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, err) _, err = th.App.UpdateActive(th.Context, botUser, false) require.Nil(t, err) @@ -1148,7 +1148,7 @@ func TestGetSystemBot(t *testing.T) { require.Nil(t, err) require.Zero(t, healed.DeleteAt) - healedUser, err := th.App.GetUser(bot.UserId) + healedUser, err := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, err) require.Zero(t, healedUser.DeleteAt) }) @@ -1167,7 +1167,7 @@ func TestSystemBotProtectedFromOwnerDeactivation(t *testing.T) { require.Nil(t, err) require.Equal(t, model.BotSystemBotUsername, systemBot.Username) - owner, err := th.App.GetUser(systemBot.OwnerId) + owner, err := th.App.GetUser(th.Context, systemBot.OwnerId) require.Nil(t, err) // A regular bot owned by the same user, to confirm the guard is scoped to diff --git a/server/channels/app/channel.go b/server/channels/app/channel.go index 30b754bd7b18..d2f20bed4290 100644 --- a/server/channels/app/channel.go +++ b/server/channels/app/channel.go @@ -192,7 +192,7 @@ func (a *App) CreateChannelWithUser(rctx request.CTX, channel *model.Channel, us a.addChannelToDefaultCategory(rctx, userID, channel) var user *model.User - if user, err = a.GetUser(userID); err != nil { + if user, err = a.GetUser(rctx, userID); err != nil { return nil, err } @@ -2014,7 +2014,7 @@ func (a *App) AddChannelMember(rctx request.CTX, userID string, channel *model.C var user *model.User var err *model.AppError - if user, err = a.GetUser(userID); err != nil { + if user, err = a.GetUser(rctx, userID); err != nil { return nil, err } @@ -2024,7 +2024,7 @@ func (a *App) AddChannelMember(rctx request.CTX, userID string, channel *model.C var userRequestor *model.User if opts.UserRequestorID != "" { - if userRequestor, err = a.GetUser(opts.UserRequestorID); err != nil { + if userRequestor, err = a.GetUser(rctx, opts.UserRequestorID); err != nil { return nil, err } } @@ -3078,7 +3078,7 @@ func (a *App) removeUserFromChannel(rctx request.CTX, userIDToRemove string, rem var actorUser *model.User if removerUserId != "" { - actorUser, _ = a.GetUser(removerUserId) + actorUser, _ = a.GetUser(rctx, removerUserId) } a.Srv().Go(func() { @@ -3123,7 +3123,7 @@ func (a *App) RemoveUserFromChannel(rctx request.CTX, userIDToRemove string, rem } var user *model.User - if user, err = a.GetUser(userIDToRemove); err != nil { + if user, err = a.GetUser(rctx, userIDToRemove); err != nil { return err } @@ -3236,7 +3236,7 @@ func (a *App) MarkChannelAsUnreadFromPost(rctx request.CTX, postID string, userI return nil, err } - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { return nil, err } @@ -3263,7 +3263,7 @@ func (a *App) markChannelAsUnreadFromPostCRTUnsupported(rctx request.CTX, postID return nil, appErr } - user, appErr := a.GetUser(userID) + user, appErr := a.GetUser(rctx, userID) if appErr != nil { return nil, appErr } @@ -3380,7 +3380,7 @@ func (a *App) AutocompleteChannels(rctx request.CTX, userID, term string) (model includeDeleted := true term = strings.TrimSpace(term) - user, appErr := a.GetUser(userID) + user, appErr := a.GetUser(rctx, userID) if appErr != nil { return nil, appErr } @@ -3401,7 +3401,7 @@ func (a *App) AutocompleteChannelsForTeam(rctx request.CTX, teamID, userID, term includeDeleted := true term = strings.TrimSpace(term) - user, appErr := a.GetUser(userID) + user, appErr := a.GetUser(rctx, userID) if appErr != nil { return nil, appErr } @@ -3418,7 +3418,7 @@ func (a *App) AutocompleteChannelsForTeamFiltered(rctx request.CTX, teamID, user includeDeleted := true term = strings.TrimSpace(term) - user, appErr := a.GetUser(userID) + user, appErr := a.GetUser(rctx, userID) if appErr != nil { return nil, appErr } @@ -4431,7 +4431,7 @@ func (a *App) validateForConvertGroupMessageToChannel(rctx request.CTX, converte } func (a *App) postMessageForConvertGroupMessageToChannel(rctx request.CTX, channelID, convertedByUserId string, channelUsers []*model.User) *model.AppError { - convertedByUser, appErr := a.GetUser(convertedByUserId) + convertedByUser, appErr := a.GetUser(rctx, convertedByUserId) if appErr != nil { return appErr } @@ -4633,7 +4633,7 @@ func (a *App) GetRecommendedPublicChannelsForUser(rctx request.CTX, userID, team return model.ChannelList{}, nil } - user, appErr := a.GetUser(userID) + user, appErr := a.GetUser(rctx, userID) if appErr != nil { return nil, appErr } diff --git a/server/channels/app/channel_discoverable_visibility.go b/server/channels/app/channel_discoverable_visibility.go index 95cf7a2c012a..aa7ad006fac4 100644 --- a/server/channels/app/channel_discoverable_visibility.go +++ b/server/channels/app/channel_discoverable_visibility.go @@ -108,7 +108,7 @@ func (a *App) FilterDiscoverableChannelsByPolicy(rctx request.CTX, channels []*m } userOnce.Do(func() { - user, userErr = a.GetUser(userID) + user, userErr = a.GetUser(rctx, userID) }) if userErr != nil { return nil, userErr @@ -211,7 +211,7 @@ func (a *App) FilterChannelListWithTeamDataForUserVisibility(rctx request.CTX, c } userOnce.Do(func() { - user, userErr = a.GetUser(userID) + user, userErr = a.GetUser(rctx, userID) }) if userErr != nil { return nil, 0, userErr diff --git a/server/channels/app/channel_join_request.go b/server/channels/app/channel_join_request.go index 05b8c98e5034..c9b5a2e8126a 100644 --- a/server/channels/app/channel_join_request.go +++ b/server/channels/app/channel_join_request.go @@ -65,7 +65,7 @@ func (a *App) requestJoinChannelGuard(rctx request.CTX, user *model.User, channe // admin review, or nil when the user was added directly to the channel (the // caller can detect this via the `joined` return value). func (a *App) RequestJoinChannel(rctx request.CTX, userID, channelID, message string) (joined bool, req *model.ChannelJoinRequest, appErr *model.AppError) { - user, appErr := a.GetUser(userID) + user, appErr := a.GetUser(rctx, userID) if appErr != nil { return false, nil, appErr } diff --git a/server/channels/app/channel_test.go b/server/channels/app/channel_test.go index 3b9e26a862f4..83f111c58db8 100644 --- a/server/channels/app/channel_test.go +++ b/server/channels/app/channel_test.go @@ -2164,7 +2164,7 @@ func TestAddUserToChannel(t *testing.T) { require.Nil(t, appErr) }() bot := th.CreateBot(t) - botUser, _ := th.App.GetUser(bot.UserId) + botUser, _ := th.App.GetUser(th.Context, bot.UserId) defer func() { appErr := th.App.PermanentDeleteBot(th.Context, botUser.Id) require.Nil(t, appErr) @@ -2259,7 +2259,7 @@ func TestRemoveUserFromChannel(t *testing.T) { }() bot := th.CreateBot(t) - botUser, _ := th.App.GetUser(bot.UserId) + botUser, _ := th.App.GetUser(th.Context, bot.UserId) defer func() { appErr := th.App.PermanentDeleteBot(th.Context, botUser.Id) require.Nil(t, appErr) diff --git a/server/channels/app/content_flagging.go b/server/channels/app/content_flagging.go index babfb50990de..d56434af346e 100644 --- a/server/channels/app/content_flagging.go +++ b/server/channels/app/content_flagging.go @@ -96,7 +96,7 @@ func (a *App) FlagPost(rctx request.CTX, post *model.Post, teamId, reportingUser return model.NewAppError("FlagPost", "app.data_spillage.get_group.error", nil, "", http.StatusInternalServerError).Wrap(appErr) } - reportingUser, appErr := a.GetUser(reportingUserId) + reportingUser, appErr := a.GetUser(rctx, reportingUserId) if appErr != nil { return appErr } @@ -338,7 +338,7 @@ func (a *App) createContentReviewPost(rctx request.CTX, flaggedPostId, teamId, r return appErr } - reportingUser, appErr := a.GetUser(reportingUserId) + reportingUser, appErr := a.GetUser(rctx, reportingUserId) if appErr != nil { return appErr } @@ -353,7 +353,7 @@ func (a *App) createContentReviewPost(rctx request.CTX, flaggedPostId, teamId, r return appErr } - flaggedPostAuthor, appErr := a.GetUser(flaggedPostAuthorId) + flaggedPostAuthor, appErr := a.GetUser(rctx, flaggedPostAuthorId) if appErr != nil { return appErr } @@ -523,7 +523,7 @@ func (a *App) getAllUsersInTeamForRoles(teamId string, systemRoles, teamRoles [] } func (a *App) sendContentFlaggingConfirmationMessage(rctx request.CTX, flaggingUserId, flaggedPostAuthorId, channelID string) *model.AppError { - flaggedPostAuthor, appErr := a.GetUser(flaggedPostAuthorId) + flaggedPostAuthor, appErr := a.GetUser(rctx, flaggedPostAuthorId) if appErr != nil { return appErr } @@ -1216,7 +1216,7 @@ func (a *App) postAssignReviewerMessage(rctx request.CTX, contentFlaggingGroupId return nil, nil } - reviewerUser, appErr := a.GetUser(reviewerId) + reviewerUser, appErr := a.GetUser(rctx, reviewerId) if appErr != nil { return nil, appErr } @@ -1225,7 +1225,7 @@ func (a *App) postAssignReviewerMessage(rctx request.CTX, contentFlaggingGroupId if reviewerId == assignedById { assignedByUser = reviewerUser } else { - assignedByUser, appErr = a.GetUser(assignedById) + assignedByUser, appErr = a.GetUser(rctx, assignedById) if appErr != nil { return nil, appErr } @@ -1236,7 +1236,7 @@ func (a *App) postAssignReviewerMessage(rctx request.CTX, contentFlaggingGroupId } func (a *App) postDeletePostReviewerMessage(rctx request.CTX, flaggedPostId, actorUserId, comment, contentFlaggingGroupId string) ([]*model.Post, *model.AppError) { - actorUser, appErr := a.GetUser(actorUserId) + actorUser, appErr := a.GetUser(rctx, actorUserId) if appErr != nil { return nil, appErr } @@ -1250,7 +1250,7 @@ func (a *App) postDeletePostReviewerMessage(rctx request.CTX, flaggedPostId, act } func (a *App) postKeepPostReviewerMessage(rctx request.CTX, flaggedPostId, actorUserId, comment, contentFlaggingGroupId string) ([]*model.Post, *model.AppError) { - actorUser, appErr := a.GetUser(actorUserId) + actorUser, appErr := a.GetUser(rctx, actorUserId) if appErr != nil { return nil, appErr } @@ -1373,7 +1373,7 @@ func (a *App) postReviewerMessage(rctx request.CTX, message, contentFlaggingGrou T := i18n.GetUserTranslations("") // Fetch reviewer user to get their locale reviewerUserId := channel.GetOtherUserIdForDM(reviewerPost.UserId) - reviewer, userErr := a.GetUser(reviewerUserId) + reviewer, userErr := a.GetUser(rctx, reviewerUserId) if userErr != nil { rctx.Logger().Error("Failed to get reviewer user for localization, falling back to default locale", mlog.Err(userErr), mlog.String("user_id", reviewerPost.UserId)) } else { diff --git a/server/channels/app/content_flagging_exposure_report_test.go b/server/channels/app/content_flagging_exposure_report_test.go index 8ae88e5b74c2..7ba5a7eef8c0 100644 --- a/server/channels/app/content_flagging_exposure_report_test.go +++ b/server/channels/app/content_flagging_exposure_report_test.go @@ -277,7 +277,7 @@ func TestComputePostExposure(t *testing.T) { t.Run("excludes bots", func(t *testing.T) { channel := th.CreateChannel(t, th.BasicTeam) bot := th.CreateBot(t) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) th.LinkUserToTeam(t, botUser, th.BasicTeam) th.AddUserToChannel(t, botUser, channel) diff --git a/server/channels/app/content_flagging_report.go b/server/channels/app/content_flagging_report.go index 4b4db2fab977..dcfd0b223581 100644 --- a/server/channels/app/content_flagging_report.go +++ b/server/channels/app/content_flagging_report.go @@ -92,7 +92,7 @@ func (a *App) writeFlaggedPostReport(rctx request.CTX, zw *zip.Writer, postID, g if appErr := a.writeExposureReportEntry(rctx, zw, rc.Post.Id); appErr != nil { return appErr } - if appErr := a.writeReportMetadataEntry(zw, generatedByUserID); appErr != nil { + if appErr := a.writeReportMetadataEntry(rctx, zw, generatedByUserID); appErr != nil { return appErr } @@ -136,7 +136,7 @@ func (a *App) loadFlaggedPostReportContext(rctx request.CTX, postID string) (*mo } } - author, appErr := a.GetUser(post.UserId) + author, appErr := a.GetUser(rctx, post.UserId) if appErr != nil { return nil, appErr } @@ -212,8 +212,8 @@ func (a *App) writeContentReviewEntry(rctx request.CTX, zw *zip.Writer, post *mo return nil } -func (a *App) writeReportMetadataEntry(zw *zip.Writer, generatedByUserID string) *model.AppError { - generator, appErr := a.GetUser(generatedByUserID) +func (a *App) writeReportMetadataEntry(rctx request.CTX, zw *zip.Writer, generatedByUserID string) *model.AppError { + generator, appErr := a.GetUser(rctx, generatedByUserID) if appErr != nil { return appErr } @@ -298,7 +298,7 @@ func (a *App) buildContentReviewYAML(rctx request.CTX, post *model.Post, generat out.Hidden = postHiddenByContentFlagging if reporterID := out.ReporterUserID; reporterID != "" { - if u, uErr := a.GetUser(reporterID); uErr == nil { + if u, uErr := a.GetUser(rctx, reporterID); uErr == nil { out.ReporterUsername = u.Username } else { rctx.Logger().Warn("Failed to fetch reporter user for flagged post report", mlog.String("user_id", reporterID), mlog.Err(uErr)) @@ -325,7 +325,7 @@ func (a *App) buildContentReviewYAML(rctx request.CTX, post *model.Post, generat } if actorUserId != "" { - if u, uErr := a.GetUser(actorUserId); uErr == nil { + if u, uErr := a.GetUser(rctx, actorUserId); uErr == nil { out.ActorUsername = u.Username out.ActorUserId = u.Id } else { @@ -345,7 +345,7 @@ func (a *App) buildContentReviewYAML(rctx request.CTX, post *model.Post, generat } if reviewerID != "" { - if u, uErr := a.GetUser(reviewerID); uErr == nil { + if u, uErr := a.GetUser(rctx, reviewerID); uErr == nil { out.ReviewerUsername = u.Username } else { rctx.Logger().Warn("Failed to fetch reviewer user for flagged post report", mlog.String("user_id", reviewerID), mlog.Err(uErr)) @@ -464,7 +464,7 @@ func (a *App) notifyReviewersOfReportGeneration(rctx request.CTX, flaggedPostID, return } - generator, appErr := a.GetUser(generatedByUserID) + generator, appErr := a.GetUser(rctx, generatedByUserID) if appErr != nil { rctx.Logger().Warn("Failed to fetch generating user for report generation notification", mlog.Err(appErr)) return diff --git a/server/channels/app/desktop_login.go b/server/channels/app/desktop_login.go index aff30ca3f1e7..d23f7d4f6ae0 100644 --- a/server/channels/app/desktop_login.go +++ b/server/channels/app/desktop_login.go @@ -8,6 +8,7 @@ import ( "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/public/shared/request" ) func (a *App) GenerateAndSaveDesktopToken(createAt int64, user *model.User) (*string, *model.AppError) { @@ -24,7 +25,7 @@ func (a *App) GenerateAndSaveDesktopToken(createAt int64, user *model.User) (*st return &token, nil } -func (a *App) ValidateDesktopToken(token string, expiryTime int64) (*model.User, *model.AppError) { +func (a *App) ValidateDesktopToken(rctx request.CTX, token string, expiryTime int64) (*model.User, *model.AppError) { // Check if token is valid userId, err := a.Srv().Store().DesktopTokens().GetUserId(token, expiryTime) if err != nil { @@ -36,7 +37,7 @@ func (a *App) ValidateDesktopToken(token string, expiryTime int64) (*model.User, } // Get the user profile - user, userErr := a.GetUser(*userId) + user, userErr := a.GetUser(rctx, *userId) if userErr != nil { // Delete the token if the user is invalid somehow if deleteErr := a.Srv().Store().DesktopTokens().Delete(token); deleteErr != nil { diff --git a/server/channels/app/desktop_login_test.go b/server/channels/app/desktop_login_test.go index 06af63acaca6..f9bb181cb979 100644 --- a/server/channels/app/desktop_login_test.go +++ b/server/channels/app/desktop_login_test.go @@ -46,28 +46,28 @@ func TestValidateDesktopToken(t *testing.T) { require.NotNil(t, badUserServerToken) t.Run("validate token", func(t *testing.T) { - user, err := th.App.ValidateDesktopToken(*authenticatedServerToken, time.Now().Add(-TTL).Unix()) + user, err := th.App.ValidateDesktopToken(th.Context, *authenticatedServerToken, time.Now().Add(-TTL).Unix()) assert.Nil(t, err) assert.NotNil(t, user) assert.Equal(t, th.BasicUser.Id, user.Id) }) t.Run("validate token - expired", func(t *testing.T) { - user, err := th.App.ValidateDesktopToken(*expiredServerToken, time.Now().Add(-TTL).Unix()) + user, err := th.App.ValidateDesktopToken(th.Context, *expiredServerToken, time.Now().Add(-TTL).Unix()) assert.NotNil(t, err) assert.Nil(t, user) assert.Equal(t, "app.desktop_token.validate.invalid", err.Id) }) t.Run("validate token - not authenticated", func(t *testing.T) { - user, err := th.App.ValidateDesktopToken("not_real_token", time.Now().Add(-TTL).Unix()) + user, err := th.App.ValidateDesktopToken(th.Context, "not_real_token", time.Now().Add(-TTL).Unix()) assert.NotNil(t, err) assert.Nil(t, user) assert.Equal(t, "app.desktop_token.validate.invalid", err.Id) }) t.Run("validate token - bad user id", func(t *testing.T) { - user, err := th.App.ValidateDesktopToken(*badUserServerToken, time.Now().Add(-TTL).Unix()) + user, err := th.App.ValidateDesktopToken(th.Context, *badUserServerToken, time.Now().Add(-TTL).Unix()) assert.NotNil(t, err) assert.Nil(t, user) assert.Equal(t, "app.desktop_token.validate.no_user", err.Id) diff --git a/server/channels/app/expired_access_token_notify.go b/server/channels/app/expired_access_token_notify.go index 569babe6b843..5115f1e47e53 100644 --- a/server/channels/app/expired_access_token_notify.go +++ b/server/channels/app/expired_access_token_notify.go @@ -35,7 +35,7 @@ func (a *App) NotifyExpiredAccessTokensDeleted(rctx request.CTX, tokens []*model } for _, token := range tokens { - user, appErr := a.GetUser(token.UserId) + user, appErr := a.GetUser(rctx, token.UserId) if appErr != nil { rctx.Logger().Warn("Failed to get user for expired personal access token notification", mlog.String("user_id", token.UserId), diff --git a/server/channels/app/expirynotify.go b/server/channels/app/expirynotify.go index 93fc7cc5dd7d..2cb4f0ad85de 100644 --- a/server/channels/app/expirynotify.go +++ b/server/channels/app/expirynotify.go @@ -44,7 +44,6 @@ func (a *App) NotifySessionsExpired() error { tmpMessage := msg.DeepCopy() tmpMessage.SetDeviceIdAndPlatform(session.DeviceId) tmpMessage.AckId = model.NewId() - tmpMessage.Message = a.getSessionExpiredPushMessage(session) rctx := request.EmptyContext(a.Log().With( mlog.String("type", model.NotificationTypePush), @@ -56,6 +55,8 @@ func (a *App) NotifySessionsExpired() error { mlog.String("post_id", msg.PostId), )) + tmpMessage.Message = a.getSessionExpiredPushMessage(rctx, session) + errPush := a.sendToPushProxy(rctx, tmpMessage, session) if errPush != nil { reason := model.NotificationReasonPushProxySendError @@ -91,9 +92,9 @@ func (a *App) NotifySessionsExpired() error { return nil } -func (a *App) getSessionExpiredPushMessage(session *model.Session) string { +func (a *App) getSessionExpiredPushMessage(rctx request.CTX, session *model.Session) string { locale := model.DefaultLocale - user, err := a.GetUser(session.UserId) + user, err := a.GetUser(rctx, session.UserId) if err == nil { locale = user.Locale } diff --git a/server/channels/app/file.go b/server/channels/app/file.go index faa170216813..0a5f5910a66c 100644 --- a/server/channels/app/file.go +++ b/server/channels/app/file.go @@ -1619,7 +1619,7 @@ func (a *App) buildFileDownloadSubject(rctx request.CTX, userID string) (*model. if rctx.Session().UserId == userID { subject, appErr = a.BuildAccessControlSubjectForSession(rctx, "") } else { - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { rctx.Logger().Warn("Failed to get user for file download permission filtering", mlog.String("user_id", userID), diff --git a/server/channels/app/helper_test.go b/server/channels/app/helper_test.go index 7a1b5316aba0..681603340317 100644 --- a/server/channels/app/helper_test.go +++ b/server/channels/app/helper_test.go @@ -328,15 +328,15 @@ func (th *TestHelper) InitBasic(tb testing.TB) *TestHelper { th.SystemAdminUser = th.CreateUser(tb) _, appErr := th.App.UpdateUserRoles(th.Context, th.SystemAdminUser.Id, model.SystemUserRoleId+" "+model.SystemAdminRoleId, false) require.Nil(tb, appErr) - th.SystemAdminUser, appErr = th.App.GetUser(th.SystemAdminUser.Id) + th.SystemAdminUser, appErr = th.App.GetUser(th.Context, th.SystemAdminUser.Id) require.Nil(tb, appErr) th.BasicUser = th.CreateUser(tb) - th.BasicUser, appErr = th.App.GetUser(th.BasicUser.Id) + th.BasicUser, appErr = th.App.GetUser(th.Context, th.BasicUser.Id) require.Nil(tb, appErr) th.BasicUser2 = th.CreateUser(tb) - th.BasicUser2, appErr = th.App.GetUser(th.BasicUser2.Id) + th.BasicUser2, appErr = th.App.GetUser(th.Context, th.BasicUser2.Id) require.Nil(tb, appErr) th.BasicTeam = th.CreateTeam(tb) @@ -846,7 +846,7 @@ func (th *TestHelper) SetUserRemoteID(tb testing.TB, userID, remoteID string) *m require.NoError(tb, err) th.App.InvalidateCacheForUser(userID) - user, appErr := th.App.GetUser(userID) + user, appErr := th.App.GetUser(th.Context, userID) require.Nil(tb, appErr) return user } diff --git a/server/channels/app/import_functions.go b/server/channels/app/import_functions.go index 243c9bc139f1..cf088cf1fc50 100644 --- a/server/channels/app/import_functions.go +++ b/server/channels/app/import_functions.go @@ -593,7 +593,7 @@ func (a *App) importUser(rctx request.CTX, data *imports.UserImportData, dryRun if appErr = a.updateUserNotifyProps(user.Id, user.NotifyProps); appErr != nil { return appErr } - if savedUser, appErr = a.GetUser(user.Id); appErr != nil { + if savedUser, appErr = a.GetUser(rctx, user.Id); appErr != nil { return appErr } } @@ -616,7 +616,7 @@ func (a *App) importUser(rctx request.CTX, data *imports.UserImportData, dryRun } if emailVerified { if hasUserEmailVerifiedChanged { - if err := a.VerifyUserEmail(user.Id, user.Email); err != nil { + if err := a.VerifyUserEmail(rctx, user.Id, user.Email); err != nil { return err } } diff --git a/server/channels/app/integration_action_test.go b/server/channels/app/integration_action_test.go index dca9568740db..6c2ffae21b22 100644 --- a/server/channels/app/integration_action_test.go +++ b/server/channels/app/integration_action_test.go @@ -2531,7 +2531,7 @@ func buildMmBlocksActionsProp(id, url string, context map[string]any) map[string func setupBotInChannel(t *testing.T, th *TestHelper) *model.User { t.Helper() bot := th.CreateBot(t) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) _, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, botUser.Id, "") require.Nil(t, appErr) diff --git a/server/channels/app/login.go b/server/channels/app/login.go index fe03113667f2..0f3df6779563 100644 --- a/server/channels/app/login.go +++ b/server/channels/app/login.go @@ -101,7 +101,7 @@ func (a *App) GetUserForLogin(rctx request.CTX, id, loginId string) (*model.User if enableEmail || enableUsername { // If we are given a userID then fail if we can't find a user with that ID if id != "" { - user, err := a.GetUser(id) + user, err := a.GetUser(rctx, id) if err != nil { if err.Id != MissingAccountError { err.StatusCode = http.StatusInternalServerError diff --git a/server/channels/app/notification_test.go b/server/channels/app/notification_test.go index 999c6267c14c..20604bc3b3ee 100644 --- a/server/channels/app/notification_test.go +++ b/server/channels/app/notification_test.go @@ -442,7 +442,7 @@ func TestSendNotifications_SilentPostBroadcastsPosted(t *testing.T) { th.AddUserToChannel(t, th.BasicUser2, th.BasicChannel) bot := th.CreateBot(t) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) th.LinkUserToTeam(t, botUser, th.BasicTeam) _, appErr = th.App.AddUserToChannel(th.Context, botUser, th.BasicChannel, false) @@ -481,7 +481,7 @@ func TestCreatePostSilentBroadcastsPostedWithProps(t *testing.T) { th.AddUserToChannel(t, th.BasicUser2, th.BasicChannel) bot := th.CreateBot(t) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) th.LinkUserToTeam(t, botUser, th.BasicTeam) _, appErr = th.App.AddUserToChannel(th.Context, botUser, th.BasicChannel, false) @@ -521,7 +521,7 @@ func TestSendNotifications_SilentSkipsGroupMention(t *testing.T) { th.AddUserToChannel(t, th.BasicUser2, th.BasicChannel) bot := th.CreateBot(t) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) th.LinkUserToTeam(t, botUser, th.BasicTeam) _, appErr = th.App.AddUserToChannel(th.Context, botUser, th.BasicChannel, false) @@ -566,7 +566,7 @@ func TestSendNotifications_SilentSkipsCRTFollowers(t *testing.T) { th.AddUserToChannel(t, th.BasicUser2, th.BasicChannel) bot := th.CreateBot(t) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) th.LinkUserToTeam(t, botUser, th.BasicTeam) _, appErr = th.App.AddUserToChannel(th.Context, botUser, th.BasicChannel, false) diff --git a/server/channels/app/notify_expiring_access_tokens.go b/server/channels/app/notify_expiring_access_tokens.go index 0648fccc0612..a4fd85d37088 100644 --- a/server/channels/app/notify_expiring_access_tokens.go +++ b/server/channels/app/notify_expiring_access_tokens.go @@ -117,7 +117,7 @@ func (a *App) sendAccessTokenExpiryNotification(rctx request.CTX, systemBot *mod return appErr } - user, appErr := a.GetUser(token.UserId) + user, appErr := a.GetUser(rctx, token.UserId) if appErr != nil { return appErr } diff --git a/server/channels/app/oauth.go b/server/channels/app/oauth.go index bf0a5fdb9692..d85c6e982f3e 100644 --- a/server/channels/app/oauth.go +++ b/server/channels/app/oauth.go @@ -279,7 +279,7 @@ func (a *App) GetOAuthAccessTokenForImplicitFlow(rctx request.CTX, userID string return nil, model.NewAppError("GetOAuthAccessToken", "api.oauth.get_access_token.credentials.app_error", nil, "", http.StatusNotFound).Wrap(err) } - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { return nil, err } diff --git a/server/channels/app/oauth_test.go b/server/channels/app/oauth_test.go index 24b82257eaa5..5bcfc25d6090 100644 --- a/server/channels/app/oauth_test.go +++ b/server/channels/app/oauth_test.go @@ -1633,7 +1633,7 @@ func TestSwitchOAuthToEmail(t *testing.T) { require.NoError(t, err) th.App.InvalidateCacheForUser(th.BasicUser.Id) - user, appErr := th.App.GetUser(th.BasicUser.Id) + user, appErr := th.App.GetUser(th.Context, th.BasicUser.Id) require.Nil(t, appErr) require.Equal(t, model.UserAuthServiceGitlab, user.AuthService) @@ -1650,7 +1650,7 @@ func TestSwitchOAuthToEmail(t *testing.T) { require.Equal(t, "api.user.oauth_to_email.integration_session.app_error", appErr.Id) require.Equal(t, http.StatusForbidden, appErr.StatusCode) - user, appErr = th.App.GetUser(user.Id) + user, appErr = th.App.GetUser(th.Context, user.Id) require.Nil(t, appErr) require.Equal(t, model.UserAuthServiceGitlab, user.AuthService) }) @@ -1664,7 +1664,7 @@ func TestSwitchOAuthToEmail(t *testing.T) { require.Nil(t, appErr) require.Equal(t, "/login?extra=signin_change", link) - user, appErr = th.App.GetUser(user.Id) + user, appErr = th.App.GetUser(th.Context, user.Id) require.Nil(t, appErr) require.Empty(t, user.AuthService) }) @@ -1982,7 +1982,7 @@ func TestLoginByIntune_BotAccountBlocked(t *testing.T) { // Create bot account bot := th.CreateBot(t) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) // Create mock Intune interface that returns bot user @@ -2028,7 +2028,7 @@ func TestLoginByIntune_AccountLocked(t *testing.T) { require.Nil(t, appErr) // Reload user to get updated DeleteAt - deletedUser, appErr = th.App.GetUser(deletedUser.Id) + deletedUser, appErr = th.App.GetUser(th.Context, deletedUser.Id) require.Nil(t, appErr) // Create mock Intune interface that returns deleted user diff --git a/server/channels/app/plugin_access_control.go b/server/channels/app/plugin_access_control.go index e186f99d15e2..2d5d046b65ef 100644 --- a/server/channels/app/plugin_access_control.go +++ b/server/channels/app/plugin_access_control.go @@ -47,11 +47,11 @@ func (a *App) pluginAccessControlAvailable() bool { // validatePluginActingUser validates actingUserID is a well-formed ID of an // existing user. Permission checks are the calling plugin's responsibility. -func (a *App) validatePluginActingUser(where, actingUserID string) *model.AppError { +func (a *App) validatePluginActingUser(rctx request.CTX, where, actingUserID string) *model.AppError { if !model.IsValidId(actingUserID) { return model.NewAppError(where, "app.access_control.plugin.invalid_acting_user.app_error", nil, "", http.StatusBadRequest) } - if _, appErr := a.GetUser(actingUserID); appErr != nil { + if _, appErr := a.GetUser(rctx, actingUserID); appErr != nil { return model.NewAppError(where, "app.access_control.plugin.invalid_acting_user.app_error", nil, "", http.StatusBadRequest).Wrap(appErr) } return nil @@ -121,7 +121,7 @@ func (a *App) EvaluatePluginAccessRequest(rctx request.CTX, pluginID, userID, re return a.resolvePluginPolicyExistence(rctx, where, pluginID, resourceType, resourceID, "abac_unavailable") } - user, appErr := a.GetUser(userID) + user, appErr := a.GetUser(rctx, userID) if appErr != nil { rctx.Logger().Warn("Plugin access evaluation: failed to load user; resolving policy existence", mlog.String("plugin_id", pluginID), @@ -185,7 +185,7 @@ func (a *App) SavePluginAccessControlPolicy(rctx request.CTX, pluginID, actingUs if appErr := a.pluginAccessControlScopeCheck("SavePluginAccessControlPolicy", pluginID, policy.Type); appErr != nil { return nil, appErr } - if appErr := a.validatePluginActingUser("SavePluginAccessControlPolicy", actingUserID); appErr != nil { + if appErr := a.validatePluginActingUser(rctx, "SavePluginAccessControlPolicy", actingUserID); appErr != nil { return nil, appErr } @@ -331,7 +331,7 @@ func (a *App) DeletePluginAccessControlPolicy(rctx request.CTX, pluginID, acting if appErr := a.pluginAccessControlScopeCheck(where, pluginID, resourceType); appErr != nil { return appErr } - if appErr := a.validatePluginActingUser(where, actingUserID); appErr != nil { + if appErr := a.validatePluginActingUser(rctx, where, actingUserID); appErr != nil { return appErr } if !model.IsValidId(id) { @@ -366,7 +366,7 @@ func (a *App) CheckPluginAccessControlExpression(rctx request.CTX, pluginID, act if appErr := a.pluginAccessControlScopeCheck("CheckPluginAccessControlExpression", pluginID, resourceType); appErr != nil { return nil, appErr } - if appErr := a.validatePluginActingUser("CheckPluginAccessControlExpression", actingUserID); appErr != nil { + if appErr := a.validatePluginActingUser(rctx, "CheckPluginAccessControlExpression", actingUserID); appErr != nil { return nil, appErr } @@ -380,7 +380,7 @@ func (a *App) QueryUsersForPluginAccessControlExpression(rctx request.CTX, plugi if appErr := a.pluginAccessControlScopeCheck("QueryUsersForPluginAccessControlExpression", pluginID, resourceType); appErr != nil { return nil, appErr } - if appErr := a.validatePluginActingUser("QueryUsersForPluginAccessControlExpression", actingUserID); appErr != nil { + if appErr := a.validatePluginActingUser(rctx, "QueryUsersForPluginAccessControlExpression", actingUserID); appErr != nil { return nil, appErr } @@ -411,7 +411,7 @@ func (a *App) GetPluginAccessControlFieldsAutocomplete(rctx request.CTX, pluginI if a.Srv().ch.AccessControl == nil { return nil, model.NewAppError("GetPluginAccessControlFieldsAutocomplete", "app.pap.get_access_control_auto_complete.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented) } - if appErr := a.validatePluginActingUser("GetPluginAccessControlFieldsAutocomplete", actingUserID); appErr != nil { + if appErr := a.validatePluginActingUser(rctx, "GetPluginAccessControlFieldsAutocomplete", actingUserID); appErr != nil { return nil, appErr } @@ -438,7 +438,7 @@ func (a *App) GetPluginAccessControlVisualAST(rctx request.CTX, pluginID, acting if appErr := a.pluginAccessControlScopeCheck("GetPluginAccessControlVisualAST", pluginID, resourceType); appErr != nil { return nil, appErr } - if appErr := a.validatePluginActingUser("GetPluginAccessControlVisualAST", actingUserID); appErr != nil { + if appErr := a.validatePluginActingUser(rctx, "GetPluginAccessControlVisualAST", actingUserID); appErr != nil { return nil, appErr } diff --git a/server/channels/app/plugin_api.go b/server/channels/app/plugin_api.go index fb61838dd605..5b6f34c5a090 100644 --- a/server/channels/app/plugin_api.go +++ b/server/channels/app/plugin_api.go @@ -89,7 +89,7 @@ func (api *PluginAPI) UnregisterCommand(teamID, trigger string) error { } func (api *PluginAPI) ExecuteSlashCommand(commandArgs *model.CommandArgs) (*model.CommandResponse, error) { - user, appErr := api.app.GetUser(commandArgs.UserId) + user, appErr := api.app.GetUser(api.ctx, commandArgs.UserId) if appErr != nil { return nil, appErr } @@ -266,7 +266,7 @@ func (api *PluginAPI) CreateUser(user *model.User) (*model.User, *model.AppError } func (api *PluginAPI) DeleteUser(userID string) *model.AppError { - user, err := api.app.GetUser(userID) + user, err := api.app.GetUser(api.ctx, userID) if err != nil { return err } @@ -283,7 +283,7 @@ func (api *PluginAPI) GetUsersByIds(usersID []string) ([]*model.User, *model.App } func (api *PluginAPI) GetUser(userID string) (*model.User, *model.AppError) { - return api.app.GetUser(userID) + return api.app.GetUser(api.ctx, userID) } func (api *PluginAPI) GetUserByEmail(email string) (*model.User, *model.AppError) { @@ -420,7 +420,7 @@ func (api *PluginAPI) RemoveUserCustomStatus(userID string) *model.AppError { } func (api *PluginAPI) GetUserCustomStatus(userID string) (*model.CustomStatus, *model.AppError) { - return api.app.GetCustomStatus(userID) + return api.app.GetCustomStatus(api.ctx, userID) } func (api *PluginAPI) GetUsersInChannel(channelID, sortBy string, page, perPage int) ([]*model.User, *model.AppError) { @@ -447,7 +447,7 @@ func (api *PluginAPI) GetLDAPUserAttributes(userID string, attributes []string) return nil, model.NewAppError("GetLdapUserAttributes", "ent.ldap.disabled.app_error", nil, "", http.StatusNotImplemented) } - user, err := api.app.GetUser(userID) + user, err := api.app.GetUser(api.ctx, userID) if err != nil { return nil, err } @@ -1014,7 +1014,7 @@ func (api *PluginAPI) UpdatePost(post *model.Post) (*model.Post, *model.AppError } func (api *PluginAPI) GetProfileImage(userID string) ([]byte, *model.AppError) { - user, err := api.app.GetUser(userID) + user, err := api.app.GetUser(api.ctx, userID) if err != nil { return nil, err } @@ -1024,7 +1024,7 @@ func (api *PluginAPI) GetProfileImage(userID string) ([]byte, *model.AppError) { } func (api *PluginAPI) SetProfileImage(userID string, data []byte) *model.AppError { - if _, err := api.app.GetUser(userID); err != nil { + if _, err := api.app.GetUser(api.ctx, userID); err != nil { return err } @@ -1248,7 +1248,7 @@ func (api *PluginAPI) SendToastMessage(userID, connectionID, message string, opt } func (api *PluginAPI) HasPermissionTo(userID string, permission *model.Permission) bool { - return api.app.HasPermissionTo(userID, permission) + return api.app.HasPermissionTo(api.ctx, userID, permission) } func (api *PluginAPI) HasPermissionToTeam(userID, teamID string, permission *model.Permission) bool { @@ -1291,7 +1291,7 @@ func (api *PluginAPI) CreateBot(bot *model.Bot) (*model.Bot, *model.AppError) { bot.OwnerId = api.id } // Bots cannot be owners of other bots - if user, err := api.app.GetUser(bot.OwnerId); err == nil { + if user, err := api.app.GetUser(api.ctx, bot.OwnerId); err == nil { if user.IsBot { return nil, model.NewAppError("CreateBot", "plugin_api.bot_cant_create_bot", nil, "", http.StatusBadRequest) } diff --git a/server/channels/app/plugin_api_test.go b/server/channels/app/plugin_api_test.go index 9ce084fd623a..85e94dddb762 100644 --- a/server/channels/app/plugin_api_test.go +++ b/server/channels/app/plugin_api_test.go @@ -697,14 +697,14 @@ func TestPluginAPIUserCustomStatus(t *testing.T) { err = api.UpdateUserCustomStatus(user1.Id, custom) assert.Nil(t, err) - userCs, err := th.App.GetCustomStatus(user1.Id) + userCs, err := th.App.GetCustomStatus(th.Context, user1.Id) assert.Nil(t, err) assert.Equal(t, custom, userCs) custom.Text = "" err = api.UpdateUserCustomStatus(user1.Id, custom) assert.Nil(t, err) - userCs, err = th.App.GetCustomStatus(user1.Id) + userCs, err = th.App.GetCustomStatus(th.Context, user1.Id) assert.Nil(t, err) assert.Equal(t, custom, userCs) @@ -712,7 +712,7 @@ func TestPluginAPIUserCustomStatus(t *testing.T) { custom.Emoji = "" err = api.UpdateUserCustomStatus(user1.Id, custom) assert.Nil(t, err) - userCs, err = th.App.GetCustomStatus(user1.Id) + userCs, err = th.App.GetCustomStatus(th.Context, user1.Id) assert.Nil(t, err) assert.Equal(t, custom, userCs) @@ -725,7 +725,7 @@ func TestPluginAPIUserCustomStatus(t *testing.T) { err = api.RemoveUserCustomStatus(user1.Id) assert.Nil(t, err) var csClear *model.CustomStatus - userCs, err = th.App.GetCustomStatus(user1.Id) + userCs, err = th.App.GetCustomStatus(th.Context, user1.Id) assert.Nil(t, err) assert.Equal(t, csClear, userCs) } @@ -2395,7 +2395,7 @@ func TestAPIMetrics(t *testing.T) { _, appErr := th.App.CreateUser(th.Context, user1) require.Nil(t, appErr) time.Sleep(1 * time.Second) - user1, appErr = th.App.GetUser(user1.Id) + user1, appErr = th.App.GetUser(th.Context, user1.Id) require.Nil(t, appErr) require.Equal(t, "plugin-callback-success", user1.Nickname) diff --git a/server/channels/app/plugin_hooks_test.go b/server/channels/app/plugin_hooks_test.go index 89510b32e88e..5a02ceecced6 100644 --- a/server/channels/app/plugin_hooks_test.go +++ b/server/channels/app/plugin_hooks_test.go @@ -889,7 +889,7 @@ func TestUserHasLoggedIn(t *testing.T) { assert.NotNil(t, session) require.EventuallyWithT(t, func(c *assert.CollectT) { - user, _ := th.App.GetUser(th.BasicUser.Id) + user, _ := th.App.GetUser(th.Context, th.BasicUser.Id) assert.Equal(c, user.FirstName, "plugin-callback-success", "Expected firstname overwrite, got default") }, 2*time.Second, 100*time.Millisecond) } @@ -938,7 +938,7 @@ func TestUserHasBeenDeactivated(t *testing.T) { require.Nil(t, err) time.Sleep(2 * time.Second) - user, err = th.App.GetUser(user.Id) + user, err = th.App.GetUser(th.Context, user.Id) require.Nil(t, err) require.Equal(t, "plugin-callback-success", user.Nickname) } @@ -983,7 +983,7 @@ func TestUserHasBeenCreated(t *testing.T) { require.Nil(t, err) time.Sleep(2 * time.Second) - user, err = th.App.GetUser(user.Id) + user, err = th.App.GetUser(th.Context, user.Id) require.Nil(t, err) require.Equal(t, "plugin-callback-success", user.Nickname) } @@ -1169,7 +1169,7 @@ func TestActiveHooks(t *testing.T) { _, appErr := th.App.CreateUser(th.Context, user1) require.Nil(t, appErr) time.Sleep(2 * time.Second) - user1, appErr = th.App.GetUser(user1.Id) + user1, appErr = th.App.GetUser(th.Context, user1.Id) require.Nil(t, appErr) require.Equal(t, "plugin-callback-success", user1.Nickname) @@ -1275,7 +1275,7 @@ func TestHookMetrics(t *testing.T) { _, appErr := th.App.CreateUser(th.Context, user1) require.Nil(t, appErr) time.Sleep(2 * time.Second) - user1, appErr = th.App.GetUser(user1.Id) + user1, appErr = th.App.GetUser(th.Context, user1.Id) require.Nil(t, appErr) require.Equal(t, "plugin-callback-success", user1.Nickname) diff --git a/server/channels/app/post.go b/server/channels/app/post.go index 7fb5d56ac572..950f5476d929 100644 --- a/server/channels/app/post.go +++ b/server/channels/app/post.go @@ -635,7 +635,7 @@ func (a *App) FillInPostProps(rctx request.CTX, post *model.Post, channel *model // Populate AI-generated username from provided user ID if aiGenUserID, ok := post.GetProp(model.PostPropsAIGeneratedByUserID).(string); ok && aiGenUserID != "" { - user, err := a.GetUser(aiGenUserID) + user, err := a.GetUser(rctx, aiGenUserID) if err != nil { // If user doesn't exist, remove the ai_generated_by prop to avoid storing invalid data rctx.Logger().Warn("Failed to get user for AI-generated post, removing ai_generated_by prop", mlog.String("user_id", aiGenUserID), mlog.Err(err)) @@ -3473,7 +3473,7 @@ func (a *App) SendTestMessage(rctx request.CTX, userID string) (*model.Post, *mo return nil, model.NewAppError("SendTestMessage", "app.notifications.send_test_message.errors.no_channel", nil, "", http.StatusInternalServerError).Wrap(err) } - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { return nil, model.NewAppError("SendTestMessage", "app.notifications.send_test_message.errors.no_user", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -3534,7 +3534,7 @@ func (a *App) RewriteMessage( userLocale := "" if session := rctx.Session(); session != nil && session.UserId != "" { - user, appErr := a.GetUser(session.UserId) + user, appErr := a.GetUser(rctx, session.UserId) if appErr == nil { userLocale = user.Locale } else { diff --git a/server/channels/app/post_metadata.go b/server/channels/app/post_metadata.go index 5b4f4e0cb70a..a0bbd601b712 100644 --- a/server/channels/app/post_metadata.go +++ b/server/channels/app/post_metadata.go @@ -448,7 +448,7 @@ func (a *App) sanitizeFileAttachmentsForUser(rctx request.CTX, post *model.Post, return } - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { rctx.Logger().Warn("Failed to get user for file attachment sanitization, stripping attachments", mlog.String("user_id", userID), diff --git a/server/channels/app/post_permission_utils.go b/server/channels/app/post_permission_utils.go index dda89f81117a..224f63faa9f9 100644 --- a/server/channels/app/post_permission_utils.go +++ b/server/channels/app/post_permission_utils.go @@ -11,8 +11,8 @@ import ( "github.com/mattermost/mattermost/server/public/shared/request" ) -func PostPriorityCheckWithApp(where string, a *App, userId string, priority *model.PostPriority, rootId string) *model.AppError { - user, appErr := a.GetUser(userId) +func PostPriorityCheckWithApp(where string, a *App, rctx request.CTX, userId string, priority *model.PostPriority, rootId string) *model.AppError { + user, appErr := a.GetUser(rctx, userId) if appErr != nil { return appErr } @@ -157,7 +157,7 @@ func PostBurnOnReadCheckWithApp(where string, a *App, rctx request.CTX, userId, // Check if the DM is with a bot (AI agents, plugins, etc.) otherUserId := channel.GetOtherUserIdForDM(userId) if otherUserId != "" && otherUserId != userId { - otherUser, err := a.GetUser(otherUserId) + otherUser, err := a.GetUser(rctx, otherUserId) if err != nil { // Failed to retrieve the other user (user not found, DB error, etc.) // Block burn-on-read post as we cannot validate the recipient diff --git a/server/channels/app/post_persistent_notification_test.go b/server/channels/app/post_persistent_notification_test.go index 99133d1eeda6..896c4b8e1410 100644 --- a/server/channels/app/post_persistent_notification_test.go +++ b/server/channels/app/post_persistent_notification_test.go @@ -449,7 +449,7 @@ func TestSendPersistentNotificationsBotSender(t *testing.T) { }) require.Nil(t, appErr) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) _, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, botUser.Id, "") @@ -501,7 +501,7 @@ func TestSendPersistentNotificationsBotSenderNotInChannel(t *testing.T) { }) require.Nil(t, appErr) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) // Make the bot a system admin so it can post to channels it's not a member of diff --git a/server/channels/app/post_test.go b/server/channels/app/post_test.go index d3a43a532539..41e34d099ceb 100644 --- a/server/channels/app/post_test.go +++ b/server/channels/app/post_test.go @@ -1526,7 +1526,7 @@ func TestCreatePost(t *testing.T) { th := Setup(t).InitBasic(t) bot := th.CreateBot(t) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) th.LinkUserToTeam(t, botUser, th.BasicTeam) _, appErr = th.App.AddUserToChannel(th.Context, botUser, th.BasicChannel, false) @@ -1571,7 +1571,7 @@ func TestCreatePost(t *testing.T) { th := Setup(t).InitBasic(t) bot := th.CreateBot(t) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) th.LinkUserToTeam(t, botUser, th.BasicTeam) _, appErr = th.App.AddUserToChannel(th.Context, botUser, th.BasicChannel, false) @@ -1709,7 +1709,7 @@ func TestCreatePost(t *testing.T) { th := Setup(t).InitBasic(t) bot := th.CreateBot(t) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) th.LinkUserToTeam(t, botUser, th.BasicTeam) _, appErr = th.App.AddUserToChannel(th.Context, botUser, th.BasicChannel, false) @@ -1730,7 +1730,7 @@ func TestCreatePost(t *testing.T) { th := Setup(t).InitBasic(t) bot := th.CreateBot(t) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) th.LinkUserToTeam(t, botUser, th.BasicTeam) _, appErr = th.App.AddUserToChannel(th.Context, botUser, th.BasicChannel, false) @@ -1757,7 +1757,7 @@ func TestCreatePost(t *testing.T) { }) bot := th.CreateBot(t) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) th.LinkUserToTeam(t, botUser, th.BasicTeam) _, appErr = th.App.AddUserToChannel(th.Context, botUser, th.BasicChannel, false) @@ -1785,7 +1785,7 @@ func TestCreatePost(t *testing.T) { th := Setup(t).InitBasic(t) bot := th.CreateBot(t) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) dm, appErr := th.App.createDirectChannel(th.Context, botUser.Id, th.BasicUser2.Id) @@ -1814,7 +1814,7 @@ func TestCreatePost(t *testing.T) { th := Setup(t).InitBasic(t) bot := th.CreateBot(t) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) _, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, botUser.Id, "") require.Nil(t, appErr) @@ -1846,7 +1846,7 @@ func TestCreatePost(t *testing.T) { th.AddUserToChannel(t, th.BasicUser2, th.BasicChannel) bot := th.CreateBot(t) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) th.LinkUserToTeam(t, botUser, th.BasicTeam) _, appErr = th.App.AddUserToChannel(th.Context, botUser, th.BasicChannel, false) @@ -2182,7 +2182,7 @@ func TestCreatePostAsUser(t *testing.T) { bot := th.CreateBot(t) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) th.LinkUserToTeam(t, botUser, th.BasicTeam) @@ -2213,7 +2213,7 @@ func TestCreatePostAsUser(t *testing.T) { bot := th.CreateBot(t) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) th.LinkUserToTeam(t, botUser, th.BasicTeam) @@ -6410,7 +6410,7 @@ func TestBurnOnReadRestrictionsForDMsAndBots(t *testing.T) { require.Nil(t, appErr) // Get the bot user - botUser, appErr := th.App.GetUser(createdBot.UserId) + botUser, appErr := th.App.GetUser(th.Context, createdBot.UserId) require.Nil(t, appErr) require.True(t, botUser.IsBot) @@ -6459,7 +6459,7 @@ func TestBurnOnReadRestrictionsForDMsAndBots(t *testing.T) { require.Nil(t, appErr) // Get the bot user - botUser, appErr := th.App.GetUser(createdBot.UserId) + botUser, appErr := th.App.GetUser(th.Context, createdBot.UserId) require.Nil(t, appErr) require.True(t, botUser.IsBot) diff --git a/server/channels/app/properties/access_control_attribute_validation.go b/server/channels/app/properties/access_control_attribute_validation.go index bdf1775da869..c958b001d709 100644 --- a/server/channels/app/properties/access_control_attribute_validation.go +++ b/server/channels/app/properties/access_control_attribute_validation.go @@ -25,7 +25,7 @@ var ( // PermissionChecker checks whether a user has a specific permission. // This avoids a circular dependency between the properties and app packages. -type PermissionChecker func(userID string, permission *model.Permission) bool +type PermissionChecker func(rctx request.CTX, userID string, permission *model.Permission) bool // AccessControlAttributeValidationHook validates and sanitizes property field attributes // and values for managed property groups. It owns the full attr pipeline @@ -458,7 +458,7 @@ func (h *AccessControlAttributeValidationHook) enforceGroupPermissions(rctx requ return nil, fmt.Errorf("missing permission to set managed=admin: no permission checker configured: %w", ErrAdminRequired) } callerID := h.propertyService.extractCallerID(rctx) - if callerID == "" || !h.permissionChecker(callerID, model.PermissionManageSystem) { + if callerID == "" || !h.permissionChecker(rctx, callerID, model.PermissionManageSystem) { return nil, fmt.Errorf("missing permission to set managed=admin: only system admins can set managed=admin: %w", ErrAdminRequired) } field.PermissionValues = &sysadmin diff --git a/server/channels/app/properties/access_control_attribute_validation_test.go b/server/channels/app/properties/access_control_attribute_validation_test.go index b492a4bbc387..a9661298da90 100644 --- a/server/channels/app/properties/access_control_attribute_validation_test.go +++ b/server/channels/app/properties/access_control_attribute_validation_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" "github.com/mattermost/mattermost/server/v8/channels/store" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1086,7 +1087,7 @@ func TestAccessControlAttributeValidationHookManagedAuthorization(t *testing.T) adminUserID := model.NewId() regularUserID := model.NewId() - permChecker := func(userID string, perm *model.Permission) bool { + permChecker := func(_ request.CTX, userID string, perm *model.Permission) bool { return userID == adminUserID && perm.Id == model.PermissionManageSystem.Id } @@ -1571,7 +1572,7 @@ func TestAccessControlAttributeValidationHookSync(t *testing.T) { require.NoError(t, err) adminUserID := model.NewId() - permChecker := func(userID string, perm *model.Permission) bool { + permChecker := func(_ request.CTX, userID string, perm *model.Permission) bool { return userID == adminUserID && perm.Id == model.PermissionManageSystem.Id } @@ -1762,7 +1763,7 @@ func TestAccessControlAttributeValidationHook_Owners(t *testing.T) { group, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_owner_validation", Version: model.PropertyGroupVersionV2}) require.NoError(t, err) - hook := NewAccessControlAttributeValidationHook(th.service, func(userID string, _ *model.Permission) bool { + hook := NewAccessControlAttributeValidationHook(th.service, func(_ request.CTX, userID string, _ *model.Permission) bool { return userID == "admin-user" }, group.ID) th.service.AddHook(hook) diff --git a/server/channels/app/recap.go b/server/channels/app/recap.go index aae5866f4c1a..76eca554ef86 100644 --- a/server/channels/app/recap.go +++ b/server/channels/app/recap.go @@ -63,7 +63,7 @@ func (a *App) CreateRecap(rctx request.CTX, title string, channelIDs []string, a storeErr error ) if model.IsLimitEnabled(limits.MaxRecapsPerDay) { - startOfDayMillis, dayErr := a.getStartOfUserDayMillis(userID) + startOfDayMillis, dayErr := a.getStartOfUserDayMillis(rctx, userID) if dayErr != nil { return nil, dayErr } @@ -215,7 +215,7 @@ func (a *App) RegenerateRecap(rctx request.CTX, userID string, recap *model.Reca } if model.IsLimitEnabled(limits.MaxRecapsPerDay) { - startOfDayMillis, dayErr := a.getStartOfUserDayMillis(userID) + startOfDayMillis, dayErr := a.getStartOfUserDayMillis(rctx, userID) if dayErr != nil { return nil, dayErr } @@ -314,7 +314,7 @@ func (a *App) ProcessRecapChannelWithOptions(rctx request.CTX, recapID, channelI } fetchSince, allowRecentFallback := recapFetchStartAt(options.TimePeriod, lastViewedAt, time.Now()) - remainingPosts, limitErr := a.getRemainingPostsForRecap(userID, recapID) + remainingPosts, limitErr := a.getRemainingPostsForRecap(rctx, userID, recapID) if limitErr != nil { return result, limitErr } @@ -435,7 +435,7 @@ func (a *App) fetchPostsForRecapWithFallback(rctx request.CTX, channelID string, // Enrich with usernames for _, post := range posts { - user, _ := a.GetUser(post.UserId) + user, _ := a.GetUser(rctx, post.UserId) if user != nil { if post.Props == nil { post.Props = make(model.StringInterface) @@ -518,7 +518,7 @@ func (a *App) checkManualRecapCooldown(userID string, limits *model.EffectiveRec "", http.StatusTooManyRequests) } -func (a *App) getRemainingPostsForRecap(userID string, recapID string) (int, *model.AppError) { +func (a *App) getRemainingPostsForRecap(rctx request.CTX, userID string, recapID string) (int, *model.AppError) { const defaultFetchLimit = 100 limits, limitsErr := a.GetEffectiveLimits() @@ -537,7 +537,7 @@ func (a *App) getRemainingPostsForRecap(userID string, recapID string) (int, *mo } if model.IsLimitEnabled(limits.MaxPostsPerDay) { - startOfDayMillis, dayErr := a.getStartOfUserDayMillis(userID) + startOfDayMillis, dayErr := a.getStartOfUserDayMillis(rctx, userID) if dayErr != nil { return 0, dayErr } diff --git a/server/channels/app/recap_limits.go b/server/channels/app/recap_limits.go index 874d0fe06d10..a9caef058e0e 100644 --- a/server/channels/app/recap_limits.go +++ b/server/channels/app/recap_limits.go @@ -8,10 +8,11 @@ import ( "time" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" ) -func (a *App) getStartOfUserDay(userID string) (time.Time, *model.AppError) { - user, appErr := a.GetUser(userID) +func (a *App) getStartOfUserDay(rctx request.CTX, userID string) (time.Time, *model.AppError) { + user, appErr := a.GetUser(rctx, userID) if appErr != nil { return time.Time{}, appErr } @@ -22,8 +23,8 @@ func (a *App) getStartOfUserDay(userID string) (time.Time, *model.AppError) { return startOfDay, nil } -func (a *App) getStartOfUserDayMillis(userID string) (int64, *model.AppError) { - startOfDay, appErr := a.getStartOfUserDay(userID) +func (a *App) getStartOfUserDayMillis(rctx request.CTX, userID string) (int64, *model.AppError) { + startOfDay, appErr := a.getStartOfUserDay(rctx, userID) if appErr != nil { return 0, appErr } @@ -43,14 +44,14 @@ func (a *App) requireAIRecapsEnabled(where string) *model.AppError { } // GetRecapLimitStatus returns the current user's limit status for UI display -func (a *App) GetRecapLimitStatus(userID string) (*model.RecapLimitStatus, *model.AppError) { +func (a *App) GetRecapLimitStatus(rctx request.CTX, userID string) (*model.RecapLimitStatus, *model.AppError) { // Get effective limits limits, appErr := a.GetEffectiveLimits() if appErr != nil { return nil, appErr } - startOfDay, appErr := a.getStartOfUserDay(userID) + startOfDay, appErr := a.getStartOfUserDay(rctx, userID) if appErr != nil { return nil, appErr } diff --git a/server/channels/app/report.go b/server/channels/app/report.go index 89b5de0c3065..949b26d37e5b 100644 --- a/server/channels/app/report.go +++ b/server/channels/app/report.go @@ -123,7 +123,7 @@ func (a *App) SendReportToUser(rctx request.CTX, job *model.Job, format string) return err } - user, err := a.GetUser(requestingUserId) + user, err := a.GetUser(rctx, requestingUserId) if err != nil { return err } @@ -240,7 +240,7 @@ func (a *App) StartUsersBatchExport(rctx request.CTX, ro *model.UserReportOption return } - user, err := a.GetUser(rctx.Session().UserId) + user, err := a.GetUser(rctx, rctx.Session().UserId) if err != nil { rctx.Logger().Error("Failed to get the user", mlog.Err(err)) return diff --git a/server/channels/app/scheduled_post_job.go b/server/channels/app/scheduled_post_job.go index c561ef9569d2..6515e8f64174 100644 --- a/server/channels/app/scheduled_post_job.go +++ b/server/channels/app/scheduled_post_job.go @@ -266,7 +266,7 @@ func (a *App) postScheduledPost(rctx request.CTX, scheduledPost *model.Scheduled // canPostScheduledPost checks whether the scheduled post be created based on permissions and other checks. func (a *App) canPostScheduledPost(rctx request.CTX, scheduledPost *model.ScheduledPost, channel *model.Channel) (string, error) { - user, appErr := a.GetUser(scheduledPost.UserId) + user, appErr := a.GetUser(rctx, scheduledPost.UserId) if appErr != nil { if appErr.Id == MissingAccountError { rctx.Logger().Debug("canPostScheduledPost user not found for scheduled post", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.String("user_id", scheduledPost.UserId), mlog.String("error_code", model.ScheduledPostErrorCodeUserDoesNotExist)) @@ -364,7 +364,7 @@ func (a *App) canPostScheduledPost(rctx request.CTX, scheduledPost *model.Schedu return model.ScheduledPostErrorInvalidPost, nil } - if appErr := PostPriorityCheckWithApp("ScheduledPostJob.postChecks", a, scheduledPost.UserId, scheduledPost.GetPriority(), scheduledPost.RootId); appErr != nil { + if appErr := PostPriorityCheckWithApp("ScheduledPostJob.postChecks", a, rctx, scheduledPost.UserId, scheduledPost.GetPriority(), scheduledPost.RootId); appErr != nil { rctx.Logger().Debug( "canPostScheduledPost post priority check failed", mlog.String("scheduled_post_id", scheduledPost.Id), @@ -503,7 +503,7 @@ func (a *App) notifyUser(rctx request.CTX, userId string, userFailedMessages []* return } - user, err := a.GetUser(userId) + user, err := a.GetUser(rctx, userId) if err != nil { rctx.Logger().Error("Failed to get the user", mlog.Err(err)) return diff --git a/server/channels/app/scheduled_recap.go b/server/channels/app/scheduled_recap.go index 4ccb41b9bf7a..48975a892ea1 100644 --- a/server/channels/app/scheduled_recap.go +++ b/server/channels/app/scheduled_recap.go @@ -233,7 +233,7 @@ func (a *App) CreateRecapFromSchedule(rctx request.CTX, sr *model.ScheduledRecap err error ) if model.IsLimitEnabled(limits.MaxRecapsPerDay) { - startOfDayMillis, dayErr := a.getStartOfUserDayMillis(sr.UserId) + startOfDayMillis, dayErr := a.getStartOfUserDayMillis(rctx, sr.UserId) if dayErr != nil { return nil, dayErr } diff --git a/server/channels/app/server.go b/server/channels/app/server.go index 6687f7d8c5d9..7fa0077d154c 100644 --- a/server/channels/app/server.go +++ b/server/channels/app/server.go @@ -337,14 +337,14 @@ func NewServer(options ...Option) (*Server, error) { // Attribute validation hook — validates visibility, sort_order on fields, // field-type constraints on values (options, user IDs, value_type), and // managed-flag authorization + permission level enforcement. - permChecker := func(userID string, perm *model.Permission) bool { + permChecker := func(rctx request.CTX, userID string, perm *model.Permission) bool { // Local-mode (unrestricted) sessions are tagged with // CallerIDLocalAdmin by the HTTP layer; grant them admin // permissions without a user lookup. if userID == model.CallerIDLocalAdmin { return true } - return app.HasPermissionTo(userID, perm) + return app.HasPermissionTo(rctx, userID, perm) } attrValidationHook := properties.NewAccessControlAttributeValidationHook(s.propertyService, permChecker, cpaGroup.ID) s.propertyService.AddHook(attrValidationHook) diff --git a/server/channels/app/session.go b/server/channels/app/session.go index f433136d1f9e..9c1592ea6f05 100644 --- a/server/channels/app/session.go +++ b/server/channels/app/session.go @@ -31,7 +31,7 @@ func (a *App) CreateSession(rctx request.CTX, session *model.Session) (*model.Se // remote/synthetic users cannot create sessions. This lookup will already be cached. // Some unit tests rely on sessions being created for users that don't exist, therefore // missing users are allowed. - user, appErr := a.GetUser(session.UserId) + user, appErr := a.GetUser(rctx, session.UserId) if appErr != nil && appErr.StatusCode != http.StatusNotFound { return nil, appErr } diff --git a/server/channels/app/shared_channel_global_user_sync_self_referential_test.go b/server/channels/app/shared_channel_global_user_sync_self_referential_test.go index 7976ffd4b168..ab14a345df85 100644 --- a/server/channels/app/shared_channel_global_user_sync_self_referential_test.go +++ b/server/channels/app/shared_channel_global_user_sync_self_referential_test.go @@ -204,7 +204,7 @@ func TestSharedChannelGlobalUserSyncSelfReferential(t *testing.T) { // Create bot bot := th.CreateBot(t) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) botUser.UpdateAt = baseTime + 300 _, err = ss.User().Update(th.Context, botUser, true) @@ -1222,7 +1222,7 @@ func TestSharedChannelGlobalUserSyncSelfReferential(t *testing.T) { }, 2*time.Second, 100*time.Millisecond, "Synced user should NEVER be synced back to its originating cluster") // Verify that the synced user still exists locally but wasn't synced - user, appErr := th.App.GetUser(syncedUserOnB.Id) + user, appErr := th.App.GetUser(th.Context, syncedUserOnB.Id) require.Nil(t, appErr) assert.NotNil(t, user.RemoteId, "Synced user should still have RemoteId") assert.Equal(t, clusterB.RemoteId, *user.RemoteId, "RemoteId should point to origin cluster") diff --git a/server/channels/app/shared_channel_membership_sync_self_referential_test.go b/server/channels/app/shared_channel_membership_sync_self_referential_test.go index fd16ef73b3c9..c85cc862f3ec 100644 --- a/server/channels/app/shared_channel_membership_sync_self_referential_test.go +++ b/server/channels/app/shared_channel_membership_sync_self_referential_test.go @@ -236,7 +236,7 @@ func TestSharedChannelMembershipSyncSelfReferential(t *testing.T) { // Add users that should be synced (including bots and system admins) // Add a bot bot := th.CreateBot(t) - botUser, appErr := th.App.GetUser(bot.UserId) + botUser, appErr := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, appErr) _, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, bot.UserId, th.BasicUser.Id) require.Nil(t, appErr) diff --git a/server/channels/app/shared_channel_test.go b/server/channels/app/shared_channel_test.go index dee7db01433e..6f0c46c27042 100644 --- a/server/channels/app/shared_channel_test.go +++ b/server/channels/app/shared_channel_test.go @@ -1236,7 +1236,7 @@ func TestPluginAPIReceiveSharedChannelSyncMsg(t *testing.T) { assert.Empty(t, resp.UserErrors) // Verify the user was actually created in the database - user, appErr := th.App.GetUser(userID) + user, appErr := th.App.GetUser(th.Context, userID) require.Nil(t, appErr) assert.Contains(t, user.Username, username) // username gets ":remotename" appended by sync assert.Equal(t, rc.RemoteId, user.GetRemoteID()) @@ -1825,7 +1825,7 @@ func TestPluginAPIReceiveSharedChannelProfileImageSyncMsg(t *testing.T) { require.NoError(t, err) // Verify the user's LastPictureUpdate was bumped - updatedUser, appErr := th.App.GetUser(remoteUser.Id) + updatedUser, appErr := th.App.GetUser(th.Context, remoteUser.Id) require.Nil(t, appErr) assert.Greater(t, updatedUser.LastPictureUpdate, lastPictureBefore) @@ -1917,7 +1917,7 @@ func TestPluginRPCSharedChannelSync(t *testing.T) { // --- Post-activation verification: check everything landed in the DB --- // Verify synced user - user, appErr := th.App.GetUser(syncUserID) + user, appErr := th.App.GetUser(th.Context, syncUserID) require.Nil(t, appErr, "synced user should exist in DB") assert.Contains(t, user.Username, "rpc-synced-user") assert.Equal(t, rc.RemoteId, user.GetRemoteID()) diff --git a/server/channels/app/slashcommands/command_groupmsg.go b/server/channels/app/slashcommands/command_groupmsg.go index ddfe207a3b8a..08ec363ad10a 100644 --- a/server/channels/app/slashcommands/command_groupmsg.go +++ b/server/channels/app/slashcommands/command_groupmsg.go @@ -108,7 +108,7 @@ func (*groupmsgProvider) DoCommand(a *app.App, rctx request.CTX, args *model.Com var groupChannel *model.Channel var channelErr *model.AppError - if a.HasPermissionTo(args.UserId, model.PermissionCreateGroupChannel) { + if a.HasPermissionTo(rctx, args.UserId, model.PermissionCreateGroupChannel) { groupChannel, channelErr = a.CreateGroupChannel(rctx, targetUsersSlice, args.UserId) if channelErr != nil { rctx.Logger().Error(channelErr.Error()) diff --git a/server/channels/app/slashcommands/command_leave.go b/server/channels/app/slashcommands/command_leave.go index 85f2bfbc4f70..6b2fd0410c6a 100644 --- a/server/channels/app/slashcommands/command_leave.go +++ b/server/channels/app/slashcommands/command_leave.go @@ -59,7 +59,7 @@ func (*LeaveProvider) DoCommand(a *app.App, rctx request.CTX, args *model.Comman return &model.CommandResponse{GotoLocation: args.SiteURL + "/"} } - user, err := a.GetUser(args.UserId) + user, err := a.GetUser(rctx, args.UserId) if err != nil { return &model.CommandResponse{Text: args.T("api.command_leave.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral} } diff --git a/server/channels/app/slashcommands/command_mobile_logs.go b/server/channels/app/slashcommands/command_mobile_logs.go index 76f6ae6d5c4d..1487aff4dbe9 100644 --- a/server/channels/app/slashcommands/command_mobile_logs.go +++ b/server/channels/app/slashcommands/command_mobile_logs.go @@ -108,7 +108,7 @@ func (*MobileLogsProvider) DoCommand(a *app.App, rctx request.CTX, args *model.C } } - caller, appErr := a.GetUser(args.UserId) + caller, appErr := a.GetUser(rctx, args.UserId) if appErr != nil { rctx.Logger().Error("Failed to get caller for mobile-logs command", mlog.String("user_id", args.UserId), mlog.Err(appErr)) return &model.CommandResponse{ @@ -125,11 +125,11 @@ func (*MobileLogsProvider) DoCommand(a *app.App, rctx request.CTX, args *model.C // Cross-user: callers without system admin get one neutral outcome for any failure // (unknown user, deactivated, disallowed target, or missing role) to avoid username // enumeration. System admins still get explicit not-found messages for support workflows. - if !a.HasPermissionTo(args.UserId, model.PermissionManageSystem) && !a.HasPermissionTo(args.UserId, model.PermissionEditOtherUsers) { + if !a.HasPermissionTo(rctx, args.UserId, model.PermissionManageSystem) && !a.HasPermissionTo(rctx, args.UserId, model.PermissionEditOtherUsers) { return mobileLogsCrossUserUnavailableResponse(args) } - callerHasManageSystem := a.HasPermissionTo(args.UserId, model.PermissionManageSystem) + callerHasManageSystem := a.HasPermissionTo(rctx, args.UserId, model.PermissionManageSystem) targetUser, lookupErr := a.GetUserByUsername(username) if lookupErr != nil { diff --git a/server/channels/app/slashcommands/command_msg.go b/server/channels/app/slashcommands/command_msg.go index 0739cbbad4b4..8b32cabdb67b 100644 --- a/server/channels/app/slashcommands/command_msg.go +++ b/server/channels/app/slashcommands/command_msg.go @@ -77,7 +77,7 @@ func (*msgProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandA if channel, channelErr := a.Srv().Store().Channel().GetByName(args.TeamId, channelName, true); channelErr != nil { var nfErr *store.ErrNotFound if errors.As(channelErr, &nfErr) { - if !a.HasPermissionTo(args.UserId, model.PermissionCreateDirectChannel) { + if !a.HasPermissionTo(rctx, args.UserId, model.PermissionCreateDirectChannel) { return &model.CommandResponse{Text: args.T("api.command_msg.permission.app_error"), ResponseType: model.CommandResponseTypeEphemeral} } diff --git a/server/channels/app/slashcommands/command_remote.go b/server/channels/app/slashcommands/command_remote.go index 583591b14c83..351f360e2204 100644 --- a/server/channels/app/slashcommands/command_remote.go +++ b/server/channels/app/slashcommands/command_remote.go @@ -72,7 +72,7 @@ func (rp *RemoteProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Co } func (rp *RemoteProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse { - if !a.HasPermissionTo(args.UserId, model.PermissionManageSecureConnections) { + if !a.HasPermissionTo(rctx, args.UserId, model.PermissionManageSecureConnections) { return response(args.T("api.command_remote.permission_required", map[string]any{"Permission": "manage_secure_connections"})) } @@ -97,7 +97,7 @@ func (rp *RemoteProvider) DoCommand(a *app.App, rctx request.CTX, args *model.Co } func (rp *RemoteProvider) GetAutoCompleteListItems(rctx request.CTX, a *app.App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg, parsed, toBeParsed string) ([]model.AutocompleteListItem, error) { - if !a.HasPermissionTo(commandArgs.UserId, model.PermissionManageSecureConnections) { + if !a.HasPermissionTo(rctx, commandArgs.UserId, model.PermissionManageSecureConnections) { return nil, errors.New("You require `manage_secure_connections` permission to manage secure connections.") } diff --git a/server/channels/app/slashcommands/command_share.go b/server/channels/app/slashcommands/command_share.go index c71c3d3f5a7b..8f6c133d3792 100644 --- a/server/channels/app/slashcommands/command_share.go +++ b/server/channels/app/slashcommands/command_share.go @@ -63,7 +63,7 @@ func (sp *ShareProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Com } func (sp *ShareProvider) GetAutoCompleteListItems(rctx request.CTX, a *app.App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg, parsed, toBeParsed string) ([]model.AutocompleteListItem, error) { - if !a.HasPermissionTo(commandArgs.UserId, model.PermissionManageSharedChannels) { + if !a.HasPermissionTo(rctx, commandArgs.UserId, model.PermissionManageSharedChannels) { return []model.AutocompleteListItem{}, nil } @@ -127,7 +127,7 @@ func (sp *ShareProvider) getAutoCompleteUnInviteRemote(a *app.App, _ *model.Comm } func (sp *ShareProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse { - if !a.HasPermissionTo(args.UserId, model.PermissionManageSharedChannels) { + if !a.HasPermissionTo(rctx, args.UserId, model.PermissionManageSharedChannels) { return response(args.T("api.command_share.permission_required", map[string]any{"Permission": "manage_shared_channels"})) } diff --git a/server/channels/app/slashcommands/command_share_test.go b/server/channels/app/slashcommands/command_share_test.go index 1ccfecd68c72..1d9e4e97d7c9 100644 --- a/server/channels/app/slashcommands/command_share_test.go +++ b/server/channels/app/slashcommands/command_share_test.go @@ -243,7 +243,7 @@ func TestShareProviderGetAutoCompleteListItemsPermission(t *testing.T) { t.Run("invite without manage_shared_channels permission returns no remote cluster data", func(t *testing.T) { th := setupForSharedChannels(t).initBasic(t) - require.False(t, th.App.HasPermissionTo(th.BasicUser.Id, model.PermissionManageSharedChannels), + require.False(t, th.App.HasPermissionTo(th.Context, th.BasicUser.Id, model.PermissionManageSharedChannels), "precondition: BasicUser must not have manage_shared_channels for this subtest") rc := seedRemote(t, th) @@ -270,7 +270,7 @@ func TestShareProviderGetAutoCompleteListItemsPermission(t *testing.T) { t.Run("uninvite without manage_shared_channels permission returns no remote cluster data", func(t *testing.T) { th := setupForSharedChannels(t).initBasic(t) - require.False(t, th.App.HasPermissionTo(th.BasicUser.Id, model.PermissionManageSharedChannels), + require.False(t, th.App.HasPermissionTo(th.Context, th.BasicUser.Id, model.PermissionManageSharedChannels), "precondition: BasicUser must not have manage_shared_channels for this subtest") rc := seedRemote(t, th) @@ -393,7 +393,7 @@ func TestShareProviderGetAutoCompleteListItemsAdjacentRoles(t *testing.T) { th := setupForSharedChannels(t).initBasic(t) guest := th.createGuest(t) - require.False(t, th.App.HasPermissionTo(guest.Id, model.PermissionManageSharedChannels), + require.False(t, th.App.HasPermissionTo(th.Context, guest.Id, model.PermissionManageSharedChannels), "precondition: a freshly-created guest must not have manage_shared_channels") rc := seedRemote(t, th) @@ -420,7 +420,7 @@ func TestShareProviderGetAutoCompleteListItemsAdjacentRoles(t *testing.T) { th := setupForSharedChannels(t).initBasic(t) guest := th.createGuest(t) - require.False(t, th.App.HasPermissionTo(guest.Id, model.PermissionManageSharedChannels), + require.False(t, th.App.HasPermissionTo(th.Context, guest.Id, model.PermissionManageSharedChannels), "precondition: a freshly-created guest must not have manage_shared_channels") rc := seedRemote(t, th) @@ -446,7 +446,7 @@ func TestShareProviderGetAutoCompleteListItemsAdjacentRoles(t *testing.T) { t.Run("system admin receives remote cluster data on invite", func(t *testing.T) { th := setupForSharedChannels(t).initBasic(t) - require.True(t, th.App.HasPermissionTo(th.SystemAdminUser.Id, model.PermissionManageSharedChannels), + require.True(t, th.App.HasPermissionTo(th.Context, th.SystemAdminUser.Id, model.PermissionManageSharedChannels), "precondition: SystemAdminUser must have manage_shared_channels via inherited permissions") rc := seedRemote(t, th) @@ -479,7 +479,7 @@ func TestShareProviderGetAutoCompleteListItemsAdjacentRoles(t *testing.T) { t.Run("system admin receives remote cluster data on uninvite", func(t *testing.T) { th := setupForSharedChannels(t).initBasic(t) - require.True(t, th.App.HasPermissionTo(th.SystemAdminUser.Id, model.PermissionManageSharedChannels), + require.True(t, th.App.HasPermissionTo(th.Context, th.SystemAdminUser.Id, model.PermissionManageSharedChannels), "precondition: SystemAdminUser must have manage_shared_channels via inherited permissions") rc := seedRemote(t, th) diff --git a/server/channels/app/status.go b/server/channels/app/status.go index ebd904ac3643..d99baaa19829 100644 --- a/server/channels/app/status.go +++ b/server/channels/app/status.go @@ -93,7 +93,7 @@ func (a *App) SetCustomStatus(rctx request.CTX, userID string, cs *model.CustomS } } - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { return err } @@ -114,7 +114,7 @@ func (a *App) SetCustomStatus(rctx request.CTX, userID string, cs *model.CustomS } func (a *App) RemoveCustomStatus(rctx request.CTX, userID string) *model.AppError { - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { return err } @@ -128,8 +128,8 @@ func (a *App) RemoveCustomStatus(rctx request.CTX, userID string) *model.AppErro return nil } -func (a *App) GetCustomStatus(userID string) (*model.CustomStatus, *model.AppError) { - user, err := a.GetUser(userID) +func (a *App) GetCustomStatus(rctx request.CTX, userID string) (*model.CustomStatus, *model.AppError) { + user, err := a.GetUser(rctx, userID) if err != nil { return &model.CustomStatus{}, err } diff --git a/server/channels/app/status_test.go b/server/channels/app/status_test.go index e37819679917..7cacc87553c7 100644 --- a/server/channels/app/status_test.go +++ b/server/channels/app/status_test.go @@ -30,7 +30,7 @@ func TestCustomStatus(t *testing.T) { err := th.App.SetCustomStatus(th.Context, user.Id, cs) require.Nil(t, err, "failed to set custom status %v", err) - csSaved, err := th.App.GetCustomStatus(user.Id) + csSaved, err := th.App.GetCustomStatus(th.Context, user.Id) require.Nil(t, err, "failed to get custom status after save %v", err) require.Equal(t, cs, csSaved) @@ -38,7 +38,7 @@ func TestCustomStatus(t *testing.T) { require.Nil(t, err, "failed to clear custom status %v", err) var csClear *model.CustomStatus - csSaved, err = th.App.GetCustomStatus(user.Id) + csSaved, err = th.App.GetCustomStatus(th.Context, user.Id) require.Nil(t, err, "failed to get custom status after clear %v", err) require.Equal(t, csClear, csSaved) } @@ -191,7 +191,7 @@ func TestSetCustomStatus(t *testing.T) { require.Nil(t, err) } - customStatus, err := th.App.GetCustomStatus(th.BasicUser.Id) + customStatus, err := th.App.GetCustomStatus(th.Context, th.BasicUser.Id) require.Nil(t, err) diff --git a/server/channels/app/team.go b/server/channels/app/team.go index 0b90fbb543c0..2ed1a9e72579 100644 --- a/server/channels/app/team.go +++ b/server/channels/app/team.go @@ -138,7 +138,7 @@ func (a *App) CreateTeam(rctx request.CTX, team *model.Team) (*model.Team, *mode } func (a *App) CreateTeamWithUser(rctx request.CTX, team *model.Team, userID string) (*model.Team, *model.AppError) { - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { return nil, err } @@ -875,7 +875,7 @@ func (a *App) JoinUserToTeam(rctx request.CTX, team *model.Team, user *model.Use var actor *model.User if userRequestorId != "" { - actor, _ = a.GetUser(userRequestorId) + actor, _ = a.GetUser(rctx, userRequestorId) } a.Srv().Go(func() { @@ -1286,7 +1286,7 @@ func (a *App) RemoveUserFromTeam(rctx request.CTX, teamID string, userID string, func (a *App) postProcessTeamMemberLeave(rctx request.CTX, teamMember *model.TeamMember, requestorId string) *model.AppError { var actor *model.User if requestorId != "" { - actor, _ = a.GetUser(requestorId) + actor, _ = a.GetUser(rctx, requestorId) } a.Srv().Go(func() { diff --git a/server/channels/app/team_access_control.go b/server/channels/app/team_access_control.go index 8666c9abd7c1..e93b79a4765d 100644 --- a/server/channels/app/team_access_control.go +++ b/server/channels/app/team_access_control.go @@ -358,7 +358,7 @@ func (a *App) ValidateTeamAdminSelfInclusion(rctx request.CTX, userID, expressio // nil to have it resolved here. func (a *App) SendTeamAccessControlRemovalNotification(rctx request.CTX, systemBot *model.Bot, userID string, team *model.Team) *model.AppError { locale := "" - if user, err := a.GetUser(userID); err == nil { + if user, err := a.GetUser(rctx, userID); err == nil { locale = user.Locale } T := i18n.GetUserTranslations(locale) @@ -377,7 +377,7 @@ func (a *App) SendTeamAccessControlAdditionNotification(rctx request.CTX, system a.LogAuditRec(rctx, rec, nil) locale := "" - if user, err := a.GetUser(userID); err == nil { + if user, err := a.GetUser(rctx, userID); err == nil { locale = user.Locale } T := i18n.GetUserTranslations(locale) diff --git a/server/channels/app/team_directory_visibility.go b/server/channels/app/team_directory_visibility.go index 93ea84fe8d0e..d8080de7b7e6 100644 --- a/server/channels/app/team_directory_visibility.go +++ b/server/channels/app/team_directory_visibility.go @@ -169,7 +169,7 @@ func (a *App) FilterNonQualifyingTeamsForUser(rctx request.CTX, teams []*model.T } userOnce.Do(func() { - user, userErr = a.GetUser(userID) + user, userErr = a.GetUser(rctx, userID) }) if userErr != nil { return nil, 0, userErr @@ -261,7 +261,7 @@ func (a *App) AnnotateRecommendedTeamsForUser(rctx request.CTX, teams []*model.T } userOnce.Do(func() { - user, userErr = a.GetUser(userID) + user, userErr = a.GetUser(rctx, userID) }) if userErr != nil { return diff --git a/server/channels/app/terms_of_service.go b/server/channels/app/terms_of_service.go index e3f4a8447183..c3cdab139679 100644 --- a/server/channels/app/terms_of_service.go +++ b/server/channels/app/terms_of_service.go @@ -8,16 +8,17 @@ import ( "net/http" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" "github.com/mattermost/mattermost/server/v8/channels/store" ) -func (a *App) CreateTermsOfService(text, userID string) (*model.TermsOfService, *model.AppError) { +func (a *App) CreateTermsOfService(rctx request.CTX, text, userID string) (*model.TermsOfService, *model.AppError) { termsOfService := &model.TermsOfService{ Text: text, UserId: userID, } - if _, appErr := a.GetUser(userID); appErr != nil { + if _, appErr := a.GetUser(rctx, userID); appErr != nil { return nil, appErr } diff --git a/server/channels/app/user.go b/server/channels/app/user.go index 284e5fe01f05..cd1c850eab73 100644 --- a/server/channels/app/user.go +++ b/server/channels/app/user.go @@ -539,9 +539,8 @@ func (a *App) AddUserToTeamByInviteIfNeeded(rctx request.CTX, user *model.User, return nil } -// TODO: Migrate this compatibility wrapper to accept request.CTX. -func (a *App) GetUser(userID string) (*model.User, *model.AppError) { - user, err := a.ch.srv.userService.GetUser(request.EmptyContext(a.Log()), userID) +func (a *App) GetUser(rctx request.CTX, userID string) (*model.User, *model.AppError) { + user, err := a.ch.srv.userService.GetUser(rctx, userID) if err != nil { var nfErr *store.ErrNotFound switch { @@ -934,8 +933,8 @@ func (a *App) sanitizeProfiles(users []*model.User, asAdmin bool) []*model.User return users } -func (a *App) GenerateMfaSecret(userID string) (*model.MfaSecret, *model.AppError) { - user, appErr := a.GetUser(userID) +func (a *App) GenerateMfaSecret(rctx request.CTX, userID string) (*model.MfaSecret, *model.AppError) { + user, appErr := a.GetUser(rctx, userID) if appErr != nil { return nil, appErr } @@ -952,8 +951,8 @@ func (a *App) GenerateMfaSecret(userID string) (*model.MfaSecret, *model.AppErro return mfaSecret, nil } -func (a *App) ActivateMfa(userID, token string) *model.AppError { - user, appErr := a.GetUser(userID) +func (a *App) ActivateMfa(rctx request.CTX, userID, token string) *model.AppError { + user, appErr := a.GetUser(rctx, userID) if appErr != nil { return appErr } @@ -981,8 +980,8 @@ func (a *App) ActivateMfa(userID, token string) *model.AppError { return nil } -func (a *App) DeactivateMfa(userID string) *model.AppError { - user, appErr := a.GetUser(userID) +func (a *App) DeactivateMfa(rctx request.CTX, userID string) *model.AppError { + user, appErr := a.GetUser(rctx, userID) if appErr != nil { return appErr } @@ -1050,7 +1049,7 @@ func (a *App) SetDefaultProfileImage(rctx request.CTX, user *model.User) *model. return err } - updatedUser, appErr := a.GetUser(user.Id) + updatedUser, appErr := a.GetUser(rctx, user.Id) if appErr != nil { rctx.Logger().Warn("Error in getting users profile forcing logout", mlog.String("user_id", user.Id), mlog.Err(appErr)) return nil @@ -1140,7 +1139,7 @@ func (a *App) SetProfileImageFromFile(rctx request.CTX, userID string, file io.R } func (a *App) UpdatePasswordAsUser(rctx request.CTX, userID, currentPassword, newPassword string) *model.AppError { - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { return err } @@ -1172,7 +1171,7 @@ func (a *App) UpdatePasswordAsUser(rctx request.CTX, userID, currentPassword, ne func (a *App) userDeactivated(rctx request.CTX, userID string) *model.AppError { a.SetStatusOffline(userID, false, true) - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { return err } @@ -1470,7 +1469,7 @@ func (a *App) IsProfileImageLockedForUser(session model.Session, user *model.Use } func (a *App) PatchUser(rctx request.CTX, userID string, patch *model.UserPatch, asAdmin bool) (*model.User, *model.AppError) { - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { return nil, err } @@ -1642,7 +1641,7 @@ func (a *App) UpdateUser(rctx request.CTX, user *model.User, sendNotifications b rctx.Logger().Warn("Error with updating default profile image", mlog.Err(err)) } - tempUser, getUserErr := a.GetUser(user.Id) + tempUser, getUserErr := a.GetUser(rctx, user.Id) if getUserErr != nil { rctx.Logger().Warn("Error when retrieving user after profile picture update, avatar may fail to update automatically on client applications.", mlog.Err(getUserErr)) } else { @@ -1691,7 +1690,7 @@ func (a *App) UpdateUser(rctx request.CTX, user *model.User, sendNotifications b } func (a *App) UpdateUserActive(rctx request.CTX, userID string, active bool) *model.AppError { - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { return err } @@ -1722,17 +1721,17 @@ func (a *App) updateUserNotifyProps(userID string, props map[string]string) *mod func (a *App) UpdateMfa(rctx request.CTX, activate bool, userID, token string) *model.AppError { if activate { - if err := a.ActivateMfa(userID, token); err != nil { + if err := a.ActivateMfa(rctx, userID, token); err != nil { return err } } else { - if err := a.DeactivateMfa(userID); err != nil { + if err := a.DeactivateMfa(rctx, userID); err != nil { return err } } a.Srv().Go(func() { - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { rctx.Logger().Error("Failed to get user", mlog.Err(err)) return @@ -1747,7 +1746,7 @@ func (a *App) UpdateMfa(rctx request.CTX, activate bool, userID, token string) * } func (a *App) UpdatePasswordByUserIdSendEmail(rctx request.CTX, userID, newPassword, method string) *model.AppError { - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { return err } @@ -1823,8 +1822,8 @@ func (a *App) UpdatePasswordSendEmail(rctx request.CTX, user *model.User, newPas return nil } -func (a *App) UpdateHashedPasswordByUserId(userID, newHashedPassword string) *model.AppError { - user, err := a.GetUser(userID) +func (a *App) UpdateHashedPasswordByUserId(rctx request.CTX, userID, newHashedPassword string) *model.AppError { + user, err := a.GetUser(rctx, userID) if err != nil { return err } @@ -1873,7 +1872,7 @@ func (a *App) resetPasswordFromToken(rctx request.CTX, userSuppliedTokenString, return model.NewAppError("resetPassword", "api.user.reset_password.token_parse.error", nil, "", http.StatusInternalServerError) } - user, err := a.GetUser(tokenData.UserId) + user, err := a.GetUser(rctx, tokenData.UserId) if err != nil { return err } @@ -2054,7 +2053,7 @@ func (a *App) DeleteToken(token *model.Token) *model.AppError { } func (a *App) UpdateUserRoles(rctx request.CTX, userID string, newRoles string, sendWebSocketEvent bool) (*model.User, *model.AppError) { - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { err.StatusCode = http.StatusBadRequest return nil, err @@ -2329,13 +2328,13 @@ func (a *App) VerifyEmailFromToken(rctx request.CTX, userSuppliedTokenString str return model.NewAppError("VerifyEmailFromToken", "api.user.verify_email.token_parse.error", nil, "", http.StatusInternalServerError) } - user, err := a.GetUser(tokenData.UserId) + user, err := a.GetUser(rctx, tokenData.UserId) if err != nil { return err } tokenData.Email = strings.ToLower(tokenData.Email) - if err := a.VerifyUserEmail(tokenData.UserId, tokenData.Email); err != nil { + if err := a.VerifyUserEmail(rctx, tokenData.UserId, tokenData.Email); err != nil { return err } @@ -2392,14 +2391,14 @@ func (a *App) GetFilteredUsersStats(options *model.UserCountOptions) (*model.Use return stats, nil } -func (a *App) VerifyUserEmail(userID, email string) *model.AppError { +func (a *App) VerifyUserEmail(rctx request.CTX, userID, email string) *model.AppError { if _, err := a.Srv().Store().User().VerifyEmail(userID, email); err != nil { return model.NewAppError("VerifyUserEmail", "app.user.verify_email.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } a.InvalidateCacheForUser(userID) - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { return err } @@ -2754,7 +2753,7 @@ func (a *App) userBelongsToChannels(userID string, channelIDs []string) (bool, * } func (a *App) GetViewUsersRestrictions(rctx request.CTX, userID string) (*model.ViewUsersRestrictions, *model.AppError) { - if a.HasPermissionTo(userID, model.PermissionViewMembers) { + if a.HasPermissionTo(rctx, userID, model.PermissionViewMembers) { return nil, nil } @@ -2803,7 +2802,7 @@ func (a *App) PromoteGuestToUser(rctx request.CTX, user *model.User, requestorId } } - promotedUser, err := a.GetUser(user.Id) + promotedUser, err := a.GetUser(rctx, user.Id) if err != nil { rctx.Logger().Warn("Failed to get user on promote guest to user", mlog.Err(err)) } else { @@ -2912,7 +2911,7 @@ func (a *App) PublishUserTyping(userID, channelID, parentId string) *model.AppEr func (a *App) invalidateUserCacheAndPublish(rctx request.CTX, userID string) { a.InvalidateCacheForUser(userID) - user, userErr := a.GetUser(userID) + user, userErr := a.GetUser(rctx, userID) if userErr != nil { rctx.Logger().Error("Error in getting users profile", mlog.String("user_id", userID), mlog.Err(userErr)) return @@ -3166,7 +3165,7 @@ func (a *App) UpdateThreadFollowForUserFromChannelAdd(rctx request.CTX, userID, if appErr != nil { return appErr } - user, appErr := a.GetUser(userID) + user, appErr := a.GetUser(rctx, userID) if appErr != nil { return appErr } @@ -3232,7 +3231,7 @@ func (a *App) UpdateThreadReadForUserByPost(rctx request.CTX, currentSessionId, } func (a *App) UpdateThreadReadForUser(rctx request.CTX, currentSessionId, userID, teamID, threadID string, timestamp int64) (*model.ThreadResponse, *model.AppError) { - user, err := a.GetUser(userID) + user, err := a.GetUser(rctx, userID) if err != nil { return nil, err } diff --git a/server/channels/app/user_terms_of_service_test.go b/server/channels/app/user_terms_of_service_test.go index 2b8cbe236aae..265d9af950d3 100644 --- a/server/channels/app/user_terms_of_service_test.go +++ b/server/channels/app/user_terms_of_service_test.go @@ -18,7 +18,7 @@ func TestUserTermsOfService(t *testing.T) { assert.Nil(t, userTermsOfService) assert.Equal(t, "app.user_terms_of_service.get_by_user.no_rows.app_error", err.Id) - termsOfService, err := th.App.CreateTermsOfService("terms of service", th.BasicUser.Id) + termsOfService, err := th.App.CreateTermsOfService(th.Context, "terms of service", th.BasicUser.Id) checkNoError(t, err) err = th.App.SaveUserTermsOfService(th.BasicUser.Id, termsOfService.Id, true) diff --git a/server/channels/app/user_test.go b/server/channels/app/user_test.go index 8d64c13bb3c7..9f2b02c233ae 100644 --- a/server/channels/app/user_test.go +++ b/server/channels/app/user_test.go @@ -121,7 +121,7 @@ func TestUpdateDefaultProfileImage(t *testing.T) { err = th.App.UpdateDefaultProfileImage(th.Context, user) require.Nil(t, err) - user = getUserFromDB(th.App, user.Id, t) + user = getUserFromDB(th.Context, th.App, user.Id, t) assert.Less(t, user.LastPictureUpdate, -startTime, "LastPictureUpdate should be set to -(current time in milliseconds)") } @@ -256,7 +256,7 @@ func TestUpdateUser(t *testing.T) { // Give the user a LastPictureUpdate to mimic having a custom profile picture err := th.App.Srv().Store().User().UpdateLastPictureUpdate(user.Id) require.NoError(t, err) - iUser, errGetUser := th.App.GetUser(user.Id) + iUser, errGetUser := th.App.GetUser(th.Context, user.Id) require.Nil(t, errGetUser) iUser.Username = "updatedUsername" iLastPictureUpdate := iUser.LastPictureUpdate @@ -402,7 +402,7 @@ func TestCreateUser(t *testing.T) { time.Sleep(1 * time.Second) - user, err = th.App.GetUser(user.Id) + user, err = th.App.GetUser(th.Context, user.Id) require.Nil(t, err) require.Equal(t, "sanitized", user.Nickname) }) @@ -452,7 +452,7 @@ func TestUpdateActiveBotsSideEffect(t *testing.T) { retbot1, err := th.App.GetBot(th.Context, bot.UserId, true) require.Nil(t, err) require.Zero(t, retbot1.DeleteAt) - user1, err := th.App.GetUser(bot.UserId) + user1, err := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, err) require.Zero(t, user1.DeleteAt) @@ -470,7 +470,7 @@ func TestUpdateActiveBotsSideEffect(t *testing.T) { retbot2, err := th.App.GetBot(th.Context, bot.UserId, true) require.Nil(t, err) require.NotZero(t, retbot2.DeleteAt) - user2, err := th.App.GetUser(bot.UserId) + user2, err := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, err) require.NotZero(t, user2.DeleteAt) @@ -573,10 +573,10 @@ func TestUpdateOAuthUserAttrs(t *testing.T) { gitlabUser := getGitlabUserPayload(gitlabUserObj, t) data := bytes.NewReader(gitlabUser) - user = getUserFromDB(th.App, user.Id, t) + user = getUserFromDB(th.Context, th.App, user.Id, t) appErr := th.App.UpdateOAuthUserAttrs(th.Context, data, user, gitlabProvider, "gitlab", nil) require.Nil(t, appErr) - user = getUserFromDB(th.App, user.Id, t) + user = getUserFromDB(th.Context, th.App, user.Id, t) require.Equal(t, gitlabUserObj.Username, user.Username, "user's username is not updated") }) @@ -587,10 +587,10 @@ func TestUpdateOAuthUserAttrs(t *testing.T) { gitlabUser := getGitlabUserPayload(gitlabUserObj, t) data := bytes.NewReader(gitlabUser) - user = getUserFromDB(th.App, user.Id, t) + user = getUserFromDB(th.Context, th.App, user.Id, t) appErr := th.App.UpdateOAuthUserAttrs(th.Context, data, user, gitlabProvider, "gitlab", nil) require.Nil(t, appErr) - user = getUserFromDB(th.App, user.Id, t) + user = getUserFromDB(th.Context, th.App, user.Id, t) require.NotEqual(t, gitlabUserObj.Username, user.Username, "user's username is updated though there already exists another user with the same username") }) @@ -602,10 +602,10 @@ func TestUpdateOAuthUserAttrs(t *testing.T) { gitlabUser := getGitlabUserPayload(gitlabUserObj, t) data := bytes.NewReader(gitlabUser) - user = getUserFromDB(th.App, user.Id, t) + user = getUserFromDB(th.Context, th.App, user.Id, t) appErr := th.App.UpdateOAuthUserAttrs(th.Context, data, user, gitlabProvider, "gitlab", nil) require.Nil(t, appErr) - user = getUserFromDB(th.App, user.Id, t) + user = getUserFromDB(th.Context, th.App, user.Id, t) require.Equal(t, gitlabUserObj.Email, user.Email, "user's email is not updated") @@ -618,10 +618,10 @@ func TestUpdateOAuthUserAttrs(t *testing.T) { gitlabUser := getGitlabUserPayload(gitlabUserObj, t) data := bytes.NewReader(gitlabUser) - user = getUserFromDB(th.App, user.Id, t) + user = getUserFromDB(th.Context, th.App, user.Id, t) appErr := th.App.UpdateOAuthUserAttrs(th.Context, data, user, gitlabProvider, "gitlab", nil) require.Nil(t, appErr) - user = getUserFromDB(th.App, user.Id, t) + user = getUserFromDB(th.Context, th.App, user.Id, t) require.NotEqual(t, gitlabUserObj.Email, user.Email, "user's email is updated though there already exists another user with the same email") }) @@ -632,10 +632,10 @@ func TestUpdateOAuthUserAttrs(t *testing.T) { gitlabUser := getGitlabUserPayload(gitlabUserObj, t) data := bytes.NewReader(gitlabUser) - user = getUserFromDB(th.App, user.Id, t) + user = getUserFromDB(th.Context, th.App, user.Id, t) appErr := th.App.UpdateOAuthUserAttrs(th.Context, data, user, gitlabProvider, "gitlab", nil) require.Nil(t, appErr) - user = getUserFromDB(th.App, user.Id, t) + user = getUserFromDB(th.Context, th.App, user.Id, t) require.Equal(t, "Updated", user.FirstName, "user's first name is not updated") }) @@ -645,10 +645,10 @@ func TestUpdateOAuthUserAttrs(t *testing.T) { gitlabUser := getGitlabUserPayload(gitlabUserObj, t) data := bytes.NewReader(gitlabUser) - user = getUserFromDB(th.App, user.Id, t) + user = getUserFromDB(th.Context, th.App, user.Id, t) appErr := th.App.UpdateOAuthUserAttrs(th.Context, data, user, gitlabProvider, "gitlab", nil) require.Nil(t, appErr) - user = getUserFromDB(th.App, user.Id, t) + user = getUserFromDB(th.Context, th.App, user.Id, t) require.Equal(t, "Lastname", user.LastName, "user's last name is not updated") }) @@ -720,7 +720,7 @@ func TestUpdateUserEmail(t *testing.T) { appErr = th.App.VerifyEmailFromToken(th.Context, token.Token) assert.Nil(t, appErr) - user2, appErr = th.App.GetUser(user2.Id) + user2, appErr = th.App.GetUser(th.Context, user2.Id) assert.Nil(t, appErr) assert.Equal(t, newEmail, user2.Email) assert.True(t, user2.EmailVerified) @@ -846,8 +846,8 @@ func TestUpdateUserEmail(t *testing.T) { }) } -func getUserFromDB(a *App, id string, t *testing.T) *model.User { - user, err := a.GetUser(id) +func getUserFromDB(rctx request.CTX, a *App, id string, t *testing.T) *model.User { + user, err := a.GetUser(rctx, id) require.Nil(t, err, "user is not found", err) return user } @@ -1563,7 +1563,7 @@ func TestPermanentDeleteUser(t *testing.T) { assert.Equal(t, 1, botCount1) // test that bot is deleted from bots table - retUser1, err := th.App.GetUser(bot.UserId) + retUser1, err := th.App.GetUser(th.Context, bot.UserId) assert.Nil(t, err) err = th.App.PermanentDeleteUser(th.Context, retUser1) @@ -2043,7 +2043,7 @@ func TestPromoteGuestToUser(t *testing.T) { err := th.App.PromoteGuestToUser(th.Context, th.BasicUser, th.BasicUser.Id) require.Nil(t, err) - user, err := th.App.GetUser(th.BasicUser.Id) + user, err := th.App.GetUser(th.Context, th.BasicUser.Id) assert.Nil(t, err) assert.Equal(t, "system_user", user.Roles) }) @@ -2054,7 +2054,7 @@ func TestPromoteGuestToUser(t *testing.T) { err := th.App.PromoteGuestToUser(th.Context, guest, th.BasicUser.Id) require.Nil(t, err) - guest, err = th.App.GetUser(guest.Id) + guest, err = th.App.GetUser(th.Context, guest.Id) assert.Nil(t, err) assert.Equal(t, "system_user", guest.Roles) }) @@ -2070,7 +2070,7 @@ func TestPromoteGuestToUser(t *testing.T) { err = th.App.PromoteGuestToUser(th.Context, guest, th.BasicUser.Id) require.Nil(t, err) - guest, err = th.App.GetUser(guest.Id) + guest, err = th.App.GetUser(th.Context, guest.Id) assert.Nil(t, err) assert.Equal(t, "system_user", guest.Roles) teamMember, err = th.App.GetTeamMember(th.Context, th.BasicTeam.Id, guest.Id) @@ -2094,7 +2094,7 @@ func TestPromoteGuestToUser(t *testing.T) { err = th.App.PromoteGuestToUser(th.Context, guest, th.BasicUser.Id) require.Nil(t, err) - guest, err = th.App.GetUser(guest.Id) + guest, err = th.App.GetUser(th.Context, guest.Id) assert.Nil(t, err) assert.Equal(t, "system_user", guest.Roles) teamMember, err = th.App.GetTeamMember(th.Context, th.BasicTeam.Id, guest.Id) @@ -2126,7 +2126,7 @@ func TestPromoteGuestToUser(t *testing.T) { err = th.App.PromoteGuestToUser(th.Context, guest, th.BasicUser.Id) require.Nil(t, err) - guest, err = th.App.GetUser(guest.Id) + guest, err = th.App.GetUser(th.Context, guest.Id) assert.Nil(t, err) assert.Equal(t, "system_user", guest.Roles) teamMember, err = th.App.GetTeamMember(th.Context, th.BasicTeam.Id, guest.Id) @@ -2176,7 +2176,7 @@ func TestDemoteUserToGuest(t *testing.T) { t.Run("Must reject bot user", func(t *testing.T) { bot := th.CreateBot(t) - user, err := th.App.GetUser(bot.UserId) + user, err := th.App.GetUser(th.Context, bot.UserId) require.Nil(t, err) require.True(t, user.IsBot) @@ -2218,7 +2218,7 @@ func TestDemoteUserToGuest(t *testing.T) { err := th.App.DemoteUserToGuest(th.Context, guest) require.Nil(t, err) - user, err := th.App.GetUser(guest.Id) + user, err := th.App.GetUser(th.Context, guest.Id) assert.Nil(t, err) assert.Equal(t, "system_guest", user.Roles) }) @@ -2229,7 +2229,7 @@ func TestDemoteUserToGuest(t *testing.T) { err := th.App.DemoteUserToGuest(th.Context, user) require.Nil(t, err) - user, err = th.App.GetUser(user.Id) + user, err = th.App.GetUser(th.Context, user.Id) assert.Nil(t, err) assert.Equal(t, "system_guest", user.Roles) }) @@ -2245,7 +2245,7 @@ func TestDemoteUserToGuest(t *testing.T) { err = th.App.DemoteUserToGuest(th.Context, user) require.Nil(t, err) - user, err = th.App.GetUser(user.Id) + user, err = th.App.GetUser(th.Context, user.Id) assert.Nil(t, err) assert.Equal(t, "system_guest", user.Roles) teamMember, err = th.App.GetTeamMember(th.Context, th.BasicTeam.Id, user.Id) @@ -2269,7 +2269,7 @@ func TestDemoteUserToGuest(t *testing.T) { err = th.App.DemoteUserToGuest(th.Context, user) require.Nil(t, err) - user, err = th.App.GetUser(user.Id) + user, err = th.App.GetUser(th.Context, user.Id) assert.Nil(t, err) assert.Equal(t, "system_guest", user.Roles) teamMember, err = th.App.GetTeamMember(th.Context, th.BasicTeam.Id, user.Id) @@ -2301,7 +2301,7 @@ func TestDemoteUserToGuest(t *testing.T) { err = th.App.DemoteUserToGuest(th.Context, user) require.Nil(t, err) - user, err = th.App.GetUser(user.Id) + user, err = th.App.GetUser(th.Context, user.Id) assert.Nil(t, err) assert.Equal(t, "system_guest", user.Roles) teamMember, err = th.App.GetTeamMember(th.Context, th.BasicTeam.Id, user.Id) @@ -2349,7 +2349,7 @@ func TestDemoteUserToGuest(t *testing.T) { err = th.App.DemoteUserToGuest(th.Context, user) require.Nil(t, err) - user, err = th.App.GetUser(user.Id) + user, err = th.App.GetUser(th.Context, user.Id) assert.Nil(t, err) assert.Equal(t, "system_guest", user.Roles) @@ -2378,15 +2378,15 @@ func TestDeactivateGuests(t *testing.T) { err := th.App.DeactivateGuests(th.Context) require.Nil(t, err) - guest1, err = th.App.GetUser(guest1.Id) + guest1, err = th.App.GetUser(th.Context, guest1.Id) assert.Nil(t, err) assert.NotEqual(t, int64(0), guest1.DeleteAt) - guest2, err = th.App.GetUser(guest2.Id) + guest2, err = th.App.GetUser(th.Context, guest2.Id) assert.Nil(t, err) assert.NotEqual(t, int64(0), guest2.DeleteAt) - user, err = th.App.GetUser(user.Id) + user, err = th.App.GetUser(th.Context, user.Id) assert.Nil(t, err) assert.Equal(t, int64(0), user.DeleteAt) } @@ -2464,7 +2464,7 @@ func TestDeactivateMfa(t *testing.T) { }) user := th.BasicUser - err := th.App.DeactivateMfa(user.Id) + err := th.App.DeactivateMfa(th.Context, user.Id) require.Nil(t, err) }) } diff --git a/server/channels/jobs/helper_test.go b/server/channels/jobs/helper_test.go index b16fcb351b30..4d19c0153145 100644 --- a/server/channels/jobs/helper_test.go +++ b/server/channels/jobs/helper_test.go @@ -180,15 +180,15 @@ func (th *TestHelper) InitBasic(tb testing.TB) *TestHelper { th.SystemAdminUser = th.CreateUser(tb) _, appErr := th.App.UpdateUserRoles(th.Context, th.SystemAdminUser.Id, model.SystemUserRoleId+" "+model.SystemAdminRoleId, false) require.Nil(tb, appErr) - th.SystemAdminUser, appErr = th.App.GetUser(th.SystemAdminUser.Id) + th.SystemAdminUser, appErr = th.App.GetUser(th.Context, th.SystemAdminUser.Id) require.Nil(tb, appErr) th.BasicUser = th.CreateUser(tb) - th.BasicUser, appErr = th.App.GetUser(th.BasicUser.Id) + th.BasicUser, appErr = th.App.GetUser(th.Context, th.BasicUser.Id) require.Nil(tb, appErr) th.BasicUser2 = th.CreateUser(tb) - th.BasicUser2, appErr = th.App.GetUser(th.BasicUser2.Id) + th.BasicUser2, appErr = th.App.GetUser(th.Context, th.BasicUser2.Id) require.Nil(tb, appErr) th.BasicTeam = th.CreateTeam(tb) diff --git a/server/cmd/mmctl/commands/ldap_e2e_test.go b/server/cmd/mmctl/commands/ldap_e2e_test.go index 0e4497c9b3ee..12fa9c34bd8b 100644 --- a/server/cmd/mmctl/commands/ldap_e2e_test.go +++ b/server/cmd/mmctl/commands/ldap_e2e_test.go @@ -122,7 +122,7 @@ func (s *MmctlE2ETestSuite) TestLdapIDMigrateCmd() { s.Require().Equal(printer.GetLines()[0], "AD/LDAP IdAttribute migration complete. You can now change your IdAttribute to: "+"cn") s.Require().Len(printer.GetErrorLines(), 0) - updatedUser, appErr := s.th.App.GetUser(ldapUser.Id) + updatedUser, appErr := s.th.App.GetUser(s.th.Context, ldapUser.Id) s.Require().Nil(appErr) s.Require().Equal("Dev1", *updatedUser.AuthData) }) diff --git a/server/cmd/mmctl/commands/permissions_role_e2e_test.go b/server/cmd/mmctl/commands/permissions_role_e2e_test.go index 4461972e634d..11b122ee7cd8 100644 --- a/server/cmd/mmctl/commands/permissions_role_e2e_test.go +++ b/server/cmd/mmctl/commands/permissions_role_e2e_test.go @@ -56,7 +56,7 @@ func (s *MmctlE2ETestSuite) TestAssignUsersCmd() { roles := user.Roles - u, err2 := s.th.App.GetUser(user.Id) + u, err2 := s.th.App.GetUser(s.th.Context, user.Id) s.Require().Nil(err2) s.Require().True(u.IsInRole(model.SystemManagerRoleId)) @@ -97,7 +97,7 @@ func (s *MmctlE2ETestSuite) TestUnassignUsersCmd() { s.Require().Len(printer.GetLines(), 0) s.Require().Len(printer.GetErrorLines(), 0) - u, err2 := s.th.App.GetUser(user.Id) + u, err2 := s.th.App.GetUser(s.th.Context, user.Id) s.Require().Nil(err2) s.Require().False(u.IsInRole(model.SystemManagerRoleId)) }) diff --git a/server/cmd/mmctl/commands/user_e2e_test.go b/server/cmd/mmctl/commands/user_e2e_test.go index 37874631065e..f416618b2956 100644 --- a/server/cmd/mmctl/commands/user_e2e_test.go +++ b/server/cmd/mmctl/commands/user_e2e_test.go @@ -34,7 +34,7 @@ func (s *MmctlE2ETestSuite) TestUserActivateCmd() { s.Require().Len(printer.GetLines(), 0) s.Require().Len(printer.GetErrorLines(), 0) - ruser, err := s.th.App.GetUser(user.Id) + ruser, err := s.th.App.GetUser(s.th.Context, user.Id) s.Require().Nil(err) s.Require().Zero(ruser.DeleteAt) }) @@ -51,7 +51,7 @@ func (s *MmctlE2ETestSuite) TestUserActivateCmd() { s.Require().Len(printer.GetErrorLines(), 1) s.Require().Equal(printer.GetErrorLines()[0], "unable to change activation status of user "+user.Id+": You do not have the appropriate permissions.") - ruser, err := s.th.App.GetUser(user.Id) + ruser, err := s.th.App.GetUser(s.th.Context, user.Id) s.Require().Nil(err) s.Require().NotZero(ruser.DeleteAt) }) @@ -84,7 +84,7 @@ func (s *MmctlE2ETestSuite) TestUserDeactivateCmd() { s.Require().Len(printer.GetLines(), 0) s.Require().Len(printer.GetErrorLines(), 0) - ruser, err := s.th.App.GetUser(user.Id) + ruser, err := s.th.App.GetUser(s.th.Context, user.Id) s.Require().Nil(err) s.Require().NotZero(ruser.DeleteAt) }) @@ -101,7 +101,7 @@ func (s *MmctlE2ETestSuite) TestUserDeactivateCmd() { s.Require().Len(printer.GetErrorLines(), 1) s.Require().Equal(printer.GetErrorLines()[0], "unable to change activation status of user "+user.Id+": You do not have the appropriate permissions.") - ruser, err := s.th.App.GetUser(user.Id) + ruser, err := s.th.App.GetUser(s.th.Context, user.Id) s.Require().Nil(err) s.Require().Zero(ruser.DeleteAt) }) @@ -487,7 +487,7 @@ func (s *MmctlE2ETestSuite) TestResetUserMfaCmd() { s.Require().Len(printer.GetErrorLines(), 0) // make sure user is updated after reset mfa - ruser, err := s.th.App.GetUser(user.Id) + ruser, err := s.th.App.GetUser(s.th.Context, user.Id) s.Require().Nil(err) s.Require().NotEqual(ruser.UpdateAt, user.UpdateAt) }) @@ -730,7 +730,7 @@ func (s *MmctlE2ETestSuite) TestDeleteUsersCmd() { s.Require().Equal(newUser.Username, deletedUser.Username) // expect user deleted - _, err = s.th.App.GetUser(newUser.Id) + _, err = s.th.App.GetUser(s.th.Context, newUser.Id) s.Require().NotNil(err) s.Require().Equal("GetUser: Unable to find the user., resource \"User\" not found, id: "+newUser.Id, err.Error()) }) @@ -781,7 +781,7 @@ func (s *MmctlE2ETestSuite) TestDeleteUsersCmd() { s.Require().EqualError(err, expectedErr.Error()) // expect user not deleted - user, err := s.th.App.GetUser(newUser.Id) + user, err := s.th.App.GetUser(s.th.Context, newUser.Id) s.Require().Nil(err) s.Require().Equal(newUser.Username, user.Username) }) @@ -810,7 +810,7 @@ func (s *MmctlE2ETestSuite) TestDeleteUsersCmd() { s.Require().EqualError(err, expectedErr.Error()) // expect user not deleted - user, err := s.th.App.GetUser(newUser.Id) + user, err := s.th.App.GetUser(s.th.Context, newUser.Id) s.Require().Nil(err) s.Require().Equal(newUser.Username, user.Username) }) @@ -838,7 +838,7 @@ func (s *MmctlE2ETestSuite) TestDeleteUsersCmd() { s.Require().Equal(newUser.Username, deletedUser.Username) // expect user deleted - _, err = s.th.App.GetUser(newUser.Id) + _, err = s.th.App.GetUser(s.th.Context, newUser.Id) s.Require().NotNil(err) s.Require().EqualError(err, "GetUser: Unable to find the user., resource \"User\" not found, id: "+newUser.Id) }) @@ -1061,7 +1061,7 @@ func (s *MmctlE2ETestSuite) TestMigrateAuthCmd() { s.Require().Equal("Successfully migrated accounts.", printer.GetLines()[0]) s.Require().Empty(printer.GetErrorLines()) - updatedUser, appErr := s.th.App.GetUser(ldapUser.Id) + updatedUser, appErr := s.th.App.GetUser(s.th.Context, ldapUser.Id) s.Require().Nil(appErr) s.Require().Equal(model.UserAuthServiceSaml, updatedUser.AuthService) }) @@ -1090,7 +1090,7 @@ func (s *MmctlE2ETestSuite) TestMigrateAuthCmd() { s.Require().Equal("Successfully migrated accounts.", printer.GetLines()[0]) s.Require().Empty(printer.GetErrorLines()) - updatedUser, appErr := s.th.App.GetUser(samlUser.Id) + updatedUser, appErr := s.th.App.GetUser(s.th.Context, samlUser.Id) s.Require().Nil(appErr) s.Require().Equal(model.UserAuthServiceLdap, updatedUser.AuthService) }) @@ -1605,7 +1605,7 @@ func (s *MmctlE2ETestSuite) TestUserEditUsernameCmd() { s.Require().Len(printer.GetErrorLines(), 0) // Verify username was updated - updatedUser, appErr := s.th.App.GetUser(user.Id) + updatedUser, appErr := s.th.App.GetUser(s.th.Context, user.Id) s.Require().Nil(appErr) s.Require().Equal(newUsername, updatedUser.Username) @@ -1626,7 +1626,7 @@ func (s *MmctlE2ETestSuite) TestUserEditUsernameCmd() { s.Require().Len(printer.GetErrorLines(), 0) // Verify username was not changed - unchangedUser, appErr := s.th.App.GetUser(user.Id) + unchangedUser, appErr := s.th.App.GetUser(s.th.Context, user.Id) s.Require().Nil(appErr) s.Require().Equal(user.Username, unchangedUser.Username) }) @@ -1643,7 +1643,7 @@ func (s *MmctlE2ETestSuite) TestUserEditUsernameCmd() { s.Require().Len(printer.GetErrorLines(), 0) // Verify username was not changed - unchangedUser, appErr := s.th.App.GetUser(user.Id) + unchangedUser, appErr := s.th.App.GetUser(s.th.Context, user.Id) s.Require().Nil(appErr) s.Require().Equal(user.Username, unchangedUser.Username) }) @@ -1684,7 +1684,7 @@ func (s *MmctlE2ETestSuite) TestUserEditEmailCmd() { s.Require().Len(printer.GetErrorLines(), 0) // Verify email was updated - updatedUser, appErr := s.th.App.GetUser(user.Id) + updatedUser, appErr := s.th.App.GetUser(s.th.Context, user.Id) s.Require().Nil(appErr) s.Require().Equal(newEmail, updatedUser.Email) @@ -1705,7 +1705,7 @@ func (s *MmctlE2ETestSuite) TestUserEditEmailCmd() { s.Require().Len(printer.GetErrorLines(), 0) // Verify email was not changed - unchangedUser, appErr := s.th.App.GetUser(user.Id) + unchangedUser, appErr := s.th.App.GetUser(s.th.Context, user.Id) s.Require().Nil(appErr) s.Require().Equal(user.Email, unchangedUser.Email) }) @@ -1722,7 +1722,7 @@ func (s *MmctlE2ETestSuite) TestUserEditEmailCmd() { s.Require().Len(printer.GetErrorLines(), 0) // Verify email was not changed - unchangedUser, appErr := s.th.App.GetUser(user.Id) + unchangedUser, appErr := s.th.App.GetUser(s.th.Context, user.Id) s.Require().Nil(appErr) s.Require().Equal(user.Email, unchangedUser.Email) }) @@ -1763,7 +1763,7 @@ func (s *MmctlE2ETestSuite) TestUserEditAuthdataCmd() { s.Require().Len(printer.GetErrorLines(), 0) // Verify authdata was not changed - unchangedUser, appErr := s.th.App.GetUser(user.Id) + unchangedUser, appErr := s.th.App.GetUser(s.th.Context, user.Id) s.Require().Nil(appErr) s.Require().NotNil(unchangedUser.AuthData) s.Require().Equal("existingauthdata", *unchangedUser.AuthData) @@ -1779,7 +1779,7 @@ func (s *MmctlE2ETestSuite) TestUserEditAuthdataCmd() { s.Require().Len(printer.GetErrorLines(), 0) // Verify authdata was not changed - unchangedUser, appErr := s.th.App.GetUser(user.Id) + unchangedUser, appErr := s.th.App.GetUser(s.th.Context, user.Id) s.Require().Nil(appErr) s.Require().NotNil(unchangedUser.AuthData) s.Require().Equal("existingauthdata", *unchangedUser.AuthData) @@ -1801,7 +1801,7 @@ func (s *MmctlE2ETestSuite) TestUserEditAuthdataCmd() { s.Require().Len(printer.GetErrorLines(), 0) // Verify authdata was not changed - unchangedUser, appErr := s.th.App.GetUser(user.Id) + unchangedUser, appErr := s.th.App.GetUser(s.th.Context, user.Id) s.Require().Nil(appErr) s.Require().NotNil(unchangedUser.AuthData) s.Require().Equal("existingauthdata", *unchangedUser.AuthData) @@ -1827,7 +1827,7 @@ func (s *MmctlE2ETestSuite) TestUserEditAuthdataCmd() { s.Require().Len(printer.GetErrorLines(), 0) // Verify authdata was updated - updatedUser, appErr := s.th.App.GetUser(user.Id) + updatedUser, appErr := s.th.App.GetUser(s.th.Context, user.Id) s.Require().Nil(appErr) s.Require().NotNil(updatedUser.AuthData) s.Require().Equal(newAuthdata, *updatedUser.AuthData) From 3bf2d07fd016456f9a51c2318ae58b9f084751d3 Mon Sep 17 00:00:00 2001 From: Eva Sarafianou Date: Mon, 31 Aug 2026 11:26:35 +0300 Subject: [PATCH 2/5] feat(docs): boost documentation results in Algolia search ranking (#38202) Algolia treats all three Docusaurus instances equally, so admin/end-user pages compete with developer and API reference pages. Add optionalFilters on docusaurus_tag so documentation ranks above developers, which ranks above API reference, without excluding any instance from results. Co-authored-by: Cursor --- docs/site/docusaurus.config.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/site/docusaurus.config.ts b/docs/site/docusaurus.config.ts index 9eeafac55af2..c539e0afd81b 100644 --- a/docs/site/docusaurus.config.ts +++ b/docs/site/docusaurus.config.ts @@ -27,6 +27,13 @@ const algoliaThemeConfig = indexName: 'mattermost-docs', contextualSearch: true, searchPagePath: 'search', + searchParameters: { + optionalFilters: [ + 'docusaurus_tag:docs-documentation-current', + 'docusaurus_tag:docs-developers-current', + 'docusaurus_tag:docs-api-current', + ], + }, }, } : {}; From 4ce99ddd38636bb6916613efc892606db3fe75f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20V=C3=A9lez?= Date: Mon, 31 Aug 2026 11:12:40 +0200 Subject: [PATCH 3/5] Mm 70167 channel attribute assignment (#37933) * [MM-70167] Assign channel attributes at channel creation * Refactor channel attribute assignment tests and improve error handling in channel creation * Improve readability of purgeAttributes function by formatting filter condition * Address review feedback on channel attribute assignment * Add CLASSIFICATIONS_GROUP_NAME constant for access control in classification markings --- .../channel_attribute_assignment.spec.ts | 194 +++++++++++++++++ .../channels/channel_attributes/helpers.ts | 86 ++++++++ .../actions/property_values_websocket.test.ts | 139 ++++++++++++ .../channels/src/actions/websocket_actions.ts | 60 ++++-- .../classification_markings.test.tsx | 27 ++- .../classification_markings.tsx | 2 +- .../classification_markings/utils/index.ts | 31 ++- .../channel_attributes_form.scss | 55 +++++ .../channel_attributes_form.test.tsx | 131 ++++++++++++ .../channel_attributes_form.tsx | 157 ++++++++++++++ .../channel_settings_configuration_tab.tsx | 10 +- .../common/hooks/useChannelAttributes.test.ts | 108 ++++++++++ .../common/hooks/useChannelAttributes.ts | 93 ++++++++ .../useChannelClassificationBanner.test.ts | 10 +- .../hooks/useChannelClassificationBanner.ts | 8 +- .../common/hooks/useChannelLabels.test.tsx | 114 ++++++++++ .../common/hooks/useChannelLabels.ts | 51 +++++ .../hooks/useClassificationMarkings.test.ts | 19 +- .../common/hooks/useClassificationMarkings.ts | 16 +- .../global_classification_banner.test.tsx | 10 +- .../global_classification_banner.tsx | 8 +- .../new_channel_modal.test.tsx | 124 +++++++++++ .../new_channel_modal/new_channel_modal.tsx | 119 +++++++++-- webapp/channels/src/i18n/en.json | 6 + .../src/constants/properties.ts | 16 ++ .../src/selectors/entities/properties.test.ts | 200 ++++++++++++++++++ .../src/selectors/entities/properties.ts | 133 +++++++++++- 27 files changed, 1828 insertions(+), 99 deletions(-) create mode 100644 e2e-tests/playwright/specs/functional/channels/channel_attributes/channel_attribute_assignment.spec.ts create mode 100644 e2e-tests/playwright/specs/functional/channels/channel_attributes/helpers.ts create mode 100644 webapp/channels/src/actions/property_values_websocket.test.ts create mode 100644 webapp/channels/src/components/channel_attributes/channel_attributes_form.scss create mode 100644 webapp/channels/src/components/channel_attributes/channel_attributes_form.test.tsx create mode 100644 webapp/channels/src/components/channel_attributes/channel_attributes_form.tsx create mode 100644 webapp/channels/src/components/common/hooks/useChannelAttributes.test.ts create mode 100644 webapp/channels/src/components/common/hooks/useChannelAttributes.ts create mode 100644 webapp/channels/src/components/common/hooks/useChannelLabels.test.tsx create mode 100644 webapp/channels/src/components/common/hooks/useChannelLabels.ts create mode 100644 webapp/channels/src/packages/mattermost-redux/src/constants/properties.ts diff --git a/e2e-tests/playwright/specs/functional/channels/channel_attributes/channel_attribute_assignment.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_attributes/channel_attribute_assignment.spec.ts new file mode 100644 index 000000000000..e5b4baa4ee1d --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/channel_attributes/channel_attribute_assignment.spec.ts @@ -0,0 +1,194 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {PropertyField} from '@mattermost/types/properties'; + +import {expect, test} from '@mattermost/playwright-lib'; + +import { + attributeName, + createAttribute, + deleteAttributes, + optionId, + purgeAttributes, + readChannelValues, + valueFor, +} from './helpers'; + +test.describe('Channel attribute assignment', {tag: ['@channel_attributes']}, () => { + test.describe.configure({mode: 'serial'}); + + /** + * @objective Verify every supported attribute control can be assigned while creating a channel. + */ + test('assigns select, multiselect, and text attribute values at channel creation', async ({pw}) => { + await pw.skipIfNoLicense(); + + // The Properties route gate is evaluated when the API router is built, so + // the flag has to be in the server config before boot. + await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true); + + const {adminClient, user, team} = await pw.initSetup(); + const suffix = pw.random.id(); + const created: PropertyField[] = []; + + try { + await purgeAttributes(adminClient); + + const program = await createAttribute(adminClient, attributeName('program', suffix), { + options: ['AURORA', 'BOREALIS'], + }); + const caveats = await createAttribute(adminClient, attributeName('caveats', suffix), { + type: 'multiselect', + options: ['NOFORN', 'ORCON'], + }); + const note = await createAttribute(adminClient, attributeName('note', suffix), {type: 'text'}); + const userScoped = await createAttribute(adminClient, attributeName('clearance', suffix), { + objectType: 'user', + options: ['SECRET'], + }); + created.push(program, caveats, note, userScoped); + + const {page, channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name); + await channelsPage.toBeVisible(); + + const modal = await channelsPage.openNewChannelModal(); + + // Only the channel-scoped attributes belong here; a user attribute in + // the same group must not leak into channel creation. + await expect(page.getByTestId(`channelAttributeRow-${program.name}`)).toBeVisible(); + await expect(page.getByTestId(`channelAttributeRow-${caveats.name}`)).toBeVisible(); + await expect(page.getByTestId(`channelAttributeRow-${note.name}`)).toBeVisible(); + await expect(page.getByTestId(`channelAttributeRow-${userScoped.name}`)).toHaveCount(0); + + const displayName = `Attr Channel ${suffix}`; + await modal.fillDisplayName(displayName); + + await page.getByTestId(`channelAttribute-${program.name}`).click(); + await page.getByText('AURORA', {exact: true}).click(); + + // The menu closes on each pick, so a multiselect needs reopening to + // add the second value. Two values is what proves the array shape. + await page.getByTestId(`channelAttribute-${caveats.name}`).click(); + await page.getByText('NOFORN', {exact: true}).click(); + await page.getByTestId(`channelAttribute-${caveats.name}`).click(); + await page.getByText('ORCON', {exact: true}).click(); + + await page.getByLabel(note.name, {exact: true}).fill('handle with care'); + + await modal.create(); + + // The values are written after the channel exists and the modal only + // closes once that resolves, so this is the signal the write finished. + // The channel header is already visible from before, so waiting on it + // races the write. + await expect(modal.container).not.toBeVisible(); + + const channel = await adminClient.getChannelByName(team.id, displayName.toLowerCase().replace(/\s+/g, '-')); + const values = await readChannelValues(adminClient, channel.id); + + expect(valueFor(values, program)).toBe(optionId(program, 'AURORA')); + expect(valueFor(values, caveats)).toEqual([optionId(caveats, 'NOFORN'), optionId(caveats, 'ORCON')]); + expect(valueFor(values, note)).toBe('handle with care'); + } finally { + await deleteAttributes(adminClient, created); + } + }); + + /** + * @objective Verify attribute assignment works for a private channel, not only a public one. + */ + test('assigns attribute values when creating a private channel', async ({pw}) => { + await pw.skipIfNoLicense(); + await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true); + + const {adminClient, user, team} = await pw.initSetup(); + const suffix = pw.random.id(); + const created: PropertyField[] = []; + + try { + await purgeAttributes(adminClient); + + const program = await createAttribute(adminClient, attributeName('private_program', suffix), { + options: ['AURORA', 'BOREALIS'], + }); + created.push(program); + + const {page, channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name); + await channelsPage.toBeVisible(); + + const modal = await channelsPage.openNewChannelModal(); + + const displayName = `Attr Private ${suffix}`; + await modal.fillDisplayName(displayName); + await modal.privateTypeButton.click(); + + await page.getByTestId(`channelAttribute-${program.name}`).click(); + await page.getByText('BOREALIS', {exact: true}).click(); + + await modal.create(); + await expect(modal.container).not.toBeVisible(); + + const channel = await adminClient.getChannelByName(team.id, displayName.toLowerCase().replace(/\s+/g, '-')); + expect(channel.type).toBe('P'); + + const values = await readChannelValues(adminClient, channel.id); + expect(valueFor(values, program)).toBe(optionId(program, 'BOREALIS')); + } finally { + await deleteAttributes(adminClient, created); + } + }); + + /** + * @objective Verify a failed value write is surfaced rather than swallowed. + */ + test('surfaces a value write failure and keeps the channel', async ({pw}) => { + await pw.skipIfNoLicense(); + await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true); + + const {adminClient, user, team} = await pw.initSetup(); + const suffix = pw.random.id(); + const created: PropertyField[] = []; + + try { + await purgeAttributes(adminClient); + + const program = await createAttribute(adminClient, attributeName('failing', suffix), { + options: ['AURORA'], + }); + created.push(program); + + const {page, channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name); + await channelsPage.toBeVisible(); + + // Fail only the value write. Channel creation must still succeed, so + // this proves the error is reported rather than the channel rolled back. + await page.route('**/api/v4/properties/groups/access_control/channel/values/**', async (route) => { + if (route.request().method() === 'PATCH') { + await route.fulfill({status: 500, body: '{"message":"forced failure"}'}); + return; + } + await route.continue(); + }); + + const modal = await channelsPage.openNewChannelModal(); + + const displayName = `Attr Fail ${suffix}`; + await modal.fillDisplayName(displayName); + await page.getByTestId(`channelAttribute-${program.name}`).click(); + await page.getByText('AURORA', {exact: true}).click(); + await modal.create(); + + // Assert the save-failure banner, not the attribute label already visible in the form. + await expect(page.getByText(/these attributes were not saved/i)).toBeVisible(); + + const channel = await adminClient.getChannelByName(team.id, displayName.toLowerCase().replace(/\s+/g, '-')); + expect(channel.delete_at).toBe(0); + } finally { + await deleteAttributes(adminClient, created); + } + }); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/channel_attributes/helpers.ts b/e2e-tests/playwright/specs/functional/channels/channel_attributes/helpers.ts new file mode 100644 index 000000000000..fe937589054f --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/channel_attributes/helpers.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Client4} from '@mattermost/client'; +import type {PropertyField, PropertyValue} from '@mattermost/types/properties'; + +// Channel attributes share the access_control group with Classification Markings. +export const GROUP = 'access_control'; +export const TARGET_TYPE = 'system'; + +// Prefix on every field these specs create, so cleanup never touches real ones. +export const FIELD_PREFIX = 'chanattr_e2e'; + +type CreateOptions = { + objectType?: 'channel' | 'user'; + type?: 'select' | 'multiselect' | 'text'; + options?: string[]; +}; + +export function attributeName(suffix: string, uniqueId: string): string { + return `${FIELD_PREFIX}_${suffix}_${uniqueId}`; +} + +export async function createAttribute( + adminClient: Client4, + name: string, + {objectType = 'channel', type, options = []}: CreateOptions = {}, +): Promise { + const field: Record = { + name, + type: type ?? (options.length ? 'select' : 'text'), + target_type: TARGET_TYPE, + target_id: '', + permission_field: 'admin', + permission_values: 'member', + permission_options: 'admin', + }; + + if (options.length) { + field.attrs = {options: options.map((optionName) => ({id: '', name: optionName}))}; + } + + return adminClient.createPropertyField(GROUP, objectType, field as Parameters[2]); +} + +export function optionId(field: PropertyField, name: string): string { + const options = (field.attrs?.options ?? []) as Array<{id: string; name: string}>; + const match = options.find((option) => option.name === name); + if (!match) { + throw new Error(`option ${name} not found on ${field.name}`); + } + return match.id; +} + +// Values are typed loosely because a multiselect stores an array where a select +// stores a single option id. The endpoint answers a bare null, not an empty +// array, when a channel has no values at all. +export async function readChannelValues(client: Client4, channelId: string): Promise>> { + const values = await client.getPropertyValues(GROUP, 'channel', channelId); + return values ?? []; +} + +export function valueFor(values: Array>, field: PropertyField): unknown { + return values.find((value) => value.field_id === field.id)?.value; +} + +export async function deleteAttributes(adminClient: Client4, fields: PropertyField[]): Promise { + for (const field of fields) { + try { + await adminClient.deletePropertyField(GROUP, field.object_type, field.id); + } catch {} // eslint-disable-line no-empty + } +} + +// Best-effort cleanup of fields left behind by an interrupted run. +export async function purgeAttributes(adminClient: Client4): Promise { + for (const objectType of ['channel', 'user'] as const) { + try { + const fields = await adminClient.getPropertyFields(GROUP, objectType, TARGET_TYPE); + const stale = (fields ?? []).filter( + (field) => field.name.startsWith(FIELD_PREFIX) && field.delete_at === 0, + ); + await deleteAttributes(adminClient, stale); + } catch {} // eslint-disable-line no-empty + } +} diff --git a/webapp/channels/src/actions/property_values_websocket.test.ts b/webapp/channels/src/actions/property_values_websocket.test.ts new file mode 100644 index 000000000000..52b4e0e6912b --- /dev/null +++ b/webapp/channels/src/actions/property_values_websocket.test.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {PropertyValue} from '@mattermost/types/properties'; + +import {PropertyTypes} from 'mattermost-redux/action_types'; + +import {handlePropertyValuesUpdated} from 'actions/websocket_actions'; + +import mockStore from 'tests/test_store'; + +// The server emits four payloads under one property_values_updated event, +// distinguished only by which keys are present. See the Go-side contract in +// TestPropertyValuesUpdatedPayloadShapes; these assert the client half. + +const CHANNEL_ID = 'channel_id_1'; +const FIELD_ID = 'field_id_1'; +const GROUP_ID = 'group_id_1'; + +function storedValue(overrides: Partial> = {}): PropertyValue { + return { + id: 'value_id_1', + target_id: CHANNEL_ID, + target_type: 'channel', + group_id: GROUP_ID, + field_id: FIELD_ID, + value: 'AURORA', + create_at: 1, + update_at: 1, + delete_at: 0, + created_by: '', + updated_by: '', + ...overrides, + }; +} + +function dispatchEvent(data: Record) { + const store = mockStore({ + entities: { + general: {config: {}, license: {}}, + properties: { + fields: {byId: {}, byObjectType: {}}, + values: {byTargetId: {}, byFieldId: {}}, + groups: {byId: {}, byName: {}}, + }, + channelCategories: {byId: {}, orderByTeam: {}}, + }, + }); + + const msg = { + event: 'property_values_updated', + data, + broadcast: {omit_users: null, user_id: '', channel_id: CHANNEL_ID, team_id: ''}, + seq: 1, + }; + + store.dispatch(handlePropertyValuesUpdated(msg as any) as any); + return store.getActions().flatMap((action: any) => (Array.isArray(action) ? action : [action])); +} + +function typesOf(actions: any[]): string[] { + return actions.map((action) => action.type); +} + +describe('property_values_updated payload shapes', () => { + test('shape 1: upsert stores the values', () => { + const actions = dispatchEvent({ + object_type: 'channel', + target_id: CHANNEL_ID, + values: JSON.stringify([storedValue()]), + }); + + expect(typesOf(actions)).toContain(PropertyTypes.RECEIVED_PROPERTY_VALUES); + const received = actions.find((a) => a.type === PropertyTypes.RECEIVED_PROPERTY_VALUES); + expect(received.data.values[0].value).toBe('AURORA'); + }); + + test('shape 1b: a null-valued row is stored, not deleted', () => { + // The user-initiated clear keeps the row. No delete event ever arrives, + // so removing it here would leave the client waiting for one. + const actions = dispatchEvent({ + object_type: 'channel', + target_id: CHANNEL_ID, + values: JSON.stringify([storedValue({value: null})]), + }); + + expect(typesOf(actions)).toContain(PropertyTypes.RECEIVED_PROPERTY_VALUES); + expect(typesOf(actions)).not.toContain(PropertyTypes.PROPERTY_VALUE_DELETED); + }); + + test('shape 2: a tombstone deletes the row rather than storing a blank one', () => { + // Identical to shape 1b on the wire apart from the empty id. + const actions = dispatchEvent({ + object_type: 'channel', + target_id: CHANNEL_ID, + values: JSON.stringify([storedValue({id: '', value: null})]), + }); + + expect(typesOf(actions)).toContain(PropertyTypes.PROPERTY_VALUE_DELETED); + expect(typesOf(actions)).not.toContain(PropertyTypes.RECEIVED_PROPERTY_VALUES); + + const deleted = actions.find((a) => a.type === PropertyTypes.PROPERTY_VALUE_DELETED); + expect(deleted.data).toEqual({targetId: CHANNEL_ID, fieldId: FIELD_ID}); + }); + + test('shape 3: an empty array clears only that target', () => { + const actions = dispatchEvent({ + object_type: 'channel', + target_id: CHANNEL_ID, + values: '[]', + }); + + expect(typesOf(actions)).toContain(PropertyTypes.PROPERTY_VALUES_DELETED_FOR_TARGET); + const cleared = actions.find((a) => a.type === PropertyTypes.PROPERTY_VALUES_DELETED_FOR_TARGET); + expect(cleared.data).toEqual({targetId: CHANNEL_ID}); + }); + + test('shape 4: field_id with no target clears that field everywhere', () => { + const actions = dispatchEvent({ + field_id: FIELD_ID, + values: '[]', + }); + + expect(typesOf(actions)).toContain(PropertyTypes.PROPERTY_VALUES_DELETED_FOR_FIELD); + const cleared = actions.find((a) => a.type === PropertyTypes.PROPERTY_VALUES_DELETED_FOR_FIELD); + expect(cleared.data).toEqual({fieldId: FIELD_ID}); + + // Must not be mistaken for a target-scoped clear, which would wipe an + // unrelated channel instead of one attribute. + expect(typesOf(actions)).not.toContain(PropertyTypes.PROPERTY_VALUES_DELETED_FOR_TARGET); + }); + + test('malformed JSON is ignored', () => { + const actions = dispatchEvent({object_type: 'channel', target_id: CHANNEL_ID, values: 'not json'}); + + expect(typesOf(actions)).not.toContain(PropertyTypes.RECEIVED_PROPERTY_VALUES); + expect(typesOf(actions)).not.toContain(PropertyTypes.PROPERTY_VALUE_DELETED); + }); +}); diff --git a/webapp/channels/src/actions/websocket_actions.ts b/webapp/channels/src/actions/websocket_actions.ts index b0c7a571214d..f3f0e87985c2 100644 --- a/webapp/channels/src/actions/websocket_actions.ts +++ b/webapp/channels/src/actions/websocket_actions.ts @@ -19,6 +19,7 @@ import type {OpenDialogRequest} from '@mattermost/types/integrations'; import type {Job} from '@mattermost/types/jobs'; import type {Post, PostAcknowledgement} from '@mattermost/types/posts'; import type {PreferenceType} from '@mattermost/types/preferences'; +import type {PropertyValue} from '@mattermost/types/properties'; import {SESSION_ATTRIBUTES_OBJECT_TYPE} from '@mattermost/types/properties_user'; import type {Reaction} from '@mattermost/types/reactions'; import type {Role} from '@mattermost/types/roles'; @@ -107,6 +108,7 @@ import { import {removeNotVisibleUsers} from 'mattermost-redux/actions/websocket'; import {Client4} from 'mattermost-redux/client'; import {General, Permissions} from 'mattermost-redux/constants'; +import {ACCESS_CONTROL_PROPERTY_GROUP} from 'mattermost-redux/constants/properties'; import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; import { getChannel, @@ -161,7 +163,6 @@ import {isThreadOpen, isThreadManuallyUnread} from 'selectors/views/threads'; import store from 'stores/redux_store'; import { - CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, CLASSIFICATIONS_FIELD_TARGET_TYPE, @@ -353,7 +354,7 @@ export function reconnect() { if (getFeatureFlagValue(state, 'ClassificationMarkings') === 'true') { dispatch( fetchPropertyFields( - CLASSIFICATIONS_GROUP_NAME, + ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, CLASSIFICATIONS_FIELD_TARGET_TYPE, CLASSIFICATIONS_FIELD_TARGET_ID, @@ -361,13 +362,13 @@ export function reconnect() { ); dispatch( fetchPropertyFields( - CLASSIFICATIONS_GROUP_NAME, + ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, CLASSIFICATIONS_FIELD_TARGET_TYPE, CLASSIFICATIONS_FIELD_TARGET_ID, ), ); - dispatch(fetchSystemPropertyValues(CLASSIFICATIONS_GROUP_NAME)); + dispatch(fetchSystemPropertyValues(ACCESS_CONTROL_PROPERTY_GROUP)); } if (state.websocket.lastDisconnectAt) { @@ -1503,7 +1504,20 @@ function handlePropertyFieldDeleted( }; } -function handlePropertyValuesUpdated(msg: WebSocketMessages.PropertyValuesUpdated): ThunkActionFunc { +// The server emits four distinct payloads under one event, distinguished only by +// which keys are present (see TestPropertyValuesUpdatedPayloadShapes): +// +// upsert object_type + target_id + the target's full values array +// single delete same keys, but one synthesized row with an empty id +// delete for target same keys, values "[]" +// delete for field field_id, no object_type/target_id, values "[]" +// +// A delete tombstone carries no value, and a nil RawMessage marshals to null, so +// it is byte-identical to a user-initiated `PATCH value: null` apart from the +// empty id. Treating either of the empty-array shapes as an upsert is a no-op, +// and treating a tombstone as one leaves a blank row in place of the deleted +// value — so each shape has to be routed to its own reducer action. +export function handlePropertyValuesUpdated(msg: WebSocketMessages.PropertyValuesUpdated): ThunkActionFunc { return (doDispatch) => { let values; try { @@ -1520,13 +1534,35 @@ function handlePropertyValuesUpdated(msg: WebSocketMessages.PropertyValuesUpdate values, }; - // Populate the Redux property values store so any component that reads - // from entities.properties.values (e.g. GlobalClassificationBanner) gets - // real-time updates without an extra network round-trip. - doDispatch({ - type: PropertyTypes.RECEIVED_PROPERTY_VALUES, - data: {values}, - }); + const {object_type: objectType, target_id: targetId, field_id: fieldId} = msg.data; + const isTargetScoped = objectType !== undefined && targetId !== undefined; + + if (!isTargetScoped && fieldId) { + doDispatch({ + type: PropertyTypes.PROPERTY_VALUES_DELETED_FOR_FIELD, + data: {fieldId}, + }); + } else if (isTargetScoped && values.length === 0) { + doDispatch({ + type: PropertyTypes.PROPERTY_VALUES_DELETED_FOR_TARGET, + data: {targetId}, + }); + } else if (isTargetScoped && values.every((value: PropertyValue) => !value.id)) { + for (const value of values) { + doDispatch({ + type: PropertyTypes.PROPERTY_VALUE_DELETED, + data: {targetId: value.target_id ?? targetId, fieldId: value.field_id}, + }); + } + } else { + // Populate the Redux property values store so any component that reads + // from entities.properties.values (e.g. GlobalClassificationBanner) gets + // real-time updates without an extra network round-trip. + doDispatch({ + type: PropertyTypes.RECEIVED_PROPERTY_VALUES, + data: {values}, + }); + } doDispatch(handleManagedCategoryPropertyValuesUpdated(parsedPropertyValuesUpdated)); }; diff --git a/webapp/channels/src/components/admin_console/classification_markings/classification_markings.test.tsx b/webapp/channels/src/components/admin_console/classification_markings/classification_markings.test.tsx index cdbd8edd57d2..3581a8e6e46b 100644 --- a/webapp/channels/src/components/admin_console/classification_markings/classification_markings.test.tsx +++ b/webapp/channels/src/components/admin_console/classification_markings/classification_markings.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; import type {PropertyField, PropertyFieldOption, PropertyValue} from '@mattermost/types/properties'; import {Client4} from 'mattermost-redux/client'; +import {ACCESS_CONTROL_PROPERTY_GROUP, DISPLAY_BANNER_BOTTOM, DISPLAY_BANNER_TOP} from 'mattermost-redux/constants/properties'; import {act, renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils'; @@ -22,17 +23,15 @@ import { CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, CLASSIFICATIONS_FIELD_TARGET_ID, CLASSIFICATIONS_FIELD_TARGET_TYPE, - CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_SYSTEM_FIELD_NAME, CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, CLASSIFICATIONS_SYSTEM_VALUE_TARGET_ID, CLASSIFICATIONS_TEMPLATE_FIELD_NAME, CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, + CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_USER_OBJECT_TYPE, CLEARANCE_FIELD_DISPLAY_NAME, CLEARANCE_FIELD_NAME, - DISPLAY_BANNER_BOTTOM, - DISPLAY_BANNER_TOP, } from './utils'; import type {ClassificationLevel} from './utils/presets'; import {PRESET_CUSTOM, presets} from './utils/presets'; @@ -50,7 +49,7 @@ jest.mock('utils/browser_history', () => ({ function makePropertyField(overrides: Partial = {}): PropertyField { return { id: 'field1', - group_id: CLASSIFICATIONS_GROUP_NAME, + group_id: ACCESS_CONTROL_PROPERTY_GROUP, name: CLASSIFICATIONS_TEMPLATE_FIELD_NAME, type: 'rank', attrs: {options: []}, @@ -69,7 +68,7 @@ function makePropertyField(overrides: Partial = {}): PropertyFiel function makeLinkedField(overrides: Partial = {}): PropertyField { return { id: 'linked_field1', - group_id: CLASSIFICATIONS_GROUP_NAME, + group_id: ACCESS_CONTROL_PROPERTY_GROUP, name: CLASSIFICATIONS_SYSTEM_FIELD_NAME, type: 'rank', attrs: {actions: []}, @@ -89,7 +88,7 @@ function makeLinkedField(overrides: Partial = {}): PropertyField function makeChannelLinkedField(overrides: Partial = {}): PropertyField { return { id: 'channel_field1', - group_id: CLASSIFICATIONS_GROUP_NAME, + group_id: ACCESS_CONTROL_PROPERTY_GROUP, name: CLASSIFICATIONS_CHANNEL_FIELD_NAME, type: 'rank', attrs: {}, @@ -140,7 +139,7 @@ function makeSystemValue(fieldId: string, optionId: string): PropertyValue { expect(result).toEqual(expected); expect(Client4.getPropertyFields).toHaveBeenCalledTimes(1); expect(Client4.getPropertyFields).toHaveBeenCalledWith( - CLASSIFICATIONS_GROUP_NAME, + ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, CLASSIFICATIONS_FIELD_TARGET_TYPE, '', @@ -792,7 +791,7 @@ describe('ClassificationMarkings component', () => { await waitFor(() => { expect(Client4.patchPropertyField).toHaveBeenCalledWith( - CLASSIFICATIONS_GROUP_NAME, + ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, 'field1', expect.objectContaining({ @@ -1044,7 +1043,7 @@ describe('GlobalClassificationIndicators section', () => { await waitFor(() => { // Template field patched without global_banner in attrs. expect(Client4.patchPropertyField).toHaveBeenCalledWith( - CLASSIFICATIONS_GROUP_NAME, + ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, 'field1', expect.objectContaining({ @@ -1062,7 +1061,7 @@ describe('GlobalClassificationIndicators section', () => { // Linked field patched with updated actions (top_and_bottom). expect(Client4.patchPropertyField).toHaveBeenCalledWith( - CLASSIFICATIONS_GROUP_NAME, + ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, 'linked_field1', expect.objectContaining({ @@ -1113,7 +1112,7 @@ describe('GlobalClassificationIndicators section', () => { await waitFor(() => { // Template field saved without global_banner. expect(Client4.patchPropertyField).toHaveBeenCalledWith( - CLASSIFICATIONS_GROUP_NAME, + ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, 'field1', expect.not.objectContaining({ @@ -1123,7 +1122,7 @@ describe('GlobalClassificationIndicators section', () => { // Linked field patched with empty actions (banner disabled). expect(Client4.patchPropertyField).toHaveBeenCalledWith( - CLASSIFICATIONS_GROUP_NAME, + ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, 'linked_field1', expect.objectContaining({ @@ -1232,7 +1231,7 @@ describe('Channel classification linked field branches', () => { await waitFor(() => { expect(createSpy).toHaveBeenCalledWith( - CLASSIFICATIONS_GROUP_NAME, + ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, expect.objectContaining({ name: CLASSIFICATIONS_CHANNEL_FIELD_NAME, diff --git a/webapp/channels/src/components/admin_console/classification_markings/classification_markings.tsx b/webapp/channels/src/components/admin_console/classification_markings/classification_markings.tsx index 559c36ba6c77..2ff303d6fde8 100644 --- a/webapp/channels/src/components/admin_console/classification_markings/classification_markings.tsx +++ b/webapp/channels/src/components/admin_console/classification_markings/classification_markings.tsx @@ -10,6 +10,7 @@ import {PlusIcon} from '@mattermost/compass-icons/components'; import type {PropertyField} from '@mattermost/types/properties'; import PropertyTypes from 'mattermost-redux/action_types/properties'; +import {DISPLAY_BANNER_TOP} from 'mattermost-redux/constants/properties'; import {getAccessControlSettings} from 'mattermost-redux/selectors/entities/access_control'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; @@ -40,7 +41,6 @@ import { CLEARANCE_FIELD_DISPLAY_NAME, CLEARANCE_FIELD_NAME, DEFAULT_GLOBAL_BANNER, - DISPLAY_BANNER_TOP, actionsToGlobalBanner, fetchChannelClassificationField, fetchClassificationField, diff --git a/webapp/channels/src/components/admin_console/classification_markings/utils/index.ts b/webapp/channels/src/components/admin_console/classification_markings/utils/index.ts index 9ba60ab65036..5488a6a58445 100644 --- a/webapp/channels/src/components/admin_console/classification_markings/utils/index.ts +++ b/webapp/channels/src/components/admin_console/classification_markings/utils/index.ts @@ -4,6 +4,7 @@ import type {PropertyField, PropertyFieldOption, PropertyValue} from '@mattermost/types/properties'; import {Client4} from 'mattermost-redux/client'; +import {ACCESS_CONTROL_PROPERTY_GROUP, DISPLAY_BANNER_BOTTOM, DISPLAY_BANNER_TOP} from 'mattermost-redux/constants/properties'; import type {ClassificationLevel} from './presets'; import {PRESET_CUSTOM, presets} from './presets'; @@ -53,10 +54,6 @@ export const CLASSIFICATIONS_SYSTEM_VALUE_TARGET_ID = 'system'; export const CLASSIFICATIONS_CHANNEL_OBJECT_TYPE = 'channel'; export const CLASSIFICATIONS_CHANNEL_FIELD_NAME = 'classification'; -// Actions stored on the linked fields' attrs.actions to control banner placement. -export const DISPLAY_BANNER_TOP = 'display_banner_top'; -export const DISPLAY_BANNER_BOTTOM = 'display_banner_bottom'; - export type GlobalBannerPlacement = 'top' | 'top_and_bottom'; export type GlobalBannerConfig = { @@ -166,7 +163,7 @@ export async function fetchClassificationField(): Promise { const options = levelsToOptions(levels); - return Client4.createPropertyField(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, { + return Client4.createPropertyField(ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, { name: CLASSIFICATIONS_TEMPLATE_FIELD_NAME, type: 'rank' as PropertyField['type'], target_type: CLASSIFICATIONS_FIELD_TARGET_TYPE, @@ -201,12 +198,12 @@ export async function saveCreateField(levels: ClassificationLevel[]): Promise { - await Client4.deletePropertyField(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, fieldId); + await Client4.deletePropertyField(ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, fieldId); } export async function savePatchField(fieldId: string, levels: ClassificationLevel[]): Promise { const options = levelsToOptions(levels); - return Client4.patchPropertyField(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, fieldId, { + return Client4.patchPropertyField(ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, fieldId, { attrs: {options}, } as Partial); } @@ -221,7 +218,7 @@ export async function fetchLinkedClassificationField(): Promise { - return Client4.createPropertyField(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, { + return Client4.createPropertyField(ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, { name: CLASSIFICATIONS_SYSTEM_FIELD_NAME, type: 'rank' as PropertyField['type'], target_type: CLASSIFICATIONS_FIELD_TARGET_TYPE, @@ -258,7 +255,7 @@ export async function saveCreateLinkedField(templateFieldId: string, config: Glo } export async function savePatchLinkedField(linkedFieldId: string, config: GlobalBannerConfig): Promise { - return Client4.patchPropertyField(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, linkedFieldId, { + return Client4.patchPropertyField(ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, linkedFieldId, { attrs: { actions: placementToActions(config), }, @@ -266,7 +263,7 @@ export async function savePatchLinkedField(linkedFieldId: string, config: Global } export async function saveDeleteLinkedField(fieldId: string): Promise { - await Client4.deletePropertyField(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, fieldId); + await Client4.deletePropertyField(ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, fieldId); } // --- System classification property value API --- @@ -276,7 +273,7 @@ export async function saveDeleteLinkedField(fieldId: string): Promise { * Uses the dedicated system values endpoint (no target_id in URL). */ export async function fetchSystemClassificationValue(linkedFieldId: string): Promise { - const values = await Client4.getSystemPropertyValues(CLASSIFICATIONS_GROUP_NAME); + const values = await Client4.getSystemPropertyValues(ACCESS_CONTROL_PROPERTY_GROUP); const match = ((values as Array>) ?? []).find((v) => v.field_id === linkedFieldId); return match?.value; } @@ -287,7 +284,7 @@ export async function fetchSystemClassificationValue(linkedFieldId: string): Pro * Returns the saved property values so callers can eagerly update the store. */ export async function saveUpsertSystemValue(linkedFieldId: string, optionId: string): Promise>> { - return Client4.patchSystemPropertyValues(CLASSIFICATIONS_GROUP_NAME, [ + return Client4.patchSystemPropertyValues(ACCESS_CONTROL_PROPERTY_GROUP, [ {field_id: linkedFieldId, value: optionId}, ]); } @@ -302,7 +299,7 @@ export async function fetchChannelClassificationField(): Promise { - return Client4.createPropertyField(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, { + return Client4.createPropertyField(ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, { name: CLASSIFICATIONS_CHANNEL_FIELD_NAME, type: 'rank' as PropertyField['type'], target_type: CLASSIFICATIONS_FIELD_TARGET_TYPE, @@ -336,7 +333,7 @@ export async function saveCreateChannelLinkedField(templateFieldId: string): Pro } export async function saveDeleteChannelLinkedField(fieldId: string): Promise { - await Client4.deletePropertyField(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, fieldId); + await Client4.deletePropertyField(ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, fieldId); } // --- User field API (clearance attribute for classification enforcement) --- diff --git a/webapp/channels/src/components/channel_attributes/channel_attributes_form.scss b/webapp/channels/src/components/channel_attributes/channel_attributes_form.scss new file mode 100644 index 000000000000..7a4d0b6f6be5 --- /dev/null +++ b/webapp/channels/src/components/channel_attributes/channel_attributes_form.scss @@ -0,0 +1,55 @@ +.channel-attributes-form { + padding-top: 24px; + border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + + &__title { + margin: 0 0 4px; + color: var(--center-channel-color); + font-family: Metropolis, sans-serif; + font-size: 16px; + font-weight: 600; + line-height: 24px; + } + + &__description { + margin: 0 0 16px; + color: rgba(var(--center-channel-color-rgb), 0.75); + font-size: 12px; + line-height: 16px; + } + + &__row { + display: flex; + align-items: center; + margin-bottom: 16px; + gap: 16px; + + &:last-child { + margin-bottom: 0; + } + } + + // Matches the classification rows above: a long attribute name wraps rather + // than being clipped, since the name is the only thing identifying the row. + &__label { + width: 140px; + flex-shrink: 0; + margin: 0; + color: var(--center-channel-color); + font-size: 14px; + font-weight: 600; + line-height: 20px; + overflow-wrap: anywhere; + } + + &__control { + min-width: 0; + flex: 1 1 auto; + + // Bootstrap's .form-control carries its own border, which paints a second + // box inside the fieldset's. Same override the purpose textarea needs. + .Input_wrapper input.form-control { + border: none; + } + } +} diff --git a/webapp/channels/src/components/channel_attributes/channel_attributes_form.test.tsx b/webapp/channels/src/components/channel_attributes/channel_attributes_form.test.tsx new file mode 100644 index 000000000000..6c23c21f2846 --- /dev/null +++ b/webapp/channels/src/components/channel_attributes/channel_attributes_form.test.tsx @@ -0,0 +1,131 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import type {PropertyField} from '@mattermost/types/properties'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import ChannelAttributesForm from './channel_attributes_form'; + +function field(overrides: Partial & {id: string; name: string}): PropertyField { + return { + group_id: 'group1', + type: 'select', + target_id: '', + target_type: 'system', + object_type: 'channel', + create_at: 1, + update_at: 1, + delete_at: 0, + created_by: '', + updated_by: '', + ...overrides, + }; +} + +const program = field({ + id: 'f_program', + name: 'program', + attrs: {display_name: 'Program', options: [{id: 'opt_a', name: 'AURORA'}, {id: 'opt_b', name: 'BOREALIS'}]}, +}); + +describe('ChannelAttributesForm', () => { + test('renders nothing when there is no field with a control', () => { + const {container} = renderWithContext( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + test('renders nothing at all when there are no fields', () => { + const {container} = renderWithContext( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + test('prefers display_name over the machine name', () => { + renderWithContext( + , + ); + + expect(screen.getByText('Program')).toBeInTheDocument(); + expect(screen.queryByText('program')).not.toBeInTheDocument(); + }); + + test('reports the selected option id, not its label', async () => { + const onChange = jest.fn(); + renderWithContext( + , + ); + + await userEvent.click(screen.getByText('Select a value')); + await userEvent.click(screen.getByText('AURORA')); + + expect(onChange).toHaveBeenCalledWith('f_program', 'opt_a'); + }); + + test('reports a multiselect as an array of option ids', async () => { + const onChange = jest.fn(); + const caveats = field({ + id: 'f_caveats', + name: 'caveats', + type: 'multiselect', + attrs: {options: [{id: 'opt_a', name: 'NOFORN'}, {id: 'opt_b', name: 'ORCON'}]}, + }); + + renderWithContext( + , + ); + + // The placeholder is gone once a chip is present, so the menu is opened + // through the combobox itself. + await userEvent.click(screen.getByRole('combobox')); + await userEvent.click(screen.getByText('ORCON')); + + expect(onChange).toHaveBeenCalledWith('f_caveats', ['opt_a', 'opt_b']); + }); + + test('reports undefined when a text value is emptied, so no row is written', async () => { + const onChange = jest.fn(); + const note = field({id: 'f_note', name: 'note', type: 'text'}); + + renderWithContext( + , + ); + + await userEvent.clear(screen.getByLabelText('note')); + + expect(onChange).toHaveBeenCalledWith('f_note', undefined); + }); +}); diff --git a/webapp/channels/src/components/channel_attributes/channel_attributes_form.tsx b/webapp/channels/src/components/channel_attributes/channel_attributes_form.tsx new file mode 100644 index 000000000000..5a33e451d9f5 --- /dev/null +++ b/webapp/channels/src/components/channel_attributes/channel_attributes_form.tsx @@ -0,0 +1,157 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import type {OnChangeValue} from 'react-select'; + +import type {PropertyField, PropertyFieldOption} from '@mattermost/types/properties'; +import {supportsOptions} from '@mattermost/types/properties'; + +import DropdownInput from 'components/dropdown_input'; +import type {ValueType as Option} from 'components/dropdown_input'; +import Input from 'components/widgets/inputs/input/input'; + +import './channel_attributes_form.scss'; + +export type ChannelAttributeSelection = Record; + +type Props = { + fields: PropertyField[]; + values: ChannelAttributeSelection; + onChange: (fieldId: string, value: string | string[] | undefined) => void; + disabled?: boolean; +}; + +// The menu is portalled to the body to escape the modal's overflow, which drops +// it out of the modal's stacking context — without these it paints behind the +// modal and swallows clicks. +const dropdownStyles = { + menu: (provided: Record) => ({...provided, zIndex: 100}), + menuPortal: (provided: Record) => ({...provided, zIndex: 1100}), +}; + +function isText(field: PropertyField): boolean { + return field.type === 'text'; +} + +function fieldLabel(field: PropertyField): string { + const displayName = field.attrs?.display_name; + return typeof displayName === 'string' && displayName ? displayName : field.name; +} + +function toOptions(field: PropertyField): Option[] { + const options = (field.attrs?.options as PropertyFieldOption[] | undefined) ?? []; + return options.map((option) => ({label: option.name, value: option.id})); +} + +const ChannelAttributesForm = ({fields, values, onChange, disabled}: Props) => { + const {formatMessage} = useIntl(); + + // Date and user-valued attributes are storable through the API but have no + // assignment UI in this release, so they are skipped rather than rendered as + // something the user cannot fill in. + const supported = useMemo(() => fields.filter((field) => supportsOptions(field) || isText(field)), [fields]); + + // react-select hands back an array for isMulti and a single option otherwise, + // so the shape is narrowed here rather than trusted from the field type. + const handleSelect = useCallback((fieldId: string, selected: OnChangeValue) => { + if (Array.isArray(selected)) { + const ids = selected.map((option) => option.value); + onChange(fieldId, ids.length ? ids : undefined); + return; + } + onChange(fieldId, (selected as Option | null)?.value || undefined); + }, [onChange]); + + const handleText = useCallback((fieldId: string, next: string) => { + onChange(fieldId, next || undefined); + }, [onChange]); + + if (supported.length === 0) { + return null; + } + + const selectPlaceholder = formatMessage({id: 'channel_attributes.select_value', defaultMessage: 'Select a value'}); + const textPlaceholder = formatMessage({id: 'channel_attributes.enter_value', defaultMessage: 'Enter a value'}); + + return ( +
+

+ +

+

+ +

+ {supported.map((field) => { + const label = fieldLabel(field); + const selected = values[field.id]; + + return ( +
+ + {label} + +
+ {isText(field) ? ( + handleText(field.id, e.target.value)} + placeholder={textPlaceholder} + disabled={disabled} + aria-label={label} + /> + ) : ( + + // No legend: the row already carries the label, and a + // legend would float a second copy inside the control. + handleSelect(field.id, option)} + isMulti={field.type === 'multiselect'} + isClearable={true} + isDisabled={disabled} + placeholder={selectPlaceholder} + styles={dropdownStyles} + menuPortalTarget={document.body} + /> + )} +
+
+ ); + })} +
+ ); +}; + +// DropdownInput types `value` as a single option even under isMulti, and casts +// internally for the same reason, so the array case is cast here rather than +// dropping multiselect support. +function resolveSelected(field: PropertyField, selected: string | string[] | undefined): Option | undefined { + const options = toOptions(field); + if (Array.isArray(selected)) { + return options.filter((option) => selected.includes(option.value)) as unknown as Option; + } + return options.find((option) => option.value === selected); +} + +export default ChannelAttributesForm; diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.tsx b/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.tsx index 6c514b8d7a58..4a7a1626127e 100644 --- a/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.tsx +++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.tsx @@ -13,15 +13,13 @@ import {patchChannel} from 'mattermost-redux/actions/channels'; import {fetchChannelRemotes} from 'mattermost-redux/actions/shared_channels'; import {Client4} from 'mattermost-redux/client'; import {Permissions} from 'mattermost-redux/constants'; +import {ACCESS_CONTROL_PROPERTY_GROUP} from 'mattermost-redux/constants/properties'; import {isChannelAutotranslated as isChannelAutotranslatedSelector} from 'mattermost-redux/selectors/entities/channels'; import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles'; import {getRemotesForChannel} from 'mattermost-redux/selectors/entities/shared_channels'; import {ColorSwatch, LevelOptionLabel} from 'components/admin_console/classification_markings/classification_markings_styled'; -import { - CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, - CLASSIFICATIONS_GROUP_NAME, -} from 'components/admin_console/classification_markings/utils'; +import {CLASSIFICATIONS_CHANNEL_OBJECT_TYPE} from 'components/admin_console/classification_markings/utils'; import {classificationPresetDropdownStyles} from 'components/admin_console/classification_markings/utils/preset_dropdown_styles'; import ColorInput from 'components/color_input'; import useChannelClassificationBanner from 'components/common/hooks/useChannelClassificationBanner'; @@ -461,7 +459,7 @@ function ChannelSettingsConfigurationTab({ if (classificationEnabled && selectedClassificationId) { try { const values = await Client4.patchPropertyValues( - CLASSIFICATIONS_GROUP_NAME, + ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, channel.id, [{field_id: classification.channelField.id, value: selectedClassificationId}], @@ -474,7 +472,7 @@ function ChannelSettingsConfigurationTab({ } else if (!classificationEnabled && initialClassificationState.enabled) { try { await Client4.patchPropertyValues( - CLASSIFICATIONS_GROUP_NAME, + ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, channel.id, [{field_id: classification.channelField.id, value: null}], diff --git a/webapp/channels/src/components/common/hooks/useChannelAttributes.test.ts b/webapp/channels/src/components/common/hooks/useChannelAttributes.test.ts new file mode 100644 index 000000000000..2c8eea1f9059 --- /dev/null +++ b/webapp/channels/src/components/common/hooks/useChannelAttributes.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {waitFor} from '@testing-library/react'; + +import type {PropertyField} from '@mattermost/types/properties'; + +import {fetchPropertyFields} from 'mattermost-redux/actions/properties'; +import {ACCESS_CONTROL_PROPERTY_GROUP, CHANNEL_OBJECT_TYPE} from 'mattermost-redux/constants/properties'; + +import {renderHookWithContext} from 'tests/react_testing_utils'; + +import useChannelAttributes from './useChannelAttributes'; + +type PartialState = Parameters[1]; + +jest.mock('mattermost-redux/actions/properties', () => ({ + __esModule: true, + fetchPropertyFields: jest.fn(), +})); + +const mockedFetch = fetchPropertyFields as jest.MockedFunction; + +const GROUP_ID = 'group_access_control'; + +const field: PropertyField = { + id: 'f_program', + group_id: GROUP_ID, + name: 'program', + type: 'select', + target_id: '', + target_type: 'system', + object_type: CHANNEL_OBJECT_TYPE, + create_at: 1, + update_at: 1, + delete_at: 0, + created_by: '', + updated_by: '', +}; + +// A store where the fields have landed but the group name -> id mapping has not. +// This is what a websocket field event alone produces, since only the fetch +// carries the group name. +function stateWith({groupResolved}: {groupResolved: boolean}): PartialState { + return { + entities: { + general: { + config: {FeatureFlagChannelAttributes: 'true'}, + license: {IsLicensed: 'true', SkuShortName: 'advanced'}, + }, + properties: { + groups: groupResolved ? { + byId: {[GROUP_ID]: {id: GROUP_ID, name: ACCESS_CONTROL_PROPERTY_GROUP}}, + byName: {[ACCESS_CONTROL_PROPERTY_GROUP]: {id: GROUP_ID, name: ACCESS_CONTROL_PROPERTY_GROUP}}, + } : {byId: {}, byName: {}}, + fields: { + byId: {[field.id]: field}, + byObjectType: {[CHANNEL_OBJECT_TYPE]: {[GROUP_ID]: {[field.id]: field}}}, + }, + values: {byTargetId: {}, byFieldId: {}}, + }, + }, + } as PartialState; +} + +describe('useChannelAttributes', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedFetch.mockReturnValue((() => Promise.resolve({data: [field]})) as ReturnType); + }); + + test('reports the fields once the group mapping has landed', async () => { + const {result} = renderHookWithContext(() => useChannelAttributes(), stateWith({groupResolved: true})); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.enabled).toBe(true); + expect(result.current.failed).toBe(false); + expect(result.current.fields).toHaveLength(1); + }); + + test('settles rather than loading forever when the fetch rejects', async () => { + mockedFetch.mockReturnValue((() => Promise.reject(new Error('network'))) as ReturnType); + + const {result} = renderHookWithContext(() => useChannelAttributes(), stateWith({groupResolved: false})); + + await waitFor(() => expect(result.current.failed).toBe(true)); + expect(result.current.loading).toBe(false); + }); + + test('treats an error result the same as a rejection', async () => { + mockedFetch.mockReturnValue((() => Promise.resolve({error: new Error('boom')})) as ReturnType); + + const {result} = renderHookWithContext(() => useChannelAttributes(), stateWith({groupResolved: false})); + + await waitFor(() => expect(result.current.failed).toBe(true)); + }); + + test('does not fetch or report anything without an enterprise licence', async () => { + const state = stateWith({groupResolved: true}) as {entities: {general: {license: {SkuShortName: string}}}}; + state.entities.general.license.SkuShortName = 'professional'; + + const {result} = renderHookWithContext(() => useChannelAttributes(), state as PartialState); + + expect(result.current.enabled).toBe(false); + expect(result.current.fields).toEqual([]); + expect(mockedFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/webapp/channels/src/components/common/hooks/useChannelAttributes.ts b/webapp/channels/src/components/common/hooks/useChannelAttributes.ts new file mode 100644 index 000000000000..a241dc901960 --- /dev/null +++ b/webapp/channels/src/components/common/hooks/useChannelAttributes.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useEffect, useState} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; + +import type {PropertyField} from '@mattermost/types/properties'; +import type {GlobalState} from '@mattermost/types/store'; + +import {fetchPropertyFields} from 'mattermost-redux/actions/properties'; +import { + ACCESS_CONTROL_PROPERTY_GROUP, + CHANNEL_OBJECT_TYPE, + SYSTEM_TARGET_ID, + SYSTEM_TARGET_TYPE, +} from 'mattermost-redux/constants/properties'; +import {getFeatureFlagValue, getLicense} from 'mattermost-redux/selectors/entities/general'; +import {getChannelAttributeFields} from 'mattermost-redux/selectors/entities/properties'; + +import {isEnterpriseLicense} from 'utils/license_utils'; + +export type ChannelAttributesState = { + enabled: boolean; + loading: boolean; + failed: boolean; + fields: PropertyField[]; +}; + +/** + * Loads the channel attribute definitions and reports whether the feature is + * usable at all. Gated on the ChannelAttributes flag and an Enterprise licence, + * matching the server, which rejects access_control writes without one. + * + * Fetching is unconditional on mount rather than skipped when fields are already + * cached: fetchPropertyFields does an authoritative scoped replace, so this is + * also how a field deleted server-side stops appearing without a reload. + * + * This fetch is also the only thing that supplies the group name -> UUID mapping + * the field selectors resolve through; a websocket field event populates the + * fields alone. So when it fails the store can hold fields that nothing can read, + * which is why the outcome is tracked here rather than inferred from the store: + * an empty result and a failed load are not the same thing, and only one of them + * is worth telling the user about. + */ +export default function useChannelAttributes(): ChannelAttributesState { + const dispatch = useDispatch(); + + const enabled = useSelector((state: GlobalState) => getFeatureFlagValue(state, 'ChannelAttributes') === 'true'); + const hasEnterpriseLicense = isEnterpriseLicense(useSelector(getLicense)); + const available = enabled && hasEnterpriseLicense; + + const fields = useSelector(getChannelAttributeFields); + + const [status, setStatus] = useState<'idle' | 'loading' | 'loaded' | 'failed'>('idle'); + + useEffect(() => { + if (!available) { + setStatus('idle'); + return undefined; + } + + // Guards against settling the state of a fetch whose consumer is gone, or + // that a newer fetch has already superseded. + let current = true; + setStatus('loading'); + + dispatch(fetchPropertyFields( + ACCESS_CONTROL_PROPERTY_GROUP, + CHANNEL_OBJECT_TYPE, + SYSTEM_TARGET_TYPE, + SYSTEM_TARGET_ID, + )).then((result) => { + if (current) { + setStatus(result?.error ? 'failed' : 'loaded'); + } + }).catch(() => { + if (current) { + setStatus('failed'); + } + }); + + return () => { + current = false; + }; + }, [available, dispatch]); + + return { + enabled: available, + loading: available && (status === 'idle' || status === 'loading'), + failed: available && status === 'failed', + fields: available ? fields : [], + }; +} diff --git a/webapp/channels/src/components/common/hooks/useChannelClassificationBanner.test.ts b/webapp/channels/src/components/common/hooks/useChannelClassificationBanner.test.ts index c06abf87e0d5..c612cef14c0b 100644 --- a/webapp/channels/src/components/common/hooks/useChannelClassificationBanner.test.ts +++ b/webapp/channels/src/components/common/hooks/useChannelClassificationBanner.test.ts @@ -6,12 +6,12 @@ import * as ReactRedux from 'react-redux'; import type {PropertyField, PropertyValue} from '@mattermost/types/properties'; import {Client4} from 'mattermost-redux/client'; +import {ACCESS_CONTROL_PROPERTY_GROUP} from 'mattermost-redux/constants/properties'; import { CLASSIFICATIONS_CHANNEL_FIELD_NAME, CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, CLASSIFICATIONS_FIELD_TARGET_TYPE, - CLASSIFICATIONS_GROUP_NAME, } from 'components/admin_console/classification_markings/utils'; import type {ClassificationLevel} from 'components/admin_console/classification_markings/utils/presets'; @@ -31,7 +31,7 @@ const FIELD_ID = 'channel_field_1'; function makeChannelField(overrides: Partial = {}): PropertyField { return { id: FIELD_ID, - group_id: CLASSIFICATIONS_GROUP_NAME, + group_id: ACCESS_CONTROL_PROPERTY_GROUP, name: CLASSIFICATIONS_CHANNEL_FIELD_NAME, type: 'select', attrs: {}, @@ -53,7 +53,7 @@ function makePropertyValue(value: string | null): PropertyValue { id: 'value1', target_id: CHANNEL_ID, target_type: CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, - group_id: CLASSIFICATIONS_GROUP_NAME, + group_id: ACCESS_CONTROL_PROPERTY_GROUP, field_id: FIELD_ID, value: value as string, create_at: 2000, @@ -211,7 +211,7 @@ describe('useChannelClassificationBanner', () => { id: 'value1', target_id: CHANNEL_ID, target_type: CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, - group_id: CLASSIFICATIONS_GROUP_NAME, + group_id: ACCESS_CONTROL_PROPERTY_GROUP, field_id: FIELD_ID, value: {classification_id: 'lvl1', banner_text: 'test'} as unknown as string, create_at: 2000, @@ -281,7 +281,7 @@ describe('useChannelClassificationBanner', () => { ); await Promise.resolve(); - expect(fetchSpy).toHaveBeenCalledWith(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, CHANNEL_ID); + expect(fetchSpy).toHaveBeenCalledWith(ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, CHANNEL_ID); }); test('silently ignores fetch errors (channel may not have classification set)', async () => { diff --git a/webapp/channels/src/components/common/hooks/useChannelClassificationBanner.ts b/webapp/channels/src/components/common/hooks/useChannelClassificationBanner.ts index cc757b8d5c1b..fb8bf11f5ba9 100644 --- a/webapp/channels/src/components/common/hooks/useChannelClassificationBanner.ts +++ b/webapp/channels/src/components/common/hooks/useChannelClassificationBanner.ts @@ -10,13 +10,11 @@ import type {GlobalState} from '@mattermost/types/store'; import {PropertyTypes} from 'mattermost-redux/action_types'; import {Client4} from 'mattermost-redux/client'; +import {ACCESS_CONTROL_PROPERTY_GROUP} from 'mattermost-redux/constants/properties'; import {getChannelBanner} from 'mattermost-redux/selectors/entities/channels'; import {getPropertyValueForTargetField} from 'mattermost-redux/selectors/entities/properties'; -import { - CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, - CLASSIFICATIONS_GROUP_NAME, -} from 'components/admin_console/classification_markings/utils'; +import {CLASSIFICATIONS_CHANNEL_OBJECT_TYPE} from 'components/admin_console/classification_markings/utils'; import useClassificationMarkings from './useClassificationMarkings'; @@ -59,7 +57,7 @@ export default function useChannelClassificationBanner(channelId: string): Chann if (!propertyValue) { Client4.getPropertyValues( - CLASSIFICATIONS_GROUP_NAME, + ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, channelId, ).then((values) => { diff --git a/webapp/channels/src/components/common/hooks/useChannelLabels.test.tsx b/webapp/channels/src/components/common/hooks/useChannelLabels.test.tsx new file mode 100644 index 000000000000..664d6e74e359 --- /dev/null +++ b/webapp/channels/src/components/common/hooks/useChannelLabels.test.tsx @@ -0,0 +1,114 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {PropertyField, PropertyValue} from '@mattermost/types/properties'; +import type {DeepPartial} from '@mattermost/types/utilities'; + +import {renderHookWithContext} from 'tests/react_testing_utils'; + +import type {GlobalState} from 'types/store'; + +import useChannelLabels from './useChannelLabels'; + +jest.mock('mattermost-redux/actions/properties', () => ({ + fetchPropertyFields: jest.fn(() => () => Promise.resolve({data: []})), +})); + +const GROUP_ID = 'group1'; +const CHANNEL_ID = 'channel1'; + +function field(id: string, actions: string[]): PropertyField { + return { + id, + group_id: GROUP_ID, + name: id, + type: 'select', + target_id: '', + target_type: 'system', + object_type: 'channel', + attrs: {actions, options: [{id: 'opt', name: id.toUpperCase()}]}, + create_at: 1, + update_at: 1, + delete_at: 0, + created_by: '', + updated_by: '', + }; +} + +function value(fieldId: string, raw: unknown): PropertyValue { + return { + id: `value_${fieldId}`, + target_id: CHANNEL_ID, + target_type: 'channel', + group_id: GROUP_ID, + field_id: fieldId, + value: raw, + create_at: 1, + update_at: 1, + delete_at: 0, + created_by: '', + updated_by: '', + }; +} + +function makeState(fields: PropertyField[], values: Array>, flag = 'true'): DeepPartial { + const byTargetId: Record>> = {}; + for (const v of values) { + byTargetId[v.target_id] = {...byTargetId[v.target_id], [v.field_id]: v}; + } + + return { + entities: { + general: { + config: {FeatureFlagChannelAttributes: flag}, + license: {IsLicensed: 'true', SkuShortName: 'enterprise'}, + }, + properties: { + groups: {byId: {[GROUP_ID]: {id: GROUP_ID, name: 'access_control'}}, byName: {access_control: {id: GROUP_ID, name: 'access_control'}}}, + fields: { + byId: Object.fromEntries(fields.map((f) => [f.id, f])), + byObjectType: {channel: {[GROUP_ID]: Object.fromEntries(fields.map((f) => [f.id, f]))}}, + }, + values: {byTargetId, byFieldId: {}}, + }, + }, + } as DeepPartial; +} + +describe('useChannelLabels', () => { + test('returns only fields designated for the requested surface', () => { + const state = makeState( + [field('header_only', ['display_label_header']), field('info_only', ['display_label_info'])], + [value('header_only', 'opt'), value('info_only', 'opt')], + ); + + const header = renderHookWithContext(() => useChannelLabels(CHANNEL_ID, 'header'), state); + expect(header.result.current.map((a) => a.field.id)).toEqual(['header_only']); + + const info = renderHookWithContext(() => useChannelLabels(CHANNEL_ID, 'info'), state); + expect(info.result.current.map((a) => a.field.id)).toEqual(['info_only']); + }); + + test('omits a designated attribute that has no value on this channel', () => { + // A chip with nothing in it says nothing, so an unset attribute is not + // rendered even though it is designated system-wide. + const state = makeState([field('program', ['display_label_header'])], []); + + const {result} = renderHookWithContext(() => useChannelLabels(CHANNEL_ID, 'header'), state); + expect(result.current).toEqual([]); + }); + + test('omits an attribute whose value was cleared to null', () => { + const state = makeState([field('program', ['display_label_header'])], [value('program', null)]); + + const {result} = renderHookWithContext(() => useChannelLabels(CHANNEL_ID, 'header'), state); + expect(result.current).toEqual([]); + }); + + test('returns nothing when the feature flag is off', () => { + const state = makeState([field('program', ['display_label_header'])], [value('program', 'opt')], 'false'); + + const {result} = renderHookWithContext(() => useChannelLabels(CHANNEL_ID, 'header'), state); + expect(result.current).toEqual([]); + }); +}); diff --git a/webapp/channels/src/components/common/hooks/useChannelLabels.ts b/webapp/channels/src/components/common/hooks/useChannelLabels.ts new file mode 100644 index 000000000000..e720c7a0c585 --- /dev/null +++ b/webapp/channels/src/components/common/hooks/useChannelLabels.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useMemo} from 'react'; +import {useSelector} from 'react-redux'; + +import type {GlobalState} from '@mattermost/types/store'; + +import {DISPLAY_LABEL_HEADER, DISPLAY_LABEL_INFO} from 'mattermost-redux/constants/properties'; +import type {ResolvedChannelAttribute} from 'mattermost-redux/selectors/entities/properties'; +import {makeGetResolvedChannelAttributes} from 'mattermost-redux/selectors/entities/properties'; + +import useChannelAttributes from './useChannelAttributes'; + +export type ChannelLabelSurface = 'header' | 'info'; + +const ACTION_BY_SURFACE: Record = { + header: DISPLAY_LABEL_HEADER, + info: DISPLAY_LABEL_INFO, +}; + +const EMPTY: ResolvedChannelAttribute[] = []; + +/** + * The attributes that should render as labels on a given surface for a channel, + * in display order. An attribute designated for display but with no value on + * this channel is omitted — a chip with nothing in it says nothing. + * + * Rendering is story 3; this exists so the data contract is defined and tested + * alongside the assignment flow that produces the values. + */ +export default function useChannelLabels(channelId: string, surface: ChannelLabelSurface): ResolvedChannelAttribute[] { + const {enabled} = useChannelAttributes(); + const getResolvedChannelAttributes = useMemo(() => makeGetResolvedChannelAttributes(), []); + const resolved = useSelector((state: GlobalState) => getResolvedChannelAttributes(state, channelId)); + + return useMemo(() => { + if (!enabled || !channelId) { + return EMPTY; + } + const action = ACTION_BY_SURFACE[surface]; + const labels = resolved.filter((attribute) => { + if (!attribute.displayValue) { + return false; + } + const actions = attribute.field.attrs?.actions; + return Array.isArray(actions) && actions.includes(action); + }); + return labels.length === 0 ? EMPTY : labels; + }, [enabled, channelId, resolved, surface]); +} diff --git a/webapp/channels/src/components/common/hooks/useClassificationMarkings.test.ts b/webapp/channels/src/components/common/hooks/useClassificationMarkings.test.ts index ffe21b43bdbb..934b071fe69e 100644 --- a/webapp/channels/src/components/common/hooks/useClassificationMarkings.test.ts +++ b/webapp/channels/src/components/common/hooks/useClassificationMarkings.test.ts @@ -5,11 +5,12 @@ import * as ReactRedux from 'react-redux'; import type {PropertyField} from '@mattermost/types/properties'; +import {ACCESS_CONTROL_PROPERTY_GROUP} from 'mattermost-redux/constants/properties'; + import { CLASSIFICATIONS_CHANNEL_FIELD_NAME, CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, CLASSIFICATIONS_FIELD_TARGET_TYPE, - CLASSIFICATIONS_GROUP_NAME, } from 'components/admin_console/classification_markings/utils'; import {renderHookWithContext} from 'tests/react_testing_utils'; @@ -26,7 +27,7 @@ jest.mock('react-redux', () => ({ function makeChannelField(overrides: Partial = {}): PropertyField { return { id: 'channel1', - group_id: CLASSIFICATIONS_GROUP_NAME, + group_id: GROUP_ID, name: CLASSIFICATIONS_CHANNEL_FIELD_NAME, type: 'select', attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]}, @@ -43,6 +44,8 @@ function makeChannelField(overrides: Partial = {}): PropertyField }; } +const GROUP_ID = 'group_access_control'; + const ENTERPRISE_LICENSE = {IsLicensed: 'true', SkuShortName: 'enterprise'}; const STARTER_LICENSE = {IsLicensed: 'true', SkuShortName: 'starter'}; @@ -51,6 +54,8 @@ function stateWith({featureFlag, license, fields = {}}: { license?: typeof ENTERPRISE_LICENSE | typeof STARTER_LICENSE | Record; fields?: Record; }): PartialState { + // Fields are looked up through the group-scoped map, which the fetch action + // populates together with the name -> id mapping, so both are set here. return { entities: { general: { @@ -58,7 +63,15 @@ function stateWith({featureFlag, license, fields = {}}: { license: license ?? {}, }, properties: { - fields: {byId: fields}, + groups: { + byId: {[GROUP_ID]: {id: GROUP_ID, name: ACCESS_CONTROL_PROPERTY_GROUP}}, + byName: {[ACCESS_CONTROL_PROPERTY_GROUP]: {id: GROUP_ID, name: ACCESS_CONTROL_PROPERTY_GROUP}}, + }, + fields: { + byId: fields, + byObjectType: {[CLASSIFICATIONS_CHANNEL_OBJECT_TYPE]: {[GROUP_ID]: fields}}, + }, + values: {byTargetId: {}, byFieldId: {}}, }, }, } as PartialState; diff --git a/webapp/channels/src/components/common/hooks/useClassificationMarkings.ts b/webapp/channels/src/components/common/hooks/useClassificationMarkings.ts index e871916ee404..188af1c4997c 100644 --- a/webapp/channels/src/components/common/hooks/useClassificationMarkings.ts +++ b/webapp/channels/src/components/common/hooks/useClassificationMarkings.ts @@ -8,27 +8,27 @@ import type {PropertyField, PropertyFieldOption} from '@mattermost/types/propert import type {GlobalState} from '@mattermost/types/store'; import {fetchPropertyFields} from 'mattermost-redux/actions/properties'; +import {ACCESS_CONTROL_PROPERTY_GROUP} from 'mattermost-redux/constants/properties'; import {getFeatureFlagValue, getLicense} from 'mattermost-redux/selectors/entities/general'; +import {getChannelAttributeFields} from 'mattermost-redux/selectors/entities/properties'; import { CLASSIFICATIONS_CHANNEL_FIELD_NAME, CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, CLASSIFICATIONS_FIELD_TARGET_ID, CLASSIFICATIONS_FIELD_TARGET_TYPE, - CLASSIFICATIONS_GROUP_NAME, optionsToLevels, } from 'components/admin_console/classification_markings/utils'; import type {ClassificationLevel} from 'components/admin_console/classification_markings/utils/presets'; import {isEnterpriseLicense} from 'utils/license_utils'; +// Scoped to the channel-object fields of this group rather than scanning every +// field in the store. linked_field_id is what distinguishes the channel field +// from the template it inherits its options from. function selectChannelClassificationField(state: GlobalState): PropertyField | undefined { - const byId = state.entities.properties?.fields?.byId; - if (!byId) { - return undefined; - } - return Object.values(byId).find( - (f) => f.object_type === CLASSIFICATIONS_CHANNEL_OBJECT_TYPE && f.name === CLASSIFICATIONS_CHANNEL_FIELD_NAME && f.linked_field_id && f.delete_at === 0, + return getChannelAttributeFields(state).find( + (f) => f.name === CLASSIFICATIONS_CHANNEL_FIELD_NAME && Boolean(f.linked_field_id), ); } @@ -64,7 +64,7 @@ export default function useClassificationMarkings(): ClassificationMarkingsState } if (!channelField) { dispatch(fetchPropertyFields( - CLASSIFICATIONS_GROUP_NAME, + ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, CLASSIFICATIONS_FIELD_TARGET_TYPE, CLASSIFICATIONS_FIELD_TARGET_ID, diff --git a/webapp/channels/src/components/global_classification_banner/global_classification_banner.test.tsx b/webapp/channels/src/components/global_classification_banner/global_classification_banner.test.tsx index 935fdd0eabf0..705708898d81 100644 --- a/webapp/channels/src/components/global_classification_banner/global_classification_banner.test.tsx +++ b/webapp/channels/src/components/global_classification_banner/global_classification_banner.test.tsx @@ -7,13 +7,11 @@ import type {PropertyField, PropertyValue} from '@mattermost/types/properties'; import type {DeepPartial} from '@mattermost/types/utilities'; import {Client4} from 'mattermost-redux/client'; +import {ACCESS_CONTROL_PROPERTY_GROUP, DISPLAY_BANNER_BOTTOM, DISPLAY_BANNER_TOP} from 'mattermost-redux/constants/properties'; import { - DISPLAY_BANNER_BOTTOM, - DISPLAY_BANNER_TOP, CLASSIFICATIONS_FIELD_TARGET_ID, CLASSIFICATIONS_FIELD_TARGET_TYPE, - CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, CLASSIFICATIONS_SYSTEM_VALUE_TARGET_ID, } from 'components/admin_console/classification_markings/utils'; @@ -34,7 +32,7 @@ const LINKED_FIELD_ID = 'linked_field1'; function makeLinkedField(actions: string[], options: Array<{id: string; name: string; color: string}> = []): PropertyField { return { id: LINKED_FIELD_ID, - group_id: CLASSIFICATIONS_GROUP_NAME, + group_id: ACCESS_CONTROL_PROPERTY_GROUP, name: 'classification', type: 'select', object_type: CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, @@ -58,7 +56,7 @@ function makeSystemValue(optionId: string): PropertyValue { id: 'value1', target_id: CLASSIFICATIONS_SYSTEM_VALUE_TARGET_ID, target_type: CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, - group_id: CLASSIFICATIONS_GROUP_NAME, + group_id: ACCESS_CONTROL_PROPERTY_GROUP, field_id: LINKED_FIELD_ID, value: optionId, create_at: 3000, @@ -275,7 +273,7 @@ describe('GlobalClassificationBanner', () => { ); expect(Client4.getPropertyFields).toHaveBeenCalledWith( - CLASSIFICATIONS_GROUP_NAME, + ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, CLASSIFICATIONS_FIELD_TARGET_TYPE, CLASSIFICATIONS_FIELD_TARGET_ID, diff --git a/webapp/channels/src/components/global_classification_banner/global_classification_banner.tsx b/webapp/channels/src/components/global_classification_banner/global_classification_banner.tsx index f9124d785fb1..fb68f5ded0d9 100644 --- a/webapp/channels/src/components/global_classification_banner/global_classification_banner.tsx +++ b/webapp/channels/src/components/global_classification_banner/global_classification_banner.tsx @@ -8,6 +8,7 @@ import type {PropertyField, PropertyFieldOption, PropertyValue} from '@mattermos import type {GlobalState} from '@mattermost/types/store'; import {fetchPropertyFields, fetchSystemPropertyValues} from 'mattermost-redux/actions/properties'; +import {ACCESS_CONTROL_PROPERTY_GROUP, DISPLAY_BANNER_BOTTOM, DISPLAY_BANNER_TOP} from 'mattermost-redux/constants/properties'; import {getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general'; import {getPropertyValueForTargetField} from 'mattermost-redux/selectors/entities/properties'; import {getContrastingSimpleColor} from 'mattermost-redux/utils/theme_utils'; @@ -15,12 +16,9 @@ import {getContrastingSimpleColor} from 'mattermost-redux/utils/theme_utils'; import { CLASSIFICATIONS_FIELD_TARGET_ID, CLASSIFICATIONS_FIELD_TARGET_TYPE, - CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_SYSTEM_FIELD_NAME, CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, CLASSIFICATIONS_SYSTEM_VALUE_TARGET_ID, - DISPLAY_BANNER_BOTTOM, - DISPLAY_BANNER_TOP, findOptionById, } from 'components/admin_console/classification_markings/utils'; @@ -67,14 +65,14 @@ export default function GlobalClassificationBanner({position}: Props) { } if (!linkedField) { dispatch(fetchPropertyFields( - CLASSIFICATIONS_GROUP_NAME, + ACCESS_CONTROL_PROPERTY_GROUP, CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, CLASSIFICATIONS_FIELD_TARGET_TYPE, CLASSIFICATIONS_FIELD_TARGET_ID, )); } if (linkedField && !systemValue) { - dispatch(fetchSystemPropertyValues(CLASSIFICATIONS_GROUP_NAME)); + dispatch(fetchSystemPropertyValues(ACCESS_CONTROL_PROPERTY_GROUP)); } }, [featureEnabled, linkedField, systemValue, dispatch]); diff --git a/webapp/channels/src/components/new_channel_modal/new_channel_modal.test.tsx b/webapp/channels/src/components/new_channel_modal/new_channel_modal.test.tsx index 7d02ff6e5f20..af0fa01dc7d8 100644 --- a/webapp/channels/src/components/new_channel_modal/new_channel_modal.test.tsx +++ b/webapp/channels/src/components/new_channel_modal/new_channel_modal.test.tsx @@ -4,11 +4,14 @@ import React from 'react'; import type {Channel} from '@mattermost/types/channels'; +import type {PropertyField} from '@mattermost/types/properties'; import type {DeepPartial} from '@mattermost/types/utilities'; import {createChannel} from 'mattermost-redux/actions/channels'; +import {Client4} from 'mattermost-redux/client'; import Permissions from 'mattermost-redux/constants/permissions'; +import useChannelAttributes from 'components/common/hooks/useChannelAttributes'; import useClassificationMarkings from 'components/common/hooks/useClassificationMarkings'; import { @@ -45,8 +48,13 @@ jest.mock('components/common/hooks/useClassificationMarkings', () => ({ __esModule: true, default: jest.fn(() => ({available: false, loading: false, channelField: null, levels: []})), })); +jest.mock('components/common/hooks/useChannelAttributes', () => ({ + __esModule: true, + default: jest.fn(() => ({enabled: false, loading: false, failed: false, fields: []})), +})); const mockedUseClassificationMarkings = useClassificationMarkings as jest.MockedFunction; +const mockedUseChannelAttributes = useChannelAttributes as jest.MockedFunction; describe('components/new_channel_modal', () => { const initialState: DeepPartial = { @@ -1290,3 +1298,119 @@ describe('components/new_channel_modal - plugin channel-type options', () => { expect(pluginButton.closest('button')).toHaveClass('selected'); }); }); + +describe('components/new_channel_modal - channel attributes', () => { + const createdChannel: Channel = { + id: 'new_channel_id', + create_at: 0, + update_at: 0, + delete_at: 0, + team_id: 'current_team_id', + type: 'O', + display_name: 'My Channel', + name: 'my-channel', + header: '', + purpose: '', + last_post_at: 0, + last_root_post_at: 0, + creator_id: '', + scheme_id: '', + group_constrained: false, + }; + + const program: PropertyField = { + id: 'f_program', + group_id: 'g_access_control', + name: 'program', + type: 'select', + target_id: '', + target_type: 'system', + object_type: 'channel', + attrs: {display_name: 'Program', options: [{id: 'opt_a', name: 'AURORA'}, {id: 'opt_b', name: 'BOREALIS'}]}, + create_at: 1, + update_at: 1, + delete_at: 0, + created_by: '', + updated_by: '', + }; + + const state: DeepPartial = { + entities: { + general: {config: {UseAnonymousURLs: 'false'}}, + channels: {currentChannelId: 'current_channel_id', channels: {}, roles: {}}, + teams: { + currentTeamId: 'current_team_id', + myMembers: {current_team_id: {roles: 'team_user'}}, + teams: {current_team_id: {id: 'current_team_id', name: 'current-team'}}, + }, + preferences: {myPreferences: {}}, + users: {currentUserId: 'current_user_id', profiles: {current_user_id: {roles: 'system_admin system_user'}}}, + roles: { + roles: { + team_user: {permissions: []}, + system_admin: {permissions: [Permissions.CREATE_PUBLIC_CHANNEL]}, + system_user: {permissions: []}, + }, + }, + }, + }; + + let patchPropertyValues: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockedUseClassificationMarkings.mockReturnValue({available: false, loading: false, channelField: null, levels: []}); + mockedUseChannelAttributes.mockReturnValue({enabled: true, loading: false, failed: false, fields: [program]}); + (createChannel as jest.Mock).mockReturnValue(() => Promise.resolve({data: createdChannel, error: null})); + patchPropertyValues = jest.spyOn(Client4, 'patchPropertyValues').mockResolvedValue([]); + }); + + afterEach(() => { + patchPropertyValues.mockRestore(); + }); + + async function fillAndSelect() { + await userEvent.type(screen.getByPlaceholderText('Enter a name for your new channel'), 'My Channel'); + await userEvent.click(screen.getByText('Select a value')); + await userEvent.click(screen.getByText('AURORA')); + } + + test('writes the selected attribute values against the channel that was just created', async () => { + renderWithContext(, state); + + await fillAndSelect(); + await userEvent.click(screen.getByText('Create channel')); + + await waitFor(() => expect(patchPropertyValues).toHaveBeenCalledTimes(1)); + expect(patchPropertyValues).toHaveBeenCalledWith( + 'access_control', + 'channel', + createdChannel.id, + [{field_id: program.id, value: 'opt_a'}], + ); + }); + + test('writes nothing when no attribute was chosen', async () => { + renderWithContext(, state); + + await userEvent.type(screen.getByPlaceholderText('Enter a name for your new channel'), 'My Channel'); + await userEvent.click(screen.getByText('Create channel')); + + await waitFor(() => expect(createChannel).toHaveBeenCalled()); + expect(patchPropertyValues).not.toHaveBeenCalled(); + }); + + test('keeps the channel and names the unsaved attribute when the write fails', async () => { + patchPropertyValues.mockRejectedValue(new Error('nope')); + + renderWithContext(, state); + + await fillAndSelect(); + await userEvent.click(screen.getByText('Create channel')); + + // The error has to name what was not saved, and the modal has to stop + // offering to create a second channel. + await waitFor(() => expect(screen.getByText(/these attributes were not saved: Program/)).toBeInTheDocument()); + expect(screen.getByText('Go to channel')).toBeEnabled(); + }); +}); diff --git a/webapp/channels/src/components/new_channel_modal/new_channel_modal.tsx b/webapp/channels/src/components/new_channel_modal/new_channel_modal.tsx index 100a5bdb7eb2..2c92aa6722d7 100644 --- a/webapp/channels/src/components/new_channel_modal/new_channel_modal.tsx +++ b/webapp/channels/src/components/new_channel_modal/new_channel_modal.tsx @@ -18,6 +18,7 @@ import {createChannel} from 'mattermost-redux/actions/channels'; import {Client4} from 'mattermost-redux/client'; import Permissions from 'mattermost-redux/constants/permissions'; import Preferences from 'mattermost-redux/constants/preferences'; +import {ACCESS_CONTROL_PROPERTY_GROUP, CHANNEL_OBJECT_TYPE} from 'mattermost-redux/constants/properties'; import {areManagedCategoriesEnabled, isChannelCategorySortingEnabled, makeGetSidebarCategoryNamesForTeam} from 'mattermost-redux/selectors/entities/channel_categories'; import {isDiscoverableChannelsEnabled} from 'mattermost-redux/selectors/entities/general'; import {get as getPreference} from 'mattermost-redux/selectors/entities/preferences'; @@ -29,17 +30,16 @@ import {switchToChannel} from 'actions/views/channel'; import {closeModal} from 'actions/views/modals'; import {ColorSwatch, LevelOptionLabel} from 'components/admin_console/classification_markings/classification_markings_styled'; -import { - CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, - CLASSIFICATIONS_GROUP_NAME, -} from 'components/admin_console/classification_markings/utils'; import {classificationPresetDropdownStyles} from 'components/admin_console/classification_markings/utils/preset_dropdown_styles'; import CategorySelector from 'components/category_selector/category_selector'; +import type {ChannelAttributeSelection} from 'components/channel_attributes/channel_attributes_form'; +import ChannelAttributesForm from 'components/channel_attributes/channel_attributes_form'; import ChannelNameFormField from 'components/channel_name_form_field/channel_name_form_field'; import { CHANNEL_BANNER_MAX_CHARACTER_LIMIT, CHANNEL_BANNER_MIN_CHARACTER_LIMIT, } from 'components/channel_settings_modal/channel_settings_configuration_tab'; +import useChannelAttributes from 'components/common/hooks/useChannelAttributes'; import useClassificationMarkings from 'components/common/hooks/useClassificationMarkings'; import DropdownInput from 'components/dropdown_input'; import type {ValueType} from 'components/dropdown_input'; @@ -129,6 +129,42 @@ const NewChannelModal = () => { const classification = useClassificationMarkings(); const isSystemAdmin = useSelector(isCurrentUserSystemAdmin); const canManageClassification = classification.available && isSystemAdmin; + + const channelAttributes = useChannelAttributes(); + const [attributeValues, setAttributeValues] = useState({}); + const [attributeError, setAttributeError] = useState(''); + const [createdChannel, setCreatedChannel] = useState(null); + + // Classification keeps its own section, with the level toggle and banner text + // it needs, so it is excluded here rather than given a second control that + // writes the same field. + const assignableAttributeFields = useMemo(() => { + return channelAttributes.fields.filter((field) => field.id !== classification.channelField?.id); + }, [channelAttributes.fields, classification.channelField]); + + const attributeDisplayName = useCallback((fieldId: string): string => { + if (fieldId === classification.channelField?.id) { + return formatMessage({id: 'channel_modal.classification.toggle_label', defaultMessage: 'Channel classification'}); + } + const field = channelAttributes.fields.find((candidate) => candidate.id === fieldId); + if (!field) { + return ''; + } + const displayName = field.attrs?.display_name; + return typeof displayName === 'string' && displayName ? displayName : field.name; + }, [channelAttributes.fields, classification.channelField, formatMessage]); + + const handleAttributeChange = useCallback((fieldId: string, value: string | string[] | undefined) => { + setAttributeValues((current) => { + const next = {...current}; + if (value === undefined) { + Reflect.deleteProperty(next, fieldId); + } else { + next[fieldId] = value; + } + return next; + }); + }, []); const [classificationEnabled, setClassificationEnabled] = useState(false); const [selectedClassificationId, setSelectedClassificationId] = useState(''); const [bannerText, setBannerText] = useState(''); @@ -196,6 +232,12 @@ const NewChannelModal = () => { }, []); const handleOnModalConfirm = async () => { + if (createdChannel) { + handleOnModalCancel(); + dispatch(switchToChannel(createdChannel)); + return; + } + if (!canCreate || !currentTeamId) { return; } @@ -244,16 +286,38 @@ const NewChannelModal = () => { return; } - if (classificationEnabled && selectedClassificationId && classification.channelField && bannerText) { + // Values are written after the channel exists, so a failure here + // leaves a real channel with missing markings. The channel is + // deliberately kept — deleting it would lose the user's other + // input — but the failure has to be visible and name what was + // not saved, so nobody walks away believing it was. + const items = [ + ...(classificationEnabled && selectedClassificationId && classification.channelField && bannerText ? [{ + field_id: classification.channelField.id, + value: selectedClassificationId, + }] : []), + ...Object.entries(attributeValues).map(([fieldId, value]) => ({field_id: fieldId, value})), + ]; + + if (items.length > 0) { try { await Client4.patchPropertyValues( - CLASSIFICATIONS_GROUP_NAME, - CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + ACCESS_CONTROL_PROPERTY_GROUP, + CHANNEL_OBJECT_TYPE, newChannel!.id, - [{field_id: classification.channelField.id, value: selectedClassificationId}], + items, ); } catch { - // Classification save failure should not block channel creation + const names = items. + map(({field_id: fieldId}) => attributeDisplayName(fieldId)). + filter(Boolean). + join(', '); + setAttributeError(formatMessage({ + id: 'channel_modal.attributes.save_failed', + defaultMessage: 'The channel was created, but these attributes were not saved: {names}. An administrator can set them later.', + }, {names})); + setCreatedChannel(newChannel!); + return; } } @@ -412,7 +476,17 @@ const NewChannelModal = () => { const hasValidType = isBuiltInType(type) || Boolean(activePluginOption); const pluginCreateGate = isBuiltInType(type) ? canCreateFromPluggable : pluginCanCreate; const classificationValid = !classificationEnabled || (Boolean(selectedClassificationId) && bannerText.trim().length > 0); - const canCreate = displayName && !urlError && hasValidType && !purposeError && !serverError && pluginCreateGate && !channelInputError && classificationValid && !isSubmitting; + + const hasCompleteForm = Boolean(displayName) && hasValidType && classificationValid; + const hasNoErrors = !urlError && !purposeError && !serverError && !channelInputError; + + // Attribute definitions gate submission: until they load the form is missing + // controls the user may still need to fill in. + const canSubmit = pluginCreateGate && !isSubmitting && !channelAttributes.loading; + + // Once the channel exists but its attributes failed to save, the only action + // left is to acknowledge and go to it — creating again would collide on the URL. + const canCreate = Boolean(createdChannel) || (hasCompleteForm && hasNoErrors && canSubmit); const pluginOptions = useMemo(() => availableOptions.map((o) => ({ id: o.id, @@ -456,11 +530,16 @@ const NewChannelModal = () => { ); - const confirmButtonText = isSubmitting ? ( - - ) : (activePluginOption?.createButtonText ?? formatMessage({id: 'channel_modal.createNew', defaultMessage: 'Create channel'})); + let confirmButtonText: React.ReactNode = activePluginOption?.createButtonText ?? formatMessage({id: 'channel_modal.createNew', defaultMessage: 'Create channel'}); + if (isSubmitting) { + confirmButtonText = ( + + ); + } else if (createdChannel) { + confirmButtonText = formatMessage({id: 'channel_modal.goToChannel', defaultMessage: 'Go to channel'}); + } return ( { modalHeaderText={formatMessage({id: 'channel_modal.modalTitle', defaultMessage: 'Create a new channel'})} confirmButtonText={confirmButtonText} cancelButtonText={formatMessage({id: 'channel_modal.cancel', defaultMessage: 'Cancel'})} - errorText={serverError} + errorText={serverError || attributeError} isConfirmDisabled={!canCreate} autoCloseOnConfirmButton={false} compassDesign={true} @@ -677,6 +756,14 @@ const NewChannelModal = () => { )} )} + {isBuiltInType(type) && ( + + )} {activePluginOption?.extraContent && ( = {}): PropertyField { @@ -426,3 +431,198 @@ describe('Group selectors', () => { }); }); }); + +const GROUP_ID = 'group_access_control'; +const CHANNEL_ID = 'channel1'; + +function attrField(overrides: Partial & {id: string}): PropertyField { + return { + group_id: GROUP_ID, + name: overrides.id, + type: 'select', + target_id: '', + target_type: 'system', + object_type: 'channel', + create_at: 1, + update_at: 1, + delete_at: 0, + created_by: '', + updated_by: '', + ...overrides, + }; +} + +function attrValue(fieldId: string, raw: unknown, targetId = CHANNEL_ID): PropertyValue { + return { + id: `value_${fieldId}`, + target_id: targetId, + target_type: 'channel', + group_id: GROUP_ID, + field_id: fieldId, + value: raw, + create_at: 1, + update_at: 1, + delete_at: 0, + created_by: '', + updated_by: '', + }; +} + +function makeAttrState(fields: PropertyField[], values: Array> = [], groupLoaded = true): GlobalState { + const byTargetId: Record>> = {}; + for (const v of values) { + byTargetId[v.target_id] = {...byTargetId[v.target_id], [v.field_id]: v}; + } + + return deepFreeze({ + entities: { + properties: { + groups: groupLoaded ? { + byId: {[GROUP_ID]: {id: GROUP_ID, name: 'access_control'}}, + byName: {access_control: {id: GROUP_ID, name: 'access_control'}}, + } : {byId: {}, byName: {}}, + fields: { + byId: Object.fromEntries(fields.map((f) => [f.id, f])), + byObjectType: { + channel: { + [GROUP_ID]: Object.fromEntries(fields.map((f) => [f.id, f])), + }, + }, + }, + values: {byTargetId, byFieldId: {}}, + }, + }, + }) as unknown as GlobalState; +} + +describe('getChannelAttributeFields', () => { + // Reachable in practice, not just in theory: a websocket field event populates + // byObjectType on its own, and only fetchPropertyFields carries the group name, + // so the fields can be in the store with nothing able to read them. That is why + // useChannelAttributes tracks the fetch outcome instead of inferring it here. + test('returns nothing while the group name has not resolved to an id', () => { + const state = makeAttrState([attrField({id: 'a'})], [], false); + expect(getChannelAttributeFields(state)).toEqual([]); + }); + + test('orders by sort_order, falling back to create_at', () => { + const state = makeAttrState([ + attrField({id: 'third', attrs: {sort_order: 30}}), + attrField({id: 'first', attrs: {sort_order: 10}}), + attrField({id: 'unranked_older', create_at: 5}), + attrField({id: 'second', attrs: {sort_order: 20}}), + attrField({id: 'unranked_newer', create_at: 9}), + ]); + + expect(getChannelAttributeFields(state).map((f) => f.id)).toEqual([ + 'first', 'second', 'third', 'unranked_older', 'unranked_newer', + ]); + }); + + test('omits a deleted field, which must not be offered for assignment', () => { + const state = makeAttrState([ + attrField({id: 'live'}), + attrField({id: 'gone', delete_at: 12345}), + ]); + + expect(getChannelAttributeFields(state).map((f) => f.id)).toEqual(['live']); + }); +}); + +describe('getChannelLabelFields', () => { + test('keeps only fields designated for a label surface', () => { + const state = makeAttrState([ + attrField({id: 'header', attrs: {actions: ['display_label_header']}}), + attrField({id: 'info', attrs: {actions: ['display_label_info']}}), + attrField({id: 'banner_only', attrs: {actions: ['display_banner_top']}}), + attrField({id: 'no_actions'}), + ]); + + expect(getChannelLabelFields(state).map((f) => f.id)).toEqual(['header', 'info']); + }); +}); + +describe('makeGetResolvedChannelAttributes', () => { + const options = [{id: 'opt_a', name: 'AURORA'}, {id: 'opt_b', name: 'NOFORN'}]; + + let getResolvedChannelAttributes: ReturnType; + + beforeEach(() => { + getResolvedChannelAttributes = makeGetResolvedChannelAttributes(); + }); + + test('resolves a select value to its option name', () => { + const state = makeAttrState([attrField({id: 'program', attrs: {options}})], [attrValue('program', 'opt_a')]); + + const [resolved] = getResolvedChannelAttributes(state, CHANNEL_ID); + expect(resolved.option?.name).toBe('AURORA'); + expect(resolved.displayValue).toBe('AURORA'); + }); + + test('treats null and empty string as unset', () => { + const state = makeAttrState( + [attrField({id: 'program', attrs: {options}}), attrField({id: 'note', type: 'text'})], + [attrValue('program', null), attrValue('note', '')], + ); + + expect(getResolvedChannelAttributes(state, CHANNEL_ID).map((a) => a.displayValue)).toEqual(['', '']); + }); + + test('includes fields with no value at all, as unset', () => { + const state = makeAttrState([attrField({id: 'program', attrs: {options}})]); + + const [resolved] = getResolvedChannelAttributes(state, CHANNEL_ID); + expect(resolved.value).toBeUndefined(); + expect(resolved.displayValue).toBe(''); + }); + + test('joins multiselect values in option order of the stored array', () => { + const state = makeAttrState([attrField({id: 'caveats', type: 'multiselect', attrs: {options}})], [attrValue('caveats', ['opt_b', 'opt_a'])]); + + expect(getResolvedChannelAttributes(state, CHANNEL_ID)[0].displayValue).toBe('NOFORN, AURORA'); + }); + + test('falls back to the raw value when the option no longer exists', () => { + // A deleted option would otherwise silently drop the marking. Showing the + // raw id is wrong but visible, which is the safer failure here. + const state = makeAttrState([attrField({id: 'program', attrs: {options}})], [attrValue('program', 'opt_deleted')]); + + const [resolved] = getResolvedChannelAttributes(state, CHANNEL_ID); + expect(resolved.option).toBeUndefined(); + expect(resolved.displayValue).toBe('opt_deleted'); + }); + + test('multiselect falls back to the raw ID when an option no longer exists', () => { + const state = makeAttrState([attrField({id: 'caveats', type: 'multiselect', attrs: {options}})], [attrValue('caveats', ['opt_a', 'opt_deleted'])]); + + expect(getResolvedChannelAttributes(state, CHANNEL_ID)[0].displayValue).toBe('AURORA, opt_deleted'); + }); + + test('text fields display their stored string directly', () => { + const state = makeAttrState([attrField({id: 'note', type: 'text'})], [attrValue('note', 'handle with care')]); + + expect(getResolvedChannelAttributes(state, CHANNEL_ID)[0].displayValue).toBe('handle with care'); + }); + + test('does not leak another channel value into this channel', () => { + const state = makeAttrState([attrField({id: 'program', attrs: {options}})], [attrValue('program', 'opt_a', 'other_channel')]); + + expect(getResolvedChannelAttributes(state, CHANNEL_ID)[0].displayValue).toBe(''); + }); + + test('each instance memoizes its own channel, so two channels do not evict each other', () => { + const state = makeAttrState( + [attrField({id: 'program', attrs: {options}})], + [attrValue('program', 'opt_a'), attrValue('program', 'opt_b', 'other_channel')], + ); + + const forThis = makeGetResolvedChannelAttributes(); + const forOther = makeGetResolvedChannelAttributes(); + + const first = forThis(state, CHANNEL_ID); + forOther(state, 'other_channel'); + + expect(forThis(state, CHANNEL_ID)).toBe(first); + expect(forOther(state, 'other_channel')[0].displayValue).toBe('NOFORN'); + }); +}); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/properties.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/properties.ts index ece4e9caf74e..eb28d3ac8a39 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/properties.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/properties.ts @@ -1,9 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {PropertyField, PropertyGroup, PropertyValue} from '@mattermost/types/properties'; +import type {PropertyField, PropertyFieldOption, PropertyGroup, PropertyValue} from '@mattermost/types/properties'; import type {GlobalState} from '@mattermost/types/store'; +import { + ACCESS_CONTROL_PROPERTY_GROUP, + CHANNEL_OBJECT_TYPE, + DISPLAY_LABEL_HEADER, + DISPLAY_LABEL_INFO, +} from 'mattermost-redux/constants/properties'; import {createSelector} from 'mattermost-redux/selectors/create_selector'; // Field selectors @@ -101,3 +107,128 @@ export const getPropertyValuesForField = createSelector( return Object.values(fieldValues); }, ); + +// Channel attribute selectors + +const EMPTY_FIELDS: PropertyField[] = []; + +function sortByFieldOrder(fields: PropertyField[]): PropertyField[] { + return [...fields].sort((a, b) => { + const rankA = typeof a.attrs?.sort_order === 'number' ? a.attrs.sort_order : Number.MAX_SAFE_INTEGER; + const rankB = typeof b.attrs?.sort_order === 'number' ? b.attrs.sort_order : Number.MAX_SAFE_INTEGER; + if (rankA !== rankB) { + return rankA - rankB; + } + return a.create_at - b.create_at; + }); +} + +/** + * Channel-object fields in the access_control group, ordered by attrs.sort_order. + * + * Fields are stored under the group's UUID, so the group has to be resolved by + * name first. That mapping only exists once something has fetched fields for the + * group, hence the empty result rather than a throw while it is still loading. + */ +export const getChannelAttributeFields: (state: GlobalState) => PropertyField[] = createSelector( + 'getChannelAttributeFields', + (state: GlobalState) => getPropertyGroupByName(state, ACCESS_CONTROL_PROPERTY_GROUP)?.id, + (state: GlobalState) => state.entities.properties.fields.byObjectType[CHANNEL_OBJECT_TYPE], + (groupId, byGroup) => { + if (!groupId) { + return EMPTY_FIELDS; + } + const fields = byGroup?.[groupId]; + if (!fields) { + return EMPTY_FIELDS; + } + const live = Object.values(fields).filter((field) => field.delete_at === 0); + return live.length === 0 ? EMPTY_FIELDS : sortByFieldOrder(live); + }, +); + +/** + * The subset designated for display as a channel label. Designation lives on the + * field and is system-wide, so this takes no channel. + */ +export const getChannelLabelFields: (state: GlobalState) => PropertyField[] = createSelector( + 'getChannelLabelFields', + getChannelAttributeFields, + (fields) => { + const labels = fields.filter((field) => { + const actions = field.attrs?.actions; + return Array.isArray(actions) && actions.some((action) => action === DISPLAY_LABEL_HEADER || action === DISPLAY_LABEL_INFO); + }); + return labels.length === 0 ? EMPTY_FIELDS : labels; + }, +); + +export type ResolvedChannelAttribute = { + field: PropertyField; + value?: PropertyValue; + + // Resolved option for select-shaped fields, absent for text fields or when + // the stored option id no longer exists on the field. + option?: PropertyFieldOption; + + // Display string, empty when the attribute is unset. A null or empty stored + // value counts as unset: the server keeps a null-valued row after a + // user-initiated clear rather than deleting it. + displayValue: string; +}; + +const EMPTY_RESOLVED: ResolvedChannelAttribute[] = []; + +function resolveDisplayValue(field: PropertyField, raw: unknown): {option?: PropertyFieldOption; displayValue: string} { + if (raw === null || raw === undefined || raw === '') { + return {displayValue: ''}; + } + + const options = (field.attrs?.options as PropertyFieldOption[] | undefined) ?? []; + + if (Array.isArray(raw)) { + const names = raw. + map((id) => options.find((option) => option.id === id)?.name ?? String(id)); + return {displayValue: names.join(', ')}; + } + + if (typeof raw !== 'string') { + return {displayValue: String(raw)}; + } + + const option = options.find((candidate) => candidate.id === raw); + if (option) { + return {option, displayValue: option.name}; + } + + // Text fields store the display string directly. A select field whose option + // was deleted lands here too and renders the raw id, which is wrong but + // visible — better than silently dropping a marking. + return {displayValue: raw}; +} + +/** + * Every channel attribute paired with this channel's value, in display order. + * Fields with no value are included with an empty displayValue so callers can + * choose whether to render them. + * + * A factory because the result depends on channelId and the memoizer only keeps + * the last arguments: a shared instance would recompute — and return a new + * array — every time two channels alternate. One instance per consumer. + */ +export function makeGetResolvedChannelAttributes(): (state: GlobalState, channelId: string) => ResolvedChannelAttribute[] { + return createSelector( + 'makeGetResolvedChannelAttributes', + getChannelAttributeFields, + (state: GlobalState, channelId: string) => state.entities.properties.values.byTargetId[channelId], + (fields, valuesByFieldId) => { + if (fields.length === 0) { + return EMPTY_RESOLVED; + } + return fields.map((field) => { + const value = valuesByFieldId?.[field.id]; + return {field, value, ...resolveDisplayValue(field, value?.value)}; + }); + }, + ); +} From c059bd33eaf5f69397ae56fab8a0bd58e0b46101 Mon Sep 17 00:00:00 2001 From: Eva Sarafianou Date: Mon, 31 Aug 2026 15:37:12 +0300 Subject: [PATCH 4/5] docs: give specialized personas a distinct visual tier on the homepage (#38205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: give specialized personas a distinct visual tier on the homepage The SRE, compliance officer, and air-gapped operator links were plain inline text under the four primary persona cards, so they were easy to miss. Add a compact CardGrid variant that renders them as low-weight chips — same surface and hover affordance as the full cards at roughly half the height, so they read as a second tier rather than competing with the primary grid. Co-authored-by: Cursor * docs: collapse compact CardGrid chips to a list in the PDF build CSS module class names hash to `chip_*`, so the existing `[class*="card"]` print rules don't reach the compact variant and it would keep its web grid layout on paper. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- docs/main/index.mdx | 9 +- docs/pdf/styles/print.css | 31 ++++++- docs/site/src/components/CardGrid/index.tsx | 37 ++++++++- .../src/components/CardGrid/styles.module.css | 82 +++++++++++++++++++ 4 files changed, 152 insertions(+), 7 deletions(-) diff --git a/docs/main/index.mdx b/docs/main/index.mdx index c358d6f8d295..a6e82f2690c7 100644 --- a/docs/main/index.mdx +++ b/docs/main/index.mdx @@ -19,9 +19,12 @@ The secure, self-hosted collaboration platform for defense, intelligence, securi ]} /> Or jump to one of the specialized personas: -[SREs / Platform Operators](/for/sre) · -[Compliance Officers](/for/compliance-officer) · -[Air-Gapped Operators](/for/air-gapped-operator) + + ## The Intelligent Mission Environment (IME) diff --git a/docs/pdf/styles/print.css b/docs/pdf/styles/print.css index 2c59e16cac56..82eba5cb5ffb 100644 --- a/docs/pdf/styles/print.css +++ b/docs/pdf/styles/print.css @@ -5,7 +5,7 @@ * page.addStyleTag({ path: 'pdf/styles/print.css' }) * before printing. * - * Why this lives here instead of docs-site/src/css: + * Why this lives here instead of docs/site/src/css: * The docs site is media-screen first; print is a publishing artifact * produced by a separate pipeline. Keeping the print CSS next to the * PDF builder makes the dependency obvious and lets us iterate on @@ -13,7 +13,7 @@ * * If you need to move it back into the docs site (e.g., to enable a * "Download PDF" button that does in-browser print-preview), drop it - * into docs-site/src/css/ and add a `` reference. */ @@ -334,6 +334,33 @@ tbody tr { page-break-inside: avoid; } [class*="card"] [class*="arrow"] { display: none !important; } [class*="card"] [class*="icon"] { display: none !important; } +/* CardGrid compact variant — hashed class names are `chip_*`, so the + * `[class*="card"]` rules above don't reach it. Collapse to the same + * list, one weight down. */ +[class*="chipRow"] { + display: block !important; +} +[class*="chip_"] { + display: block !important; + margin: 3pt 0 !important; + padding: 4pt 8pt !important; + border: 0.5pt solid #D6DDEC !important; + border-radius: 2pt !important; + background: #FFFFFF !important; + box-shadow: none !important; + page-break-inside: avoid; +} +[class*="chipTitle"] { + font-family: 'Archivo Black', system-ui, sans-serif; + font-size: 9.5pt; + color: #1E325C !important; +} +[class*="chipDescription"] { + font-size: 8.5pt; + color: #1B1D22 !important; +} +[class*="chipArrow"] { display: none !important; } + /* ============================================================ * OpenAPI plugin — print-specific overrides * ========================================================== */ diff --git a/docs/site/src/components/CardGrid/index.tsx b/docs/site/src/components/CardGrid/index.tsx index 70c28d6972b9..e5ed3bfc5261 100644 --- a/docs/site/src/components/CardGrid/index.tsx +++ b/docs/site/src/components/CardGrid/index.tsx @@ -4,7 +4,7 @@ import styles from './styles.module.css'; type Card = { title: string; - description: string; + description?: string; to: string; icon?: string; // CompassIcon name meta?: string; // small annotation (e.g., "549 endpoints") @@ -15,6 +15,10 @@ type Card = { * of links — each card is a discrete navigation surface with title, * description, and optional Compass icon + meta. * + * The `compact` variant renders the same cards as a wrapping row of + * low-weight chips, for secondary destinations that need to stay + * scannable without competing with a primary card grid above them. + * * Usage: * + * + * */ -export default function CardGrid({cards, columns = 3}: {cards: Card[]; columns?: 2 | 3 | 4}) { +export default function CardGrid({ + cards, + columns = 3, + variant = 'default', +}: { + cards: Card[]; + columns?: 2 | 3 | 4; + variant?: 'default' | 'compact'; +}) { + if (variant === 'compact') { + return ( +
+ {cards.map((c, i) => ( + + + {c.title} + {c.description && {c.description}} + + + + ))} +
+ ); + } + return (
{cards.map((c, i) => ( diff --git a/docs/site/src/components/CardGrid/styles.module.css b/docs/site/src/components/CardGrid/styles.module.css index 016e0fadc6d0..cadaa85ddd1d 100644 --- a/docs/site/src/components/CardGrid/styles.module.css +++ b/docs/site/src/components/CardGrid/styles.module.css @@ -144,3 +144,85 @@ background: rgba(255, 255, 255, 0.08); color: var(--mm-color-white); } + +/* Compact variant — secondary destinations that sit under a primary + * card grid. Same affordances (surface, hover, arrow) at roughly half + * the visual weight, so they read as a second tier rather than a + * competing one. */ +.chipRow { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 0.6rem; + margin: 0.75rem 0 2.5rem; +} + +.chip { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.85rem; + background: var(--mm-bg-surface); + border: 1px solid var(--mm-border-subtle); + border-radius: 6px; + text-decoration: none; + color: inherit; + transition: background 0.15s ease, border-color 0.15s ease; +} +.chip:hover { + background: var(--mm-bg-subtle); + border-color: var(--mm-border-strong); + text-decoration: none; + color: inherit; +} +.chip:focus-visible { + outline: 2px solid var(--mm-color-denim); + outline-offset: 2px; +} + +[data-theme='dark'] .chip { + background: rgba(255, 255, 255, 0.035); + border-color: rgba(255, 255, 255, 0.14); +} +[data-theme='dark'] .chip:hover { + background: rgba(255, 255, 255, 0.06); + border-color: var(--mm-denim-300); +} +[data-theme='dark'] .chip:focus-visible { + outline-color: var(--mm-denim-300, #8499C5); +} + +.chipBody { + flex: 1 1 auto; + min-width: 0; +} + +.chipTitle { + display: block; + font-family: var(--mm-font-sans); + font-weight: 700; + font-size: 0.92rem; + letter-spacing: -0.01em; + color: var(--mm-text-primary); +} + +.chipDescription { + display: block; + font-size: 0.8rem; + line-height: 1.4; + color: var(--mm-text-secondary); +} + +.chipArrow { + flex: 0 0 auto; + color: var(--mm-text-secondary); + font-size: 0.85rem; + transition: transform 0.15s ease, color 0.15s ease; +} +.chip:hover .chipArrow { + color: var(--mm-color-denim); + transform: translateX(3px); +} + +[data-theme='dark'] .chip:hover .chipArrow { + color: var(--mm-color-white); +} From 0d1ce90d72122d3047345c022bf236d5c0f6a13e Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:08:06 -0400 Subject: [PATCH 5/5] [MM-70250] Remove unused GET bulk reactions endpoint (#38174) Co-authored-by: Mattermost Build --- api/v4/source/definitions.yaml | 6 -- api/v4/source/reactions.yaml | 34 -------- server/channels/api4/reaction.go | 29 ------- server/channels/api4/reaction_test.go | 80 ------------------- server/channels/app/reaction.go | 28 ------- .../channels/store/retrylayer/retrylayer.go | 21 ----- .../channels/store/sqlstore/reaction_store.go | 25 ------ server/channels/store/store.go | 1 - .../store/storetest/mocks/ReactionStore.go | 30 ------- .../store/storetest/reaction_store.go | 78 ------------------ .../channels/store/timerlayer/timerlayer.go | 16 ---- server/i18n/en.json | 4 - server/public/model/client4.go | 10 --- .../apiAuditLogs/whitelist.go | 1 - 14 files changed, 363 deletions(-) diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index 6f2c86a699ec..5ec8b339d4e8 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -3472,12 +3472,6 @@ components: description: The time in milliseconds that this action was performed. type: integer format: int64 - PostIdToReactionsMap: - type: object - additionalProperties: - type: array - items: - $ref: "#/components/schemas/Reaction" Product: type: object properties: diff --git a/api/v4/source/reactions.yaml b/api/v4/source/reactions.yaml index f852ad34ac61..3a47be6f72fa 100644 --- a/api/v4/source/reactions.yaml +++ b/api/v4/source/reactions.yaml @@ -101,37 +101,3 @@ $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" - /api/v4/posts/ids/reactions: - post: - tags: - - reactions - summary: Bulk get the reaction for posts - description: | - Get a list of reactions made by all users to a given post. - ##### Permissions - Must have `read_channel` permission for the channel the post is in. - - __Minimum server version__: 5.8 - operationId: GetBulkReactions - requestBody: - content: - application/json: - schema: - type: array - items: - type: string - description: Array of post IDs - required: true - responses: - "200": - description: Reactions retrieval successful - content: - application/json: - schema: - $ref: "#/components/schemas/PostIdToReactionsMap" - "400": - $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" diff --git a/server/channels/api4/reaction.go b/server/channels/api4/reaction.go index e3cf38bd891e..05458837a86e 100644 --- a/server/channels/api4/reaction.go +++ b/server/channels/api4/reaction.go @@ -16,7 +16,6 @@ func (api *API) InitReaction() { api.BaseRoutes.Reactions.Handle("", api.APISessionRequired(saveReaction)).Methods(http.MethodPost) api.BaseRoutes.Post.Handle("/reactions", api.APISessionRequired(getReactions)).Methods(http.MethodGet) api.BaseRoutes.ReactionByNameForPostForUser.Handle("", api.APISessionRequired(deleteReaction)).Methods(http.MethodDelete) - api.BaseRoutes.Posts.Handle("/ids/reactions", api.APISessionRequired(getBulkReactions)).Methods(http.MethodPost) } func saveReaction(c *Context, w http.ResponseWriter, r *http.Request) { @@ -114,31 +113,3 @@ func deleteReaction(c *Context, w http.ResponseWriter, r *http.Request) { ReturnStatusOK(w) } - -func getBulkReactions(c *Context, w http.ResponseWriter, r *http.Request) { - postIds, err := model.SortedArrayFromJSON(r.Body) - if err != nil { - c.Err = model.NewAppError("getBulkReactions", model.PayloadParseError, nil, "", http.StatusBadRequest).Wrap(err) - return - } - for _, postId := range postIds { - if ok, _ := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), postId); !ok { - c.SetPermissionError(model.PermissionReadChannelContent) - return - } - } - reactions, appErr := c.App.GetBulkReactionsForPosts(postIds) - if appErr != nil { - c.Err = appErr - return - } - - js, err := json.Marshal(reactions) - if err != nil { - c.Err = model.NewAppError("getBulkReactions", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - if _, err := w.Write(js); err != nil { - c.Logger.Warn("Error while writing js response", mlog.Err(err)) - } -} diff --git a/server/channels/api4/reaction_test.go b/server/channels/api4/reaction_test.go index 146dfc754da6..833e9dd6b3a9 100644 --- a/server/channels/api4/reaction_test.go +++ b/server/channels/api4/reaction_test.go @@ -517,83 +517,3 @@ func TestDeleteReaction(t *testing.T) { require.Equal(t, 1, len(reactions), "should have not deleted a reaction") }) } - -func TestGetBulkReactions(t *testing.T) { - mainHelper.Parallel(t) - th := Setup(t).InitBasic(t) - client := th.Client - userId := th.BasicUser.Id - user2Id := th.BasicUser2.Id - post1 := &model.Post{UserId: userId, ChannelId: th.BasicChannel.Id, Message: "zz" + model.NewId() + "a"} - post2 := &model.Post{UserId: userId, ChannelId: th.BasicChannel.Id, Message: "zz" + model.NewId() + "a"} - post3 := &model.Post{UserId: userId, ChannelId: th.BasicChannel.Id, Message: "zz" + model.NewId() + "a"} - - post4 := &model.Post{UserId: user2Id, ChannelId: th.BasicChannel.Id, Message: "zz" + model.NewId() + "a"} - post5 := &model.Post{UserId: user2Id, ChannelId: th.BasicChannel.Id, Message: "zz" + model.NewId() + "a"} - - post1, _, _ = client.CreatePost(context.Background(), post1) - post2, _, _ = client.CreatePost(context.Background(), post2) - post3, _, _ = client.CreatePost(context.Background(), post3) - post4, _, _ = client.CreatePost(context.Background(), post4) - post5, _, _ = client.CreatePost(context.Background(), post5) - - expectedPostIdsReactionsMap := make(map[string][]*model.Reaction) - expectedPostIdsReactionsMap[post1.Id] = []*model.Reaction{} - expectedPostIdsReactionsMap[post2.Id] = []*model.Reaction{} - expectedPostIdsReactionsMap[post3.Id] = []*model.Reaction{} - expectedPostIdsReactionsMap[post5.Id] = []*model.Reaction{} - - userReactions := []*model.Reaction{ - { - UserId: userId, - PostId: post1.Id, - EmojiName: "happy", - }, - { - UserId: userId, - PostId: post1.Id, - EmojiName: "sad", - }, - { - UserId: userId, - PostId: post2.Id, - EmojiName: "smile", - }, - { - UserId: user2Id, - PostId: post4.Id, - EmojiName: "smile", - }, - } - - for _, userReaction := range userReactions { - reactions := expectedPostIdsReactionsMap[userReaction.PostId] - reaction, err := th.App.Srv().Store().Reaction().Save(userReaction) - require.NoError(t, err) - reactions = append(reactions, reaction) - expectedPostIdsReactionsMap[userReaction.PostId] = reactions - } - - postIds := []string{post1.Id, post2.Id, post3.Id, post4.Id, post5.Id} - - t.Run("get-reactions", func(t *testing.T) { - postIdsReactionsMap, _, err := client.GetBulkReactions(context.Background(), postIds) - require.NoError(t, err) - - assert.ElementsMatch(t, expectedPostIdsReactionsMap[post1.Id], postIdsReactionsMap[post1.Id]) - assert.ElementsMatch(t, expectedPostIdsReactionsMap[post2.Id], postIdsReactionsMap[post2.Id]) - assert.ElementsMatch(t, expectedPostIdsReactionsMap[post3.Id], postIdsReactionsMap[post3.Id]) - assert.ElementsMatch(t, expectedPostIdsReactionsMap[post4.Id], postIdsReactionsMap[post4.Id]) - assert.ElementsMatch(t, expectedPostIdsReactionsMap[post5.Id], postIdsReactionsMap[post5.Id]) - assert.Equal(t, expectedPostIdsReactionsMap, postIdsReactionsMap) - }) - - t.Run("get-reactions-as-anonymous-user", func(t *testing.T) { - _, err := client.Logout(context.Background()) - require.NoError(t, err) - - _, resp, err := client.GetBulkReactions(context.Background(), postIds) - require.Error(t, err) - CheckUnauthorizedStatus(t, resp) - }) -} diff --git a/server/channels/app/reaction.go b/server/channels/app/reaction.go index ad471b4beaa8..0aafbe4f4c85 100644 --- a/server/channels/app/reaction.go +++ b/server/channels/app/reaction.go @@ -126,34 +126,6 @@ func (a *App) GetReactionsForPost(postID string) ([]*model.Reaction, *model.AppE return reactions, nil } -func (a *App) GetBulkReactionsForPosts(postIDs []string) (map[string][]*model.Reaction, *model.AppError) { - reactions := make(map[string][]*model.Reaction) - - allReactions, err := a.Srv().Store().Reaction().BulkGetForPosts(postIDs) - if err != nil { - return nil, model.NewAppError("GetBulkReactionsForPosts", "app.reaction.bulk_get_for_post_ids.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - } - - for _, reaction := range allReactions { - reactionsForPost := reactions[reaction.PostId] - reactionsForPost = append(reactionsForPost, reaction) - - reactions[reaction.PostId] = reactionsForPost - } - - reactions = populateEmptyReactions(postIDs, reactions) - return reactions, nil -} - -func populateEmptyReactions(postIDs []string, reactions map[string][]*model.Reaction) map[string][]*model.Reaction { - for _, postID := range postIDs { - if _, present := reactions[postID]; !present { - reactions[postID] = []*model.Reaction{} - } - } - return reactions -} - func (a *App) DeleteReactionForPost(rctx request.CTX, reaction *model.Reaction) *model.AppError { reaction.EmojiName = strings.ToLower(reaction.EmojiName) post, err := a.GetSinglePost(rctx, reaction.PostId, false) diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 5d019a3e522a..f44f21312a10 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -11109,27 +11109,6 @@ func (s *RetryLayerPropertyValueStore) Upsert(values []*model.PropertyValue) ([] } -func (s *RetryLayerReactionStore) BulkGetForPosts(postIds []string) ([]*model.Reaction, error) { - - tries := 0 - for { - result, err := s.ReactionStore.BulkGetForPosts(postIds) - if err == nil { - return result, nil - } - if !isRepeatableError(err) { - return result, err - } - tries++ - if tries >= 3 { - err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") - return result, err - } - timepkg.Sleep(100 * timepkg.Millisecond) - } - -} - func (s *RetryLayerReactionStore) Delete(reaction *model.Reaction) (*model.Reaction, error) { tries := 0 diff --git a/server/channels/store/sqlstore/reaction_store.go b/server/channels/store/sqlstore/reaction_store.go index d2e3e1f3b06b..8ef989f6031c 100644 --- a/server/channels/store/sqlstore/reaction_store.go +++ b/server/channels/store/sqlstore/reaction_store.go @@ -162,31 +162,6 @@ func (s *SqlReactionStore) GetUniqueCountForPost(postId string) (int, error) { return int(count), nil } -func (s *SqlReactionStore) BulkGetForPosts(postIds []string) ([]*model.Reaction, error) { - placeholder, values := constructArrayArgs(postIds) - var reactions []*model.Reaction - - if err := s.GetReplica().Select(&reactions, - `SELECT - UserId, - PostId, - EmojiName, - CreateAt, - COALESCE(UpdateAt, CreateAt) As UpdateAt, - COALESCE(DeleteAt, 0) As DeleteAt, - RemoteId, - ChannelId - FROM - Reactions - WHERE - PostId IN `+placeholder+` AND COALESCE(DeleteAt, 0) = 0 - ORDER BY - CreateAt`, values...); err != nil { - return nil, errors.Wrap(err, "failed to get Reactions") - } - return reactions, nil -} - func (s *SqlReactionStore) GetSingle(userID, postID, remoteID, emojiName string) (*model.Reaction, error) { query := s.getQueryBuilder(). Select("UserId", "PostId", "EmojiName", "CreateAt", diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 17f22dfc935b..2c233f0625b3 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -816,7 +816,6 @@ type ReactionStore interface { GetUniqueCountForPost(postID string) (int, error) ExistsOnPost(postID string, emojiName string) (bool, error) DeleteAllWithEmojiName(rctx request.CTX, emojiName string) error - BulkGetForPosts(postIds []string) ([]*model.Reaction, error) GetSingle(userID, postID, remoteID, emojiName string) (*model.Reaction, error) DeleteOrphanedRowsByIds(r *model.RetentionIdsForDeletion) (int64, error) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) diff --git a/server/channels/store/storetest/mocks/ReactionStore.go b/server/channels/store/storetest/mocks/ReactionStore.go index ec99d895a404..6b53539b760a 100644 --- a/server/channels/store/storetest/mocks/ReactionStore.go +++ b/server/channels/store/storetest/mocks/ReactionStore.go @@ -15,36 +15,6 @@ type ReactionStore struct { mock.Mock } -// BulkGetForPosts provides a mock function with given fields: postIds -func (_m *ReactionStore) BulkGetForPosts(postIds []string) ([]*model.Reaction, error) { - ret := _m.Called(postIds) - - if len(ret) == 0 { - panic("no return value specified for BulkGetForPosts") - } - - var r0 []*model.Reaction - var r1 error - if rf, ok := ret.Get(0).(func([]string) ([]*model.Reaction, error)); ok { - return rf(postIds) - } - if rf, ok := ret.Get(0).(func([]string) []*model.Reaction); ok { - r0 = rf(postIds) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]*model.Reaction) - } - } - - if rf, ok := ret.Get(1).(func([]string) error); ok { - r1 = rf(postIds) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // Delete provides a mock function with given fields: reaction func (_m *ReactionStore) Delete(reaction *model.Reaction) (*model.Reaction, error) { ret := _m.Called(reaction) diff --git a/server/channels/store/storetest/reaction_store.go b/server/channels/store/storetest/reaction_store.go index 4c78fb0ae32d..171d1300d74c 100644 --- a/server/channels/store/storetest/reaction_store.go +++ b/server/channels/store/storetest/reaction_store.go @@ -26,7 +26,6 @@ func TestReactionStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStor t.Run("ReactionDeleteAllWithEmojiName", func(t *testing.T) { testReactionDeleteAllWithEmojiName(t, rctx, ss, s) }) t.Run("PermanentDeleteByUser", func(t *testing.T) { testPermanentDeleteByUser(t, rctx, ss) }) t.Run("PermanentDeleteBatch", func(t *testing.T) { testReactionStorePermanentDeleteBatch(t, rctx, ss) }) - t.Run("ReactionBulkGetForPosts", func(t *testing.T) { testReactionBulkGetForPosts(t, rctx, ss) }) t.Run("ReactionDeadlock", func(t *testing.T) { testReactionDeadlock(t, rctx, ss) }) t.Run("ExistsOnPost", func(t *testing.T) { testExistsOnPost(t, rctx, ss) }) t.Run("GetUniqueCountForPost", func(t *testing.T) { testGetUniqueCountForPost(t, rctx, ss) }) @@ -742,83 +741,6 @@ func testReactionStorePermanentDeleteBatch(t *testing.T, rctx request.CTX, ss st require.Len(t, returned, 1, "reactions for newer post should not have been deleted") } -func testReactionBulkGetForPosts(t *testing.T, rctx request.CTX, ss store.Store) { - userId := model.NewId() - post, _ := ss.Post().Save(rctx, &model.Post{ - ChannelId: model.NewId(), - UserId: userId, - }) - postId := post.Id - post, _ = ss.Post().Save(rctx, &model.Post{ - ChannelId: model.NewId(), - UserId: userId, - }) - post2Id := post.Id - post, _ = ss.Post().Save(rctx, &model.Post{ - ChannelId: model.NewId(), - UserId: userId, - }) - post3Id := post.Id - post, _ = ss.Post().Save(rctx, &model.Post{ - ChannelId: model.NewId(), - UserId: userId, - }) - post4Id := post.Id - - reactions := []*model.Reaction{ - { - UserId: userId, - PostId: postId, - EmojiName: "smile", - }, - { - UserId: model.NewId(), - PostId: post2Id, - EmojiName: "smile", - }, - { - UserId: userId, - PostId: post3Id, - EmojiName: "sad", - }, - { - UserId: userId, - PostId: postId, - EmojiName: "angry", - }, - { - UserId: userId, - PostId: post2Id, - EmojiName: "angry", - }, - { - UserId: userId, - PostId: post4Id, - EmojiName: "angry", - }, - } - - for _, reaction := range reactions { - _, err := ss.Reaction().Save(reaction) - require.NoError(t, err) - } - - postIds := []string{postId, post2Id, post3Id} - returned, err := ss.Reaction().BulkGetForPosts(postIds) - require.NoError(t, err) - require.Len(t, returned, 5, "should've returned 5 reactions") - - post4IdFound := false - for _, reaction := range returned { - if reaction.PostId == post4Id { - post4IdFound = true - break - } - } - - require.False(t, post4IdFound, "Wrong reaction returned") -} - // testReactionDeadlock is a best-case attempt to recreate the deadlock scenario. // It at least deadlocks 2 times out of 5. func testReactionDeadlock(t *testing.T, rctx request.CTX, ss store.Store) { diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index 47d9001668ae..bfaac90da924 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -8879,22 +8879,6 @@ func (s *TimerLayerPropertyValueStore) Upsert(values []*model.PropertyValue) ([] return result, err } -func (s *TimerLayerReactionStore) BulkGetForPosts(postIds []string) ([]*model.Reaction, error) { - start := time.Now() - - result, err := s.ReactionStore.BulkGetForPosts(postIds) - - elapsed := float64(time.Since(start)) / float64(time.Second) - if s.Root.Metrics != nil { - success := "false" - if err == nil { - success = "true" - } - s.Root.Metrics.ObserveStoreMethodDuration("ReactionStore.BulkGetForPosts", success, elapsed) - } - return result, err -} - func (s *TimerLayerReactionStore) Delete(reaction *model.Reaction) (*model.Reaction, error) { start := time.Now() diff --git a/server/i18n/en.json b/server/i18n/en.json index 67f2caee008e..ce29581ec103 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -8824,10 +8824,6 @@ "id": "app.property_value.validate.app_error", "translation": "Property value failed validation." }, - { - "id": "app.reaction.bulk_get_for_post_ids.app_error", - "translation": "Unable to get reactions for post." - }, { "id": "app.reaction.delete_all_with_emoji_name.get_reactions.app_error", "translation": "Unable to get all reactions with this emoji name." diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 9e91cc1c3cec..05ada3486985 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -6337,16 +6337,6 @@ func (c *Client4) DeleteReaction(ctx context.Context, reaction *Reaction) (*Resp return BuildResponse(r), nil } -// FetchBulkReactions returns a map of postIds and corresponding reactions -func (c *Client4) GetBulkReactions(ctx context.Context, postIds []string) (map[string][]*Reaction, *Response, error) { - r, err := c.doAPIPostJSON(ctx, c.postsRoute().Join("ids", "reactions"), postIds) - if err != nil { - return nil, BuildResponse(r), err - } - defer closeBody(r) - return DecodeJSONFromResponse[map[string][]*Reaction](r) -} - // Timezone Section // GetSupportedTimezone returns a page of supported timezones on the system. diff --git a/tools/mattermost-govet/apiAuditLogs/whitelist.go b/tools/mattermost-govet/apiAuditLogs/whitelist.go index 9ec9502a30a9..4a54fb548e92 100644 --- a/tools/mattermost-govet/apiAuditLogs/whitelist.go +++ b/tools/mattermost-govet/apiAuditLogs/whitelist.go @@ -22,7 +22,6 @@ var whiteList = map[string]bool{ "getBotIconImage": true, "getBots": true, "getBrandImage": true, - "getBulkReactions": true, "getChannel": true, "getChannelByName": true, "getChannelByNameForTeamName": true,