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/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/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', + ], + }, }, } : {}; 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); +} 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/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/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/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/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/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/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/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) 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, 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)}; + }); + }, + ); +}