diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml
index b5d512632e09..a4ff44c48304 100644
--- a/api/v4/source/definitions.yaml
+++ b/api/v4/source/definitions.yaml
@@ -243,9 +243,13 @@ components:
roles:
type: string
last_viewed_at:
- description: The time in milliseconds the channel was last viewed by the user
+ description: >-
+ The time in milliseconds the channel was last viewed by the user.
+ This field is omitted when the membership belongs to a user other
+ than the requester, as the value is private to that user.
type: integer
format: int64
+ nullable: true
msg_count:
type: integer
mention_count:
@@ -253,9 +257,13 @@ components:
notify_props:
$ref: "#/components/schemas/ChannelNotifyProps"
last_update_at:
- description: The time in milliseconds the channel member was last updated
+ description: >-
+ The time in milliseconds the channel member was last updated. This
+ field is omitted when the membership belongs to a user other than
+ the requester, as the value is private to that user.
type: integer
format: int64
+ nullable: true
ChannelMemberWithTeamData:
allOf:
- $ref: "#/components/schemas/ChannelMember"
diff --git a/docs/develop/integrate/plugins/interactive-dialogs/index.md b/docs/develop/integrate/plugins/interactive-dialogs/index.md
index 9b2e6288cb29..4eb8b09bcac8 100644
--- a/docs/develop/integrate/plugins/interactive-dialogs/index.md
+++ b/docs/develop/integrate/plugins/interactive-dialogs/index.md
@@ -436,8 +436,6 @@ The full list of supported fields for `date` elements is included below:
| `help_text` | String | (Optional) Help text displayed below the field. Maximum 150 characters. |
| `optional` | Boolean | (Optional) Set to `true` if this form element is not required. Default is `false`. |
| `datetime_config` | Object | (Optional) Nested date configuration object. See [datetime_config object](#datetime_config-object) for supported properties. |
-| `min_date` | String | (Deprecated — use `datetime_config.min_date`.) Earliest selectable date. Supports ISO date format (YYYY-MM-DD) or relative formats (`today`, `tomorrow`, `+1d`, `-7d`, etc.). Full ISO datetime strings are accepted, but only the date part is parsed; timezone information is ignored. |
-| `max_date` | String | (Deprecated — use `datetime_config.max_date`.) Latest selectable date. Supports ISO date format (YYYY-MM-DD) or relative formats (`today`, `+30d`, `+1y`, etc.). Full ISO datetime strings are accepted, but only the date part is parsed; timezone information is ignored. |
#### Date field usage examples
@@ -505,9 +503,6 @@ The full list of supported fields for `datetime` elements is included below:
| `help_text` | String | (Optional) Help text displayed below the field. Maximum 150 characters. |
| `optional` | Boolean | (Optional) Set to `true` if this form element is not required. Default is `false`. |
| `datetime_config` | Object | (Optional) Nested datetime configuration object. See [datetime_config object](#datetime_config-object) for supported properties. |
-| `min_date` | String | (Deprecated — use `datetime_config.min_date`.) Earliest selectable date. Supports ISO format or relative formats (`today`, `tomorrow`, `+1d`, `-7d`, etc.). |
-| `max_date` | String | (Deprecated — use `datetime_config.max_date`.) Latest selectable date. Supports ISO format or relative formats (`today`, `+30d`, `+1y`, etc.). |
-| `time_interval` | Integer | (Deprecated — use `datetime_config.time_interval`.) Time selection interval in minutes. Must be between 1 and 1440, and must be a divisor of 1440 to create evenly spaced intervals throughout the day. Common values: 15, 30, 60, 90, 120. Default is 60. |
#### DateTime field usage examples
@@ -570,9 +565,8 @@ The `datetime_config` object groups date/datetime configuration into a single ne
| `time_interval` | Integer | `datetime` | 11.6 | (Optional) Time selection interval in minutes. Must be between 1 and 1440, and must be a divisor of 1440. Default is 60. |
| `location_timezone` | String | `datetime` | 11.6 | (Optional) IANA timezone used to display and submit the time (e.g. `America/Denver`, `Asia/Tokyo`). When set, all users see the same wall-clock time regardless of their own timezone. Defaults to the viewing user's timezone. |
| `manual_time_entry` | Boolean | `datetime` | 11.8 | (Optional) When `true`, users can type the time directly in addition to using the dropdown. Default is `false`. |
-| `allow_manual_time_entry` | Boolean | `datetime` | 11.6 (deprecated in 11.8) | (Deprecated — use `manual_time_entry`.) When both are set, either enabling turns the feature on. |
-**Backward compatibility (new in 11.8):** The top-level `min_date`, `max_date`, and `time_interval` fields on `date` and `datetime` elements are still accepted for existing integrations, but are deprecated in favor of `datetime_config`. When both are provided on the same element, values inside `datetime_config` take precedence over the legacy top-level values.
+> **Breaking change (12.0):** The top-level `min_date`, `max_date`, and `time_interval` fields on `date`/`datetime` elements, and the `datetime_config.allow_manual_time_entry` field, have been removed. Integrations must send these values under `datetime_config` (using `manual_time_entry` instead of `allow_manual_time_entry`) or they will be silently ignored.
#### Date and DateTime field specifications
diff --git a/docs/main/administration-guide/configure/experimental-configuration-settings.mdx b/docs/main/administration-guide/configure/experimental-configuration-settings.mdx
index 7a92c5ec81e5..92cf91175377 100644
--- a/docs/main/administration-guide/configure/experimental-configuration-settings.mdx
+++ b/docs/main/administration-guide/configure/experimental-configuration-settings.mdx
@@ -361,46 +361,6 @@ Set a default theme that applies to all new users on the system.
-### Enable user typing messages
-
-This setting determines whether "user is typing..." messages are displayed below the message box when using Mattermost in a web browser or the desktop app.
-
-
-
-
-
-
-
-This feature's config.json setting is "EnableUserTypingMessages": true with options true and false. |
-
-
-
-
-
-
-Disabling this experimental configuration setting in larger deployments may improve server performance in the following areas:
-
-- Reduced Server Load: Typing events generate additional websocket traffic. Disabling them can reduce the amount of data that needs to be handled by the server, improving the overall response time and decreasing server load.
-- Lower Network Traffic: When typing events are enabled, every keystroke generates a network event. This can lead to a significant amount of network traffic, particularly in busy channels. Disabling these events reduces the amount of information transmitted over the network.
-- Client Performance: On the client side, processing typing events requires resources. By not having to handle these events, the client can be more responsive and use less memory and CPU.
-
-
-
-### User typing timeout
-
-This setting defines how frequently "user is typing..." messages are updated, measured in milliseconds.
-
-
-
-
-
-
-
-This feature's config.json setting is "TimeBetweenUserTypingUpdatesMilliseconds": 5000 with numerical input. |
-
-
-
-
### User's status and profile fetching poll interval
This setting configures the number of milliseconds to wait between fetching user statuses and profiles periodically. Set to `0` to disable.
diff --git a/docs/main/administration-guide/configure/site-configuration-settings.mdx b/docs/main/administration-guide/configure/site-configuration-settings.mdx
index 0a89cc5a5d23..3df838f38de4 100644
--- a/docs/main/administration-guide/configure/site-configuration-settings.mdx
+++ b/docs/main/administration-guide/configure/site-configuration-settings.mdx
@@ -1714,6 +1714,52 @@ While drafts can be very useful for maintaining work continuity, especially in c
+### Enable user typing messages
+
+
+
+
+
+
+
+
+This setting determines whether "user is typing..." messages are displayed below the message box. - true: (Default) "User is typing..." messages are displayed below the message box.
- false: "User is typing..." messages are not displayed.
|
+- System Config path: Site Configuration > Posts
config.json setting: ServiceSettings > EnableUserTypingMessages > true- Environment variable:
MM_SERVICESETTINGS_ENABLEUSERTYPINGMESSAGES
|
+
+
+
+
+
+
+Disabling this configuration setting in larger deployments may improve server performance in the following areas:
+
+- Reduced Server Load: Typing events generate additional websocket traffic. Disabling them can reduce the amount of data that needs to be handled by the server, improving the overall response time and decreasing server load.
+- Lower Network Traffic: When typing events are enabled, every keystroke generates a network event. This can lead to a significant amount of network traffic, particularly in busy channels. Disabling these events reduces the amount of information transmitted over the network.
+- Client Performance: On the client side, processing typing events requires resources. By not having to handle these events, the client can be more responsive and use less memory and CPU.
+
+
+
+### User typing timeout
+
+
+
+
+
+
+
+
+The number of milliseconds to wait between emitting user typing websocket events, which determines how frequently "user is typing..." messages are updated. Numerical input in milliseconds. Default is 5000. Minimum is 1000. |
+- System Config path: Site Configuration > Posts
config.json setting: ServiceSettings > TimeBetweenUserTypingUpdatesMilliseconds > 5000- Environment variable:
MM_SERVICESETTINGS_TIMEBETWEENUSERTYPINGUPDATESMILLISECONDS
|
+
+
+
+
+
+
+This setting only applies when **Enable user typing messages** is set to **true**.
+
+
+
------------------------------------------------------------------------------------------------------------------------
## Content flagging
diff --git a/docs/main/administration-guide/upgrade/important-upgrade-notes.mdx b/docs/main/administration-guide/upgrade/important-upgrade-notes.mdx
index 2c3cef926f64..219da7ae1709 100644
--- a/docs/main/administration-guide/upgrade/important-upgrade-notes.mdx
+++ b/docs/main/administration-guide/upgrade/important-upgrade-notes.mdx
@@ -21,6 +21,10 @@ We recommend reviewing the [additional upgrade notes](#additional-upgrade-notes)
+| v12.0 |
+Mattermost v12.0 removes the deprecated interactive dialog date/datetime fields. Top-level min_date, max_date, and time_interval on dialog elements and app fields, and datetime_config.allow_manual_time_entry, are no longer accepted. Integrations must migrate to datetime_config (using manual_time_entry instead of allow_manual_time_entry) before upgrading to v12.0. Legacy keys are silently ignored, so date constraints and manual time entry will not apply until payloads are updated. See the interactive dialogs documentation for details. |
+
+
| v11.9 |
Mattermost v11.9 changes how redirect URI allowlist patterns are matched for OAuth Dynamic Client Registration (DCR). Patterns are now evaluated per URL component (scheme, host, path, and query) rather than as a whole-string glob. As a result, a pattern such as https://\*.example.com/\*\* no longer matches redirect URIs that include a query string (for example, https://app.example.com/callback?tenant=foo); redirect URIs without a query string continue to match as expected. Admins using DCR with redirect URIs that include query strings must update their allowlist. To allow redirect URIs both with and without a query string, add two separate entries: https://\*.example.com/\*\* — matches redirect URIs with no query string.https://\*.example.com/\*\*?\*\* — matches redirect URIs with any query string.
A pattern that includes a query component (such as ?\*\*) only matches URIs that also carry a query string; it will not match URIs without one. Both entries are required to cover both cases. |
diff --git a/e2e-tests/.ci/server.generate.sh b/e2e-tests/.ci/server.generate.sh
index a3ccd9bc92a5..537616e2d04e 100755
--- a/e2e-tests/.ci/server.generate.sh
+++ b/e2e-tests/.ci/server.generate.sh
@@ -64,7 +64,6 @@ services:
MM_EMAILSETTINGS_SMTPSERVER: "localhost"
MM_CLUSTERSETTINGS_READONLYCONFIG: "false"
MM_SERVICEENVIRONMENT: "test"
- MM_FEATUREFLAGS_MOVETHREADSENABLED: "true"
MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES: "true"
MM_FEATUREFLAGS_PERMISSIONPOLICIES: "true"
MM_FEATUREFLAGS_TEAMMEMBERSHIPACCESSCONTROL: "true"
diff --git a/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_dm_spec.js b/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_dm_spec.js
index 592c5bfbbff7..2d65a7df1b0e 100644
--- a/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_dm_spec.js
+++ b/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_dm_spec.js
@@ -12,7 +12,9 @@
import * as TIMEOUTS from '@/fixtures/timeouts';
-describe('Move Thread', () => {
+// Skipped: MoveThreadsEnabled is retired and rejected by Config.IsValid (MM-69646).
+// These specs require the flag and cannot run while the server refuses to enable it.
+describe.skip('Move Thread', () => {
let user1;
let user2;
let testTeam;
diff --git a/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_gm_spec.js b/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_gm_spec.js
index 906042fe16b1..b761ea2e29ce 100644
--- a/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_gm_spec.js
+++ b/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_gm_spec.js
@@ -12,7 +12,9 @@
import * as TIMEOUTS from '@/fixtures/timeouts';
-describe('Move thread', () => {
+// Skipped: MoveThreadsEnabled is retired and rejected by Config.IsValid (MM-69646).
+// These specs require the flag and cannot run while the server refuses to enable it.
+describe.skip('Move thread', () => {
let user1;
let user2;
let user3;
diff --git a/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_private_channel_spec.js b/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_private_channel_spec.js
index 094783b0819d..fbff62361502 100644
--- a/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_private_channel_spec.js
+++ b/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_private_channel_spec.js
@@ -12,7 +12,9 @@
import * as TIMEOUTS from '@/fixtures/timeouts';
-describe('Move thread', () => {
+// Skipped: MoveThreadsEnabled is retired and rejected by Config.IsValid (MM-69646).
+// These specs require the flag and cannot run while the server refuses to enable it.
+describe.skip('Move thread', () => {
let user1;
let testTeam;
let privateChannel;
diff --git a/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_public_channel_spec.js b/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_public_channel_spec.js
index 42a64a563be9..50bf871cc91e 100644
--- a/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_public_channel_spec.js
+++ b/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_public_channel_spec.js
@@ -10,7 +10,9 @@
// Stage: @prod
// Group: @channels @enterprise @messaging
-describe('Move Thread', () => {
+// Skipped: MoveThreadsEnabled is retired and rejected by Config.IsValid (MM-69646).
+// These specs require the flag and cannot run while the server refuses to enable it.
+describe.skip('Move Thread', () => {
let user1;
let user2;
let user3;
diff --git a/e2e-tests/cypress/utils/webhook_utils.js b/e2e-tests/cypress/utils/webhook_utils.js
index 9543c7e6dced..e21c4fd991af 100644
--- a/e2e-tests/cypress/utils/webhook_utils.js
+++ b/e2e-tests/cypress/utils/webhook_utils.js
@@ -437,7 +437,9 @@ function getBasicDateTimeDialog(triggerId, webhookBaseUrl) {
placeholder: 'Select date and time',
help_text: 'Select the date and time for your meeting',
optional: false,
- time_interval: 60,
+ datetime_config: {
+ time_interval: 60,
+ },
},
],
submit_label: 'Submit',
@@ -465,7 +467,9 @@ function getMinDateConstraintDialog(triggerId, webhookBaseUrl) {
placeholder: 'Select a future date',
help_text: 'Must be today or later',
optional: true,
- min_date: 'today',
+ datetime_config: {
+ min_date: 'today',
+ },
},
],
submit_label: 'Submit',
@@ -493,7 +497,9 @@ function getCustomIntervalDialog(triggerId, webhookBaseUrl) {
placeholder: 'Select time (30min intervals)',
help_text: 'Time picker with 30-minute intervals',
optional: true,
- time_interval: 30,
+ datetime_config: {
+ time_interval: 30,
+ },
},
],
submit_label: 'Submit',
diff --git a/e2e-tests/playwright/lib/src/containers/assets/webhook/utils/webhook_utils.js b/e2e-tests/playwright/lib/src/containers/assets/webhook/utils/webhook_utils.js
index 0925d6aa3140..f31bc5685b3f 100644
--- a/e2e-tests/playwright/lib/src/containers/assets/webhook/utils/webhook_utils.js
+++ b/e2e-tests/playwright/lib/src/containers/assets/webhook/utils/webhook_utils.js
@@ -567,7 +567,9 @@ function getBasicDateTimeDialog(triggerId, webhookBaseUrl) {
placeholder: 'Select date and time',
help_text: 'Select the date and time for your meeting',
optional: false,
- time_interval: 60,
+ datetime_config: {
+ time_interval: 60,
+ },
},
],
submit_label: 'Submit',
@@ -595,7 +597,9 @@ function getMinDateConstraintDialog(triggerId, webhookBaseUrl) {
placeholder: 'Select a future date',
help_text: 'Must be today or later',
optional: true,
- min_date: 'today',
+ datetime_config: {
+ min_date: 'today',
+ },
},
],
submit_label: 'Submit',
@@ -623,7 +627,9 @@ function getCustomIntervalDialog(triggerId, webhookBaseUrl) {
placeholder: 'Select time (30min intervals)',
help_text: 'Time picker with 30-minute intervals',
optional: true,
- time_interval: 30,
+ datetime_config: {
+ time_interval: 30,
+ },
},
],
submit_label: 'Submit',
diff --git a/e2e-tests/playwright/lib/src/containers/env_baseline.ts b/e2e-tests/playwright/lib/src/containers/env_baseline.ts
index 1419149a980e..638fdad1c076 100644
--- a/e2e-tests/playwright/lib/src/containers/env_baseline.ts
+++ b/e2e-tests/playwright/lib/src/containers/env_baseline.ts
@@ -19,7 +19,6 @@ export const SERVER_ENV_BASELINE: Record = {
// Feature flags this test suite needs on, off by default in the server
MM_FEATUREFLAGS_ATTRIBUTEVALUEMASKING: 'true',
MM_FEATUREFLAGS_ENABLEREMOTECLUSTERSERVICE: 'true',
- MM_FEATUREFLAGS_MOVETHREADSENABLED: 'true',
MM_FEATUREFLAGS_PERMISSIONPOLICIES: 'true',
MM_FEATUREFLAGS_PROPERTYFIELDRANK: 'true',
MM_FEATUREFLAGS_RECURRINGSCHEDULEDPOSTS: 'true',
diff --git a/e2e-tests/playwright/specs/functional/channels/file_attachments/edit_file_attachment.spec.ts b/e2e-tests/playwright/specs/functional/channels/file_attachments/edit_file_attachment.spec.ts
index 670d8bd0d703..375709990f45 100644
--- a/e2e-tests/playwright/specs/functional/channels/file_attachments/edit_file_attachment.spec.ts
+++ b/e2e-tests/playwright/specs/functional/channels/file_attachments/edit_file_attachment.spec.ts
@@ -13,7 +13,7 @@ test('MM-T5654_1 should be able to add attachments while editing a post', {tag:
// # Initialize user and login
const {user} = await pw.initSetup();
- const {channelsPage} = await pw.testBrowser.login(user);
+ const {channelsPage, page} = await pw.testBrowser.login(user);
// # Navigate to channels page and post a message
await channelsPage.goto();
@@ -28,6 +28,7 @@ test('MM-T5654_1 should be able to add attachments while editing a post', {tag:
// # Open the dot menu and click edit
await post.postMenu.dotMenuButton.click();
+ await moveMouseAway(page);
await channelsPage.postDotMenu.toBeVisible();
await channelsPage.postDotMenu.editMenuItem.click();
@@ -59,6 +60,7 @@ test('MM-T5654_2 should be able to add attachments while editing a threaded post
// open the dot menu
await post.postMenu.dotMenuButton.click();
+ await moveMouseAway(page);
await channelsPage.postDotMenu.toBeVisible();
await channelsPage.postDotMenu.replyMenuItem.click();
await channelsPage.sidebarRight.toBeVisible();
@@ -71,6 +73,7 @@ test('MM-T5654_2 should be able to add attachments while editing a threaded post
await replyPost.hover();
await replyPost.postMenu.toBeVisible();
await replyPost.postMenu.dotMenuButton.click();
+ await moveMouseAway(page);
await channelsPage.postDotMenu.toBeVisible();
await channelsPage.postDotMenu.editMenuItem.click();
await channelsPage.sidebarRight.postEdit.toBeVisible();
@@ -85,6 +88,7 @@ test('MM-T5654_2 should be able to add attachments while editing a threaded post
await updatedReplyPost.hover();
await updatedReplyPost.postMenu.toBeVisible();
await updatedReplyPost.postMenu.dotMenuButton.click();
+ await moveMouseAway(page);
await channelsPage.postDotMenu.toBeVisible();
await channelsPage.postDotMenu.editMenuItem.click();
await channelsPage.sidebarRight.postEdit.toBeVisible();
@@ -103,7 +107,7 @@ test('MM-T5654_2 should be able to add attachments while editing a threaded post
await updatedReplyPost.hover();
await updatedReplyPost.postMenu.toBeVisible();
await updatedReplyPost.postMenu.clickOnDotMenu();
- await moveMouseToCenter(page);
+ await moveMouseAway(page);
await channelsPage.postDotMenu.toBeVisible();
await channelsPage.postDotMenu.editMenuItem.click();
await channelsPage.sidebarRight.postEdit.toBeVisible();
@@ -124,7 +128,7 @@ test('MM-T5654_3 should be able to edit post message originally containing files
const originalMessage = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit';
const {user} = await pw.initSetup();
- const {channelsPage} = await pw.testBrowser.login(user);
+ const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
@@ -137,6 +141,7 @@ test('MM-T5654_3 should be able to edit post message originally containing files
// open the dot menu
await post.postMenu.dotMenuButton.click();
+ await moveMouseAway(page);
await channelsPage.postDotMenu.toBeVisible();
await channelsPage.postDotMenu.editMenuItem.click();
await channelsPage.centerView.postEdit.toBeVisible();
@@ -152,7 +157,7 @@ test('MM-T5654_4 should be able to add files when editing a post', async ({pw})
const originalMessage = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit';
const {user} = await pw.initSetup();
- const {channelsPage} = await pw.testBrowser.login(user);
+ const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
@@ -165,6 +170,7 @@ test('MM-T5654_4 should be able to add files when editing a post', async ({pw})
// open the dot menu
await post.postMenu.dotMenuButton.click();
+ await moveMouseAway(page);
await channelsPage.postDotMenu.toBeVisible();
await channelsPage.postDotMenu.editMenuItem.click();
await channelsPage.centerView.postEdit.toBeVisible();
@@ -179,6 +185,7 @@ test('MM-T5654_4 should be able to add files when editing a post', async ({pw})
// now we'll add multiple files
await post.postMenu.dotMenuButton.click();
+ await moveMouseAway(page);
await channelsPage.postDotMenu.toBeVisible();
await channelsPage.postDotMenu.editMenuItem.click();
await channelsPage.centerView.postEdit.toBeVisible();
@@ -213,7 +220,7 @@ test('MM-5654_5 should be able to remove attachments while editing a post', asyn
await post.hover();
await post.postMenu.toBeVisible();
await post.postMenu.clickOnDotMenu();
- await moveMouseToCenter(page);
+ await moveMouseAway(page);
await channelsPage.postDotMenu.toBeVisible();
await channelsPage.postDotMenu.editMenuItem.click();
@@ -233,7 +240,7 @@ test('MM-T5655_1 removing message content and files should delete the post', asy
const originalMessage = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit';
const {user} = await pw.initSetup();
- const {channelsPage} = await pw.testBrowser.login(user);
+ const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
@@ -247,6 +254,7 @@ test('MM-T5655_1 removing message content and files should delete the post', asy
await post.hover();
await post.postMenu.toBeVisible();
await post.postMenu.dotMenuButton.click();
+ await moveMouseAway(page);
await channelsPage.postDotMenu.toBeVisible();
await channelsPage.postDotMenu.editMenuItem.click();
@@ -283,7 +291,7 @@ test('MM-T5655_2 should be able to remove all files when editing a post', async
await post.hover();
await post.postMenu.toBeVisible();
await post.postMenu.clickOnDotMenu();
- await moveMouseToCenter(page);
+ await moveMouseAway(page);
await channelsPage.postDotMenu.toBeVisible();
await channelsPage.postDotMenu.editMenuItem.click();
@@ -306,7 +314,7 @@ test('MM-T5656_1 should be able to restore previously edited post version that c
const newMessage = 'New Message';
const {user} = await pw.initSetup();
- const {channelsPage} = await pw.testBrowser.login(user);
+ const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
@@ -320,6 +328,7 @@ test('MM-T5656_1 should be able to restore previously edited post version that c
await post.hover();
await post.postMenu.toBeVisible();
await post.postMenu.dotMenuButton.click();
+ await moveMouseAway(page);
await channelsPage.postDotMenu.toBeVisible();
await channelsPage.postDotMenu.editMenuItem.click();
@@ -350,6 +359,11 @@ test('MM-T5656_1 should be able to restore previously edited post version that c
await restoredPost.toContainText('sample_text_file.txt');
});
-async function moveMouseToCenter(page: Page) {
+/**
+ * Parks the cursor away from the post so it does not hover whatever menu item happens to render
+ * under it. A hovered submenu item (e.g. "Remind") opens a nested MUI popover, which marks the
+ * parent menu aria-hidden and makes getByRole('menu') unresolvable.
+ */
+async function moveMouseAway(page: Page) {
await page.mouse.move(0, 0);
}
diff --git a/e2e-tests/playwright/specs/functional/system_console/global_attributes/global_attributes.spec.ts b/e2e-tests/playwright/specs/functional/system_console/global_attributes/global_attributes.spec.ts
index eed208526465..ee857932a289 100644
--- a/e2e-tests/playwright/specs/functional/system_console/global_attributes/global_attributes.spec.ts
+++ b/e2e-tests/playwright/specs/functional/system_console/global_attributes/global_attributes.spec.ts
@@ -21,7 +21,9 @@ import {
import {
GLOBAL_ATTRIBUTES_ADMIN_PATH,
createGlobalAttributeField,
+ createLinkedDependentField,
deleteGlobalAttributeFieldIfExists,
+ deleteLinkedDependentField,
requireGlobalAttributesEnabled,
setGlobalAttributesFeatureFlag,
} from './global_attributes_helpers';
@@ -436,9 +438,13 @@ test.describe('System Console - Global Attributes', {tag: '@system_console'}, ()
// one, so "E2E ..." would actually slugify to "e2_e_..." (verified against
// slugifyForCEL directly), not the naively-expected "e2e_...". "Playwright"
// has no internal case/digit boundary, so its derived slug is unambiguous.
+ // The prefix is kept short on purpose: the Unique name input is capped at
+ // Constants.MAX_CUSTOM_ATTRIBUTE_NAME_LENGTH (40), and a 13-digit Date.now()
+ // leaves only 27 characters for everything before it. A longer prefix derives
+ // a silently truncated slug that no longer matches the expectation below.
const timestamp = Date.now();
- const displayName = `Playwright Created Attribute ${timestamp}`;
- const expectedName = `playwright_created_attribute_${timestamp}`;
+ const displayName = `Playwright Attr ${timestamp}`;
+ const expectedName = `playwright_attr_${timestamp}`;
try {
// # Log in and open the Manage Attributes page
@@ -681,8 +687,9 @@ test.describe('System Console - Global Attributes', {tag: '@system_console'}, ()
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
const timestamp = Date.now();
- const displayName = `Playwright Select Attribute ${timestamp}`;
- const expectedName = `playwright_select_attribute_${timestamp}`;
+ // Short prefix: see the 40-char Unique name cap noted in the bare-Text test above
+ const displayName = `Playwright Select ${timestamp}`;
+ const expectedName = `playwright_select_${timestamp}`;
try {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
@@ -736,8 +743,9 @@ test.describe('System Console - Global Attributes', {tag: '@system_console'}, ()
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
const timestamp = Date.now();
- const displayName = `Playwright Ranked Attribute ${timestamp}`;
- const expectedName = `playwright_ranked_attribute_${timestamp}`;
+ // Short prefix: see the 40-char Unique name cap noted in the bare-Text test above
+ const displayName = `Playwright Ranked ${timestamp}`;
+ const expectedName = `playwright_ranked_${timestamp}`;
try {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
@@ -822,8 +830,11 @@ test.describe('System Console - Global Attributes', {tag: '@system_console'}, ()
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
const timestamp = Date.now();
- const displayName = `Playwright LDAP Linked Attribute ${timestamp}`;
- const expectedName = `playwright_ldap_linked_attribute_${timestamp}`;
+ // Short prefix: see the 40-char Unique name cap noted in the bare-Text test above.
+ // This one only uses expectedName for cleanup, so an over-long prefix leaked the
+ // created field onto the shared server instead of failing loudly.
+ const displayName = `Playwright Ldap ${timestamp}`;
+ const expectedName = `playwright_ldap_${timestamp}`;
try {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
@@ -867,8 +878,9 @@ test.describe('System Console - Global Attributes', {tag: '@system_console'}, ()
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
const timestamp = Date.now();
- const displayName = `Playwright Dual Linked Attribute ${timestamp}`;
- const expectedName = `playwright_dual_linked_attribute_${timestamp}`;
+ // Short prefix: see the 40-char Unique name cap noted in the bare-Text test above
+ const displayName = `Playwright Dual ${timestamp}`;
+ const expectedName = `playwright_dual_${timestamp}`;
try {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
@@ -1011,4 +1023,162 @@ test.describe('System Console - Global Attributes', {tag: '@system_console'}, ()
);
});
});
+
+ test.describe('delete attribute', () => {
+ /**
+ * @objective Ensure the row kebab's Delete action removes the attribute end-to-end:
+ * the confirmation names the attribute, and confirming drops the row from the table.
+ */
+ test('deletes an attribute from the row menu after confirming, and the row disappears', async ({pw}) => {
+ const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
+
+ const timestamp = Date.now();
+ const name = `e2e_global_attribute_delete_${timestamp}`;
+ const displayName = `E2E Delete Attribute ${timestamp}`;
+
+ try {
+ const field = await createGlobalAttributeField(adminClient, name, {
+ type: 'text',
+ attrs: {display_name: displayName},
+ });
+
+ const {systemConsolePage} = await pw.testBrowser.login(adminUser);
+ const {page} = systemConsolePage;
+ await page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
+
+ const row = page.locator('tr', {
+ has: page.getByTestId('global-attribute-name').filter({hasText: displayName}),
+ });
+ await expect(row).toBeVisible();
+
+ // # Open the row kebab and click Delete
+ await page.getByTestId(`global-attribute-actions-${field.id}`).click();
+ await page.locator(`#global-attribute-actions-${field.id}-delete`).click();
+
+ // * The confirmation names the specific attribute rather than prompting generically
+ await expect(page.getByRole('heading', {name: `Delete ${displayName} attribute`})).toBeVisible();
+
+ // # Confirm
+ await page.getByRole('button', {name: 'Delete', exact: true}).click();
+
+ // * The row is gone and no error banner appeared
+ await expect(row).toHaveCount(0);
+ await expect(page.getByTestId('global-attributes-delete-error')).toHaveCount(0);
+
+ // * The delete really hit the server, not just the client store — a fresh
+ // page load still doesn't show it
+ await page.reload();
+ await expect(page.getByTestId('global-attribute-name').filter({hasText: displayName})).toHaveCount(0);
+ } finally {
+ await deleteGlobalAttributeFieldIfExists(adminClient, name);
+ }
+ });
+
+ /**
+ * @objective Ensure cancelling the confirmation is a true no-op — no delete call fires
+ * and the attribute survives a reload.
+ */
+ test('leaves the attribute in place when the confirmation is cancelled', async ({pw}) => {
+ const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
+
+ const timestamp = Date.now();
+ const name = `e2e_global_attribute_cancel_${timestamp}`;
+ const displayName = `E2E Cancel Attribute ${timestamp}`;
+
+ try {
+ const field = await createGlobalAttributeField(adminClient, name, {
+ type: 'text',
+ attrs: {display_name: displayName},
+ });
+
+ const {systemConsolePage} = await pw.testBrowser.login(adminUser);
+ const {page} = systemConsolePage;
+ await page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
+
+ const row = page.locator('tr', {
+ has: page.getByTestId('global-attribute-name').filter({hasText: displayName}),
+ });
+ await expect(row).toBeVisible();
+
+ // # Open the row kebab, click Delete, then back out
+ await page.getByTestId(`global-attribute-actions-${field.id}`).click();
+ await page.locator(`#global-attribute-actions-${field.id}-delete`).click();
+ await expect(page.getByRole('heading', {name: `Delete ${displayName} attribute`})).toBeVisible();
+ await page.getByRole('button', {name: 'Cancel'}).click();
+
+ // * The modal closed and the row survived
+ await expect(page.getByRole('heading', {name: `Delete ${displayName} attribute`})).toHaveCount(0);
+ await expect(row).toBeVisible();
+
+ // * Nothing was deleted server-side either
+ await page.reload();
+ await expect(page.getByTestId('global-attribute-name').filter({hasText: displayName})).toBeVisible();
+ } finally {
+ await deleteGlobalAttributeFieldIfExists(adminClient, name);
+ }
+ });
+
+ /**
+ * @objective Ensure a server-side 409 (the attribute still has live linked dependents)
+ * surfaces as the specific "still linked" banner above the table, not the generic error,
+ * and leaves the row intact. Exercised against a real 409 from the server rather than a
+ * stubbed rejection.
+ */
+ test('shows the linked-dependents banner and keeps the row when the server refuses the delete', async ({
+ pw,
+ }) => {
+ const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
+
+ const timestamp = Date.now();
+ const name = `e2e_global_attribute_linked_${timestamp}`;
+ const displayName = `E2E Linked Attribute ${timestamp}`;
+ const dependentName = `e2e_global_attribute_dependent_${timestamp}`;
+
+ let dependentFieldId: string | undefined;
+
+ try {
+ const field = await createGlobalAttributeField(adminClient, name, {
+ type: 'text',
+ attrs: {display_name: displayName},
+ });
+
+ // # Point a dependent field at it, which is what makes the server refuse the delete
+ const dependent = await createLinkedDependentField(adminClient, dependentName, field.id, 'text');
+ dependentFieldId = dependent.id;
+
+ const {systemConsolePage} = await pw.testBrowser.login(adminUser);
+ const {page} = systemConsolePage;
+ await page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
+
+ const row = page.locator('tr', {
+ has: page.getByTestId('global-attribute-name').filter({hasText: displayName}),
+ });
+ await expect(row).toBeVisible();
+
+ // # Try to delete it
+ await page.getByTestId(`global-attribute-actions-${field.id}`).click();
+ await page.locator(`#global-attribute-actions-${field.id}-delete`).click();
+ await page.getByRole('button', {name: 'Delete', exact: true}).click();
+
+ // * The banner explains the blocking dependency instead of the generic failure
+ const banner = page.getByTestId('global-attributes-delete-error');
+ await expect(banner).toBeVisible();
+ await expect(banner).toContainText('other attributes are still linked to it');
+ await expect(banner).not.toContainText('An error occurred while deleting this attribute');
+
+ // * The row survived the rejected delete
+ await expect(row).toBeVisible();
+
+ // # The banner is dismissible
+ await banner.getByRole('button', {name: 'Close'}).click();
+ await expect(banner).toHaveCount(0);
+ } finally {
+ // Dependent first: the source delete stays blocked while it exists
+ if (dependentFieldId) {
+ await deleteLinkedDependentField(adminClient, dependentFieldId);
+ }
+ await deleteGlobalAttributeFieldIfExists(adminClient, name);
+ }
+ });
+ });
});
diff --git a/e2e-tests/playwright/specs/functional/system_console/global_attributes/global_attributes_helpers.ts b/e2e-tests/playwright/specs/functional/system_console/global_attributes/global_attributes_helpers.ts
index 00d17059e46a..168580549a1a 100644
--- a/e2e-tests/playwright/specs/functional/system_console/global_attributes/global_attributes_helpers.ts
+++ b/e2e-tests/playwright/specs/functional/system_console/global_attributes/global_attributes_helpers.ts
@@ -14,6 +14,12 @@ const PROPERTY_GROUP = 'access_control';
const OBJECT_TYPE = 'template';
const TARGET_TYPE = 'system';
+// Object type used to seed a field that *links to* a template field. It has to be
+// anything but 'template': PropertyField.IsValid rejects a template field carrying a
+// linked_field_id ("template fields cannot have a linked field"). 'user' matches the
+// shape the store's own CountLinkedFields coverage uses.
+const LINKED_OBJECT_TYPE = 'user';
+
// Server clamps per_page to this max (see web.PerPageMaximum in server/channels/web/params.go).
// Directory-mode search with no cursor sorts CreateAt ASC, so the default 60-item page only
// returns the oldest fields — request the max to reduce the risk of missing newer ones.
@@ -96,3 +102,37 @@ export async function createGlobalAttributeField(
...field,
} as Parameters[2]);
}
+
+/**
+ * Creates a field that links to `sourceFieldId`, which makes the server refuse to delete
+ * that source field: deletePropertyField counts live linked dependents and returns 409
+ * `has_linked_dependents` when any exist (server/channels/app/properties/property_field.go).
+ * This is the only way to exercise the listing's 409 branch against a real server response.
+ */
+export async function createLinkedDependentField(
+ adminClient: Client4,
+ name: string,
+ sourceFieldId: string,
+ type: string,
+) {
+ return adminClient.createPropertyField(PROPERTY_GROUP, LINKED_OBJECT_TYPE, {
+ name,
+ type,
+ target_type: TARGET_TYPE,
+ target_id: '',
+ linked_field_id: sourceFieldId,
+ } as unknown as Parameters[2]);
+}
+
+/**
+ * Deletes a linked dependent field by id, ignoring failures (it may already be gone).
+ * Must run BEFORE deleting the field it points at — the source delete stays blocked
+ * with a 409 for as long as a live dependent exists.
+ */
+export async function deleteLinkedDependentField(adminClient: Client4, fieldId: string) {
+ try {
+ await adminClient.deletePropertyField(PROPERTY_GROUP, LINKED_OBJECT_TYPE, fieldId);
+ } catch {
+ // Already deleted, or routes unavailable; ignore.
+ }
+}
diff --git a/server/channels/api4/channel_test.go b/server/channels/api4/channel_test.go
index f88f1380102a..2be8d2ff74b1 100644
--- a/server/channels/api4/channel_test.go
+++ b/server/channels/api4/channel_test.go
@@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io"
"net/http"
"strings"
"testing"
@@ -7505,73 +7506,162 @@ func TestChannelMemberSanitization(t *testing.T) {
_, _, err := client.AddChannelMember(context.Background(), channel.Id, user2.Id)
require.NoError(t, err)
- t.Run("getChannelMembers sanitizes LastViewedAt and LastUpdateAt for other users", func(t *testing.T) {
- members, _, err := client.GetChannelMembers(context.Background(), channel.Id, 0, 60, "")
+ // Give the current user a real, non-zero last_viewed_at: user2 posts a
+ // message so the channel is unread for the current user, who then views it.
+ // This keeps the current-user assertions realistic; the requester's own
+ // timestamps are never sanitized, so a genuine 0 would still be serialized.
+ user2Client := th.CreateClient()
+ _, _, err = user2Client.Login(context.Background(), user2.Email, user2.Password)
+ require.NoError(t, err)
+ _, _, err = user2Client.CreatePost(context.Background(), &model.Post{ChannelId: channel.Id, Message: "unread message"})
+ require.NoError(t, err)
+
+ _, _, err = client.ViewChannel(context.Background(), user.Id, &model.ChannelView{ChannelId: channel.Id})
+ require.NoError(t, err)
+
+ // decodeRawMembers reads the raw JSON body of a channel member response so the
+ // test can assert whether the timestamp fields are present or omitted, which a
+ // typed model.ChannelMember cannot distinguish from a zero value.
+ decodeRawMembers := func(resp *http.Response, err error) []map[string]json.RawMessage {
require.NoError(t, err)
+ defer resp.Body.Close()
+
+ var raw json.RawMessage
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&raw))
+
+ var members []map[string]json.RawMessage
+ if decodeErr := json.Unmarshal(raw, &members); decodeErr != nil {
+ var single map[string]json.RawMessage
+ require.NoError(t, json.Unmarshal(raw, &single))
+ members = []map[string]json.RawMessage{single}
+ }
+ return members
+ }
+
+ // decodeNDJSONMembers reads a newline-delimited JSON stream, as returned by
+ // getChannelMembersForUser when page=-1.
+ decodeNDJSONMembers := func(resp *http.Response, err error) []map[string]json.RawMessage {
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ var members []map[string]json.RawMessage
+ decoder := json.NewDecoder(resp.Body)
+ for {
+ var member map[string]json.RawMessage
+ decodeErr := decoder.Decode(&member)
+ if decodeErr == io.EOF {
+ break
+ }
+ require.NoError(t, decodeErr)
+ members = append(members, member)
+ }
+ return members
+ }
+
+ userIDOf := func(t *testing.T, member map[string]json.RawMessage) string {
+ t.Helper()
+ var id string
+ require.NoError(t, json.Unmarshal(member["user_id"], &id))
+ return id
+ }
+ // assertTimestamps verifies that the current user's memberships expose valid
+ // timestamps while other users' timestamps are omitted entirely.
+ assertTimestamps := func(t *testing.T, members []map[string]json.RawMessage) {
+ t.Helper()
for _, member := range members {
- if member.UserId == user.Id {
- // Current user should see their own timestamps
- assert.NotEqual(t, int64(-1), member.LastViewedAt, "Current user should see their LastViewedAt")
- assert.NotEqual(t, int64(-1), member.LastUpdateAt, "Current user should see their LastUpdateAt")
+ rawLastViewedAt, hasLastViewedAt := member["last_viewed_at"]
+ rawLastUpdateAt, hasLastUpdateAt := member["last_update_at"]
+
+ if userIDOf(t, member) == user.Id {
+ require.True(t, hasLastViewedAt, "Current user should see their last_viewed_at")
+ require.True(t, hasLastUpdateAt, "Current user should see their last_update_at")
+
+ var lastViewedAt, lastUpdateAt int64
+ require.NoError(t, json.Unmarshal(rawLastViewedAt, &lastViewedAt))
+ require.NoError(t, json.Unmarshal(rawLastUpdateAt, &lastUpdateAt))
+ assert.GreaterOrEqual(t, lastViewedAt, int64(0), "Current user's last_viewed_at should be a valid timestamp, not the sentinel")
+ assert.GreaterOrEqual(t, lastUpdateAt, int64(0), "Current user's last_update_at should be a valid timestamp, not the sentinel")
} else {
- // Other users' timestamps should be sanitized
- assert.Equal(t, int64(-1), member.LastViewedAt, "Other users' LastViewedAt should be sanitized")
- assert.Equal(t, int64(-1), member.LastUpdateAt, "Other users' LastUpdateAt should be sanitized")
+ assert.False(t, hasLastViewedAt, "Other users' last_viewed_at should be omitted, not returned as an invalid value")
+ assert.False(t, hasLastUpdateAt, "Other users' last_update_at should be omitted, not returned as an invalid value")
}
}
- })
-
- t.Run("getChannelMember sanitizes LastViewedAt and LastUpdateAt for other users", func(t *testing.T) {
- // Get other user's membership data
- member, _, err := client.GetChannelMember(context.Background(), channel.Id, user2.Id, "")
- require.NoError(t, err)
+ }
- // Should be sanitized since it's not the current user
- assert.Equal(t, int64(-1), member.LastViewedAt, "Other user's LastViewedAt should be sanitized")
- assert.Equal(t, int64(-1), member.LastUpdateAt, "Other user's LastUpdateAt should be sanitized")
+ t.Run("getChannelMembers omits last_viewed_at and last_update_at for other users", func(t *testing.T) {
+ members := decodeRawMembers(client.DoAPIGet(context.Background(), "/channels/"+channel.Id+"/members?page=0&per_page=60", ""))
+ require.Len(t, members, 2)
+ assertTimestamps(t, members)
+ })
- // Get current user's membership data
- currentMember, _, err := client.GetChannelMember(context.Background(), channel.Id, user.Id, "")
- require.NoError(t, err)
+ t.Run("getChannelMember omits timestamps for other users but keeps them for the current user", func(t *testing.T) {
+ otherMembers := decodeRawMembers(client.DoAPIGet(context.Background(), "/channels/"+channel.Id+"/members/"+user2.Id, ""))
+ require.Len(t, otherMembers, 1)
+ _, hasLastViewedAt := otherMembers[0]["last_viewed_at"]
+ _, hasLastUpdateAt := otherMembers[0]["last_update_at"]
+ assert.False(t, hasLastViewedAt, "Other user's last_viewed_at should be omitted")
+ assert.False(t, hasLastUpdateAt, "Other user's last_update_at should be omitted")
- // Should not be sanitized since it's the current user
- assert.NotEqual(t, int64(-1), currentMember.LastViewedAt, "Current user should see their LastViewedAt")
- assert.NotEqual(t, int64(-1), currentMember.LastUpdateAt, "Current user should see their LastUpdateAt")
+ currentMembers := decodeRawMembers(client.DoAPIGet(context.Background(), "/channels/"+channel.Id+"/members/"+user.Id, ""))
+ require.Len(t, currentMembers, 1)
+ assertTimestamps(t, currentMembers)
})
- t.Run("getChannelMembersByIds sanitizes data appropriately", func(t *testing.T) {
- userIds := []string{user.Id, user2.Id}
- members, _, err := client.GetChannelMembersByIds(context.Background(), channel.Id, userIds)
- require.NoError(t, err)
+ t.Run("getChannelMembersByIds omits timestamps for other users", func(t *testing.T) {
+ members := decodeRawMembers(client.DoAPIPostJSON(context.Background(), "/channels/"+channel.Id+"/members/ids", []string{user.Id, user2.Id}))
require.Len(t, members, 2)
+ assertTimestamps(t, members)
+ })
+ assertOtherUserMembersOmitted := func(t *testing.T, members []map[string]json.RawMessage, expectTeamData bool) {
+ t.Helper()
+ require.NotEmpty(t, members)
for _, member := range members {
- if member.UserId == user.Id {
- // Current user should see their own timestamps
- assert.NotEqual(t, int64(-1), member.LastViewedAt, "Current user should see their LastViewedAt")
- assert.NotEqual(t, int64(-1), member.LastUpdateAt, "Current user should see their LastUpdateAt")
- } else {
- // Other users' timestamps should be sanitized
- assert.Equal(t, int64(-1), member.LastViewedAt, "Other users' LastViewedAt should be sanitized")
- assert.Equal(t, int64(-1), member.LastUpdateAt, "Other users' LastUpdateAt should be sanitized")
+ assert.Equal(t, user2.Id, userIDOf(t, member))
+ assert.NotContains(t, member, "last_viewed_at", "Other user's last_viewed_at should be omitted")
+ assert.NotContains(t, member, "last_update_at", "Other user's last_update_at should be omitted")
+ if expectTeamData {
+ assert.Contains(t, member, "team_name", "Team data should still be present")
}
}
+ }
+
+ t.Run("getChannelMembersForUser (paginated) omits timestamps for other users", func(t *testing.T) {
+ // Querying another user's channel members requires the edit_other_users
+ // permission, so use the system admin client.
+ members := decodeRawMembers(th.SystemAdminClient.DoAPIGet(context.Background(), "/users/"+user2.Id+"/channel_members?page=0", ""))
+ assertOtherUserMembersOmitted(t, members, true)
+ })
+
+ t.Run("getChannelMembersForUser (NDJSON stream) omits timestamps for other users", func(t *testing.T) {
+ // page=-1 switches the endpoint to the newline-delimited streaming path,
+ // which sanitizes members through a separate code path.
+ members := decodeNDJSONMembers(th.SystemAdminClient.DoAPIGet(context.Background(), "/users/"+user2.Id+"/channel_members?page=-1", ""))
+ assertOtherUserMembersOmitted(t, members, true)
+ })
+
+ t.Run("getChannelMembersForTeamForUser omits timestamps for other users", func(t *testing.T) {
+ // Querying another user's memberships requires manage_system, so use the
+ // system admin client.
+ members := decodeRawMembers(th.SystemAdminClient.DoAPIGet(context.Background(), "/users/"+user2.Id+"/teams/"+th.BasicTeam.Id+"/channels/members", ""))
+ assertOtherUserMembersOmitted(t, members, false)
})
- t.Run("addChannelMember sanitizes returned member data", func(t *testing.T) {
+ t.Run("addChannelMember omits timestamps in the returned member data", func(t *testing.T) {
newUser := th.CreateUser(t)
th.LinkUserToTeam(t, newUser, th.BasicTeam)
- // Add new user and check returned member data
- returnedMember, _, err := client.AddChannelMember(context.Background(), channel.Id, newUser.Id)
- require.NoError(t, err)
+ members := decodeRawMembers(client.DoAPIPostJSON(context.Background(), "/channels/"+channel.Id+"/members", map[string]string{"user_id": newUser.Id}))
+ require.Len(t, members, 1)
+
+ assert.NotContains(t, members[0], "last_viewed_at", "Returned member last_viewed_at should be omitted")
+ assert.NotContains(t, members[0], "last_update_at", "Returned member last_update_at should be omitted")
+ assert.Equal(t, newUser.Id, userIDOf(t, members[0]), "UserId should be preserved")
- // The returned member should be sanitized since it's not the current user
- assert.Equal(t, int64(-1), returnedMember.LastViewedAt, "Returned member LastViewedAt should be sanitized")
- assert.Equal(t, int64(-1), returnedMember.LastUpdateAt, "Returned member LastUpdateAt should be sanitized")
- assert.Equal(t, newUser.Id, returnedMember.UserId, "UserId should be preserved")
- assert.Equal(t, channel.Id, returnedMember.ChannelId, "ChannelId should be preserved")
+ var channelID string
+ require.NoError(t, json.Unmarshal(members[0]["channel_id"], &channelID))
+ assert.Equal(t, channel.Id, channelID, "ChannelId should be preserved")
})
}
diff --git a/server/channels/api4/post_test.go b/server/channels/api4/post_test.go
index 385fb1dfdd70..abe98131c7d8 100644
--- a/server/channels/api4/post_test.go
+++ b/server/channels/api4/post_test.go
@@ -996,6 +996,10 @@ func TestCreatePostWithOutgoingHook_no_content_type(t *testing.T) {
}
func TestMoveThread(t *testing.T) {
+ // Skipped: MoveThreadsEnabled is retired and rejected by Config.IsValid (MM-69646).
+ // This test requires the flag and cannot run while the server refuses to enable it.
+ t.Skip("MoveThreadsEnabled feature flag is retired (MM-69646)")
+
th := SetupEnterprise(t).InitBasic(t)
// Enable MoveThreads feature flag
diff --git a/server/i18n/en.json b/server/i18n/en.json
index a0b96045a63a..811692751b0c 100644
--- a/server/i18n/en.json
+++ b/server/i18n/en.json
@@ -11962,6 +11962,10 @@
"id": "model.config.is_valid.extract_content_timeout.app_error",
"translation": "Invalid content extraction timeout for file settings. Must be a whole number of seconds greater than or equal to zero."
},
+ {
+ "id": "model.config.is_valid.feature_flags.move_threads_enabled.app_error",
+ "translation": "The MoveThreadsEnabled feature flag is no longer supported and must be disabled."
+ },
{
"id": "model.config.is_valid.file_driver.app_error",
"translation": "Invalid driver name for file settings. Must be 'local', 'amazons3', or 'azureblob'."
diff --git a/server/public/model/channel_member.go b/server/public/model/channel_member.go
index fa3630e5e415..3a5992e83c92 100644
--- a/server/public/model/channel_member.go
+++ b/server/public/model/channel_member.go
@@ -4,6 +4,7 @@
package model
import (
+ "encoding/json"
"fmt"
"net/http"
"strings"
@@ -90,19 +91,53 @@ func (o *ChannelMember) Auditable() map[string]any {
}
}
-// SanitizeForCurrentUser sanitizes channel member data based on whether
-// it's the current user's own membership or another user's membership
+// sanitizedTimestamp marks a LastViewedAt/LastUpdateAt field that belongs to
+// another user and must be hidden. MarshalJSON omits any field holding this
+// sentinel rather than serializing an invalid value
+const sanitizedTimestamp int64 = -1
+
+// SanitizeForCurrentUser hides another user's private timestamp fields by
+// marking them with the sanitized sentinel, which MarshalJSON then omits from
+// API responses. The requester's own values are left untouched.
func (o *ChannelMember) SanitizeForCurrentUser(currentUserId string) {
- // If this is not the current user's own membership,
- // sanitize sensitive timestamp fields
if o.UserId != currentUserId {
- o.LastViewedAt = -1
- o.LastUpdateAt = -1
+ o.LastViewedAt = sanitizedTimestamp
+ o.LastUpdateAt = sanitizedTimestamp
+ }
+}
+
+// timestampOrNil returns nil for the sanitized sentinel so that the omitempty
+// tag drops the field, and a pointer to the real value otherwise (including a
+// legitimate 0).
+func timestampOrNil(ts int64) *int64 {
+ if ts == sanitizedTimestamp {
+ return nil
}
+ return &ts
+}
+
+// MarshalJSON serializes the channel member in a single pass, omitting
+// last_viewed_at and/or last_update_at when they hold the sanitized sentinel
+// written by SanitizeForCurrentUser. The shadowing pointer fields allow for a
+// direct marshal with the sanitized values removed if needed.
+func (o ChannelMember) MarshalJSON() ([]byte, error) {
+ type alias ChannelMember
+ return json.Marshal(&struct {
+ *alias
+ LastViewedAt *int64 `json:"last_viewed_at,omitempty"`
+ LastUpdateAt *int64 `json:"last_update_at,omitempty"`
+ }{
+ alias: (*alias)(&o),
+ LastViewedAt: timestampOrNil(o.LastViewedAt),
+ LastUpdateAt: timestampOrNil(o.LastUpdateAt),
+ })
}
// ChannelMemberWithTeamData contains ChannelMember appended with extra team information
// as well.
+//
+// Any new non-embedded field added here must also be added to MarshalJSON below,
+// otherwise it will be silently dropped from the JSON output.
type ChannelMemberWithTeamData struct {
ChannelMember
TeamDisplayName string `json:"team_display_name"`
@@ -110,10 +145,35 @@ type ChannelMemberWithTeamData struct {
TeamUpdateAt int64 `json:"team_update_at"`
}
+// MarshalJSON flattens the embedded ChannelMember together with the team fields
+// in a single pass. It is required because ChannelMember's MarshalJSON would
+// otherwise be promoted and drop the team fields entirely.
+func (o ChannelMemberWithTeamData) MarshalJSON() ([]byte, error) {
+ type alias ChannelMember
+ return json.Marshal(&struct {
+ *alias
+ LastViewedAt *int64 `json:"last_viewed_at,omitempty"`
+ LastUpdateAt *int64 `json:"last_update_at,omitempty"`
+ TeamDisplayName string `json:"team_display_name"`
+ TeamName string `json:"team_name"`
+ TeamUpdateAt int64 `json:"team_update_at"`
+ }{
+ alias: (*alias)(&o.ChannelMember),
+ LastViewedAt: timestampOrNil(o.LastViewedAt),
+ LastUpdateAt: timestampOrNil(o.LastUpdateAt),
+ TeamDisplayName: o.TeamDisplayName,
+ TeamName: o.TeamName,
+ TeamUpdateAt: o.TeamUpdateAt,
+ })
+}
+
type ChannelMembers []ChannelMember
type ChannelMembersWithTeamData []ChannelMemberWithTeamData
+// ChannelMemberForExport is only converted field-by-field for export and is
+// never JSON-marshaled. If that changes, it must define its own MarshalJSON;
+// otherwise ChannelMember's promoted MarshalJSON drops ChannelName and Username.
type ChannelMemberForExport struct {
ChannelMember
ChannelName string
diff --git a/server/public/model/channel_member_test.go b/server/public/model/channel_member_test.go
index 26ad81228932..3fcb4013bf96 100644
--- a/server/public/model/channel_member_test.go
+++ b/server/public/model/channel_member_test.go
@@ -4,6 +4,7 @@
package model
import (
+ "encoding/json"
"strings"
"testing"
@@ -93,8 +94,8 @@ func TestChannelMemberSanitizeForCurrentUser(t *testing.T) {
member.SanitizeForCurrentUser(currentUserId)
- assert.Equal(t, int64(-1), member.LastViewedAt, "LastViewedAt should be sanitized for other users")
- assert.Equal(t, int64(-1), member.LastUpdateAt, "LastUpdateAt should be sanitized for other users")
+ assert.Equal(t, sanitizedTimestamp, member.LastViewedAt, "LastViewedAt should be marked sanitized for other users")
+ assert.Equal(t, sanitizedTimestamp, member.LastUpdateAt, "LastUpdateAt should be marked sanitized for other users")
})
t.Run("should preserve other fields when sanitizing", func(t *testing.T) {
@@ -120,8 +121,8 @@ func TestChannelMemberSanitizeForCurrentUser(t *testing.T) {
member.SanitizeForCurrentUser(currentUserId)
- assert.Equal(t, int64(-1), member.LastViewedAt, "LastViewedAt should be sanitized")
- assert.Equal(t, int64(-1), member.LastUpdateAt, "LastUpdateAt should be sanitized")
+ assert.Equal(t, sanitizedTimestamp, member.LastViewedAt, "LastViewedAt should be marked sanitized")
+ assert.Equal(t, sanitizedTimestamp, member.LastUpdateAt, "LastUpdateAt should be marked sanitized")
assert.Equal(t, originalRoles, member.Roles, "Roles should be preserved")
assert.Equal(t, originalMsgCount, member.MsgCount, "MsgCount should be preserved")
assert.Equal(t, originalMentionCount, member.MentionCount, "MentionCount should be preserved")
@@ -129,3 +130,124 @@ func TestChannelMemberSanitizeForCurrentUser(t *testing.T) {
assert.Equal(t, originalSchemeAdmin, member.SchemeAdmin, "SchemeAdmin should be preserved")
})
}
+
+func TestChannelMemberMarshalJSON(t *testing.T) {
+ currentUserId := NewId()
+ otherUserId := NewId()
+
+ newMember := func(userId string) ChannelMember {
+ return ChannelMember{
+ ChannelId: NewId(),
+ UserId: userId,
+ Roles: "channel_user",
+ LastViewedAt: 1234567890000,
+ MsgCount: 100,
+ LastUpdateAt: 1234567890000,
+ NotifyProps: GetDefaultChannelNotifyProps(),
+ }
+ }
+
+ decode := func(t *testing.T, member ChannelMember) map[string]any {
+ t.Helper()
+ data, err := json.Marshal(member)
+ require.NoError(t, err)
+
+ fields := map[string]any{}
+ require.NoError(t, json.Unmarshal(data, &fields))
+ return fields
+ }
+
+ t.Run("keeps timestamps for the current user's own membership", func(t *testing.T) {
+ member := newMember(currentUserId)
+ member.SanitizeForCurrentUser(currentUserId)
+
+ fields := decode(t, member)
+ assert.EqualValues(t, 1234567890000, fields["last_viewed_at"])
+ assert.EqualValues(t, 1234567890000, fields["last_update_at"])
+ })
+
+ t.Run("keeps a legitimate zero timestamp for the requester", func(t *testing.T) {
+ member := newMember(currentUserId)
+ member.LastViewedAt = 0
+ member.LastUpdateAt = 0
+ member.SanitizeForCurrentUser(currentUserId)
+
+ fields := decode(t, member)
+ assert.Contains(t, fields, "last_viewed_at", "the requester's own last_viewed_at of 0 (never viewed) must be serialized")
+ assert.EqualValues(t, 0, fields["last_viewed_at"])
+ assert.Contains(t, fields, "last_update_at", "the requester's own last_update_at of 0 must be serialized")
+ assert.EqualValues(t, 0, fields["last_update_at"])
+ })
+
+ t.Run("omits sanitized timestamps for another user's membership", func(t *testing.T) {
+ member := newMember(otherUserId)
+ member.SanitizeForCurrentUser(currentUserId)
+
+ fields := decode(t, member)
+ assert.NotContains(t, fields, "last_viewed_at", "sanitized last_viewed_at must be omitted")
+ assert.NotContains(t, fields, "last_update_at", "sanitized last_update_at must be omitted")
+
+ assert.Equal(t, member.ChannelId, fields["channel_id"])
+ assert.Equal(t, otherUserId, fields["user_id"])
+ assert.Equal(t, "channel_user", fields["roles"])
+ assert.EqualValues(t, 100, fields["msg_count"])
+ assert.Contains(t, fields, "notify_props")
+ })
+}
+
+func TestChannelMemberWithTeamDataMarshalJSON(t *testing.T) {
+ currentUserId := NewId()
+ otherUserId := NewId()
+
+ newMember := func(userId string) ChannelMemberWithTeamData {
+ return ChannelMemberWithTeamData{
+ ChannelMember: ChannelMember{
+ ChannelId: NewId(),
+ UserId: userId,
+ Roles: "channel_user",
+ LastViewedAt: 1234567890000,
+ LastUpdateAt: 1234567890000,
+ NotifyProps: GetDefaultChannelNotifyProps(),
+ },
+ TeamDisplayName: "Test Team",
+ TeamName: "test-team",
+ TeamUpdateAt: 987654321,
+ }
+ }
+
+ decode := func(t *testing.T, member ChannelMemberWithTeamData) map[string]any {
+ t.Helper()
+ data, err := json.Marshal(member)
+ require.NoError(t, err)
+
+ fields := map[string]any{}
+ require.NoError(t, json.Unmarshal(data, &fields))
+ return fields
+ }
+
+ t.Run("preserves team data and timestamps for the current user", func(t *testing.T) {
+ member := newMember(currentUserId)
+ member.SanitizeForCurrentUser(currentUserId)
+
+ fields := decode(t, member)
+ assert.EqualValues(t, 1234567890000, fields["last_viewed_at"])
+ assert.EqualValues(t, 1234567890000, fields["last_update_at"])
+ assert.Equal(t, "Test Team", fields["team_display_name"])
+ assert.Equal(t, "test-team", fields["team_name"])
+ assert.EqualValues(t, 987654321, fields["team_update_at"])
+ })
+
+ t.Run("omits sanitized timestamps but keeps team data for another user", func(t *testing.T) {
+ member := newMember(otherUserId)
+ member.SanitizeForCurrentUser(currentUserId)
+
+ fields := decode(t, member)
+ assert.NotContains(t, fields, "last_viewed_at", "sanitized last_viewed_at must be omitted")
+ assert.NotContains(t, fields, "last_update_at", "sanitized last_update_at must be omitted")
+
+ assert.Equal(t, "Test Team", fields["team_display_name"])
+ assert.Equal(t, "test-team", fields["team_name"])
+ assert.EqualValues(t, 987654321, fields["team_update_at"])
+ assert.Equal(t, otherUserId, fields["user_id"])
+ })
+}
diff --git a/server/public/model/config.go b/server/public/model/config.go
index 0a001809fc66..8d8277da7112 100644
--- a/server/public/model/config.go
+++ b/server/public/model/config.go
@@ -435,12 +435,12 @@ type ServiceSettings struct {
EnableCustomEmoji *bool `access:"site_emoji"`
EnableEmojiPicker *bool `access:"site_emoji"`
PostEditTimeLimit *int `access:"user_management_permissions"`
- TimeBetweenUserTypingUpdatesMilliseconds *int64 `access:"experimental_features,write_restrictable,cloud_restrictable"`
+ TimeBetweenUserTypingUpdatesMilliseconds *int64 `access:"site_posts,write_restrictable,cloud_restrictable"`
EnableCrossTeamSearch *bool `access:"write_restrictable,cloud_restrictable"`
EnablePostSearch *bool `access:"write_restrictable,cloud_restrictable"`
EnableFileSearch *bool `access:"write_restrictable"`
MinimumHashtagLength *int `access:"environment_database,write_restrictable,cloud_restrictable"`
- EnableUserTypingMessages *bool `access:"experimental_features,write_restrictable,cloud_restrictable"`
+ EnableUserTypingMessages *bool `access:"site_posts,write_restrictable,cloud_restrictable"`
EnableChannelViewedMessages *bool `access:"experimental_features,write_restrictable,cloud_restrictable"`
EnableUserStatuses *bool `access:"write_restrictable,cloud_restrictable"`
ExperimentalEnableAuthenticationTransfer *bool `access:"experimental_features"`
@@ -4519,6 +4519,12 @@ func (o *Config) IsValid() *AppError {
return appErr
}
+ if o.FeatureFlags != nil {
+ if appErr := o.FeatureFlags.isValid(); appErr != nil {
+ return appErr
+ }
+ }
+
return nil
}
diff --git a/server/public/model/config_test.go b/server/public/model/config_test.go
index 263eb99957a1..20c47ef673ee 100644
--- a/server/public/model/config_test.go
+++ b/server/public/model/config_test.go
@@ -116,6 +116,39 @@ func TestConfigIsValid(t *testing.T) {
})
}
+func TestFeatureFlagsIsValid(t *testing.T) {
+ t.Run("defaults are valid", func(t *testing.T) {
+ f := &FeatureFlags{}
+ f.SetDefaults()
+ require.Nil(t, f.isValid())
+ })
+
+ t.Run("MoveThreadsEnabled is rejected", func(t *testing.T) {
+ f := &FeatureFlags{}
+ f.SetDefaults()
+ f.MoveThreadsEnabled = true
+
+ appErr := f.isValid()
+ require.NotNil(t, appErr)
+ require.Equal(t, "model.config.is_valid.feature_flags.move_threads_enabled.app_error", appErr.Id)
+ })
+}
+
+func TestConfigIsValidMoveThreadsEnabled(t *testing.T) {
+ c := Config{}
+ c.SetDefaults()
+ require.Nil(t, c.IsValid())
+
+ c.FeatureFlags.MoveThreadsEnabled = true
+ appErr := c.IsValid()
+ require.NotNil(t, appErr)
+ require.Equal(t, "model.config.is_valid.feature_flags.move_threads_enabled.app_error", appErr.Id)
+
+ // A nil FeatureFlags must not panic the validation chain.
+ c.FeatureFlags = nil
+ require.Nil(t, c.IsValid())
+}
+
func TestAccessControlSettingsIsValid(t *testing.T) {
for name, test := range map[string]struct {
AccessControlSettings AccessControlSettings
diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go
index 14894f3630c7..94e849ef6759 100644
--- a/server/public/model/feature_flags.go
+++ b/server/public/model/feature_flags.go
@@ -4,6 +4,7 @@
package model
import (
+ "net/http"
"reflect"
"strconv"
)
@@ -219,6 +220,17 @@ func (f *FeatureFlags) SetDefaults() {
f.RecurringScheduledPosts = false
}
+// isValid rejects feature flag combinations that are no longer supported.
+func (f *FeatureFlags) isValid() *AppError {
+ // MoveThreadsEnabled is being retired in favor of Wrangler, so the server
+ // refuses to start while it is enabled.
+ if f.MoveThreadsEnabled {
+ return NewAppError("FeatureFlags.IsValid", "model.config.is_valid.feature_flags.move_threads_enabled.app_error", nil, "", http.StatusBadRequest)
+ }
+
+ return nil
+}
+
// IsChannelPermissionPoliciesEnabled reports whether channel-scope
// policies may carry permission-rule actions (file upload/download)
// and whether the Channel Settings → Permissions Policy tab should
diff --git a/server/public/model/integration_action.go b/server/public/model/integration_action.go
index 05e16b16489a..9602cb2370c2 100644
--- a/server/public/model/integration_action.go
+++ b/server/public/model/integration_action.go
@@ -467,9 +467,6 @@ type DialogDateTimeConfig struct {
LocationTimezone string `json:"location_timezone,omitempty"`
// ManualTimeEntry: Allow manual text entry for time instead of dropdown
ManualTimeEntry bool `json:"manual_time_entry,omitempty"`
- // Deprecated: Use ManualTimeEntry instead. Kept for backward compatibility;
- // when both are provided, either field being true enables manual time entry.
- AllowManualTimeEntry bool `json:"allow_manual_time_entry,omitempty"`
}
type DialogElement struct {
@@ -492,46 +489,18 @@ type DialogElement struct {
// Date/datetime field configuration
DateTimeConfig *DialogDateTimeConfig `json:"datetime_config,omitempty"`
- // Deprecated: Use DateTimeConfig.MinDate instead. Kept for backward compatibility;
- // if DateTimeConfig is provided, its MinDate takes precedence.
- MinDate string `json:"min_date,omitempty"`
- // Deprecated: Use DateTimeConfig.MaxDate instead. Kept for backward compatibility;
- // if DateTimeConfig is provided, its MaxDate takes precedence.
- MaxDate string `json:"max_date,omitempty"`
- // Deprecated: Use DateTimeConfig.TimeInterval instead. Kept for backward compatibility;
- // if DateTimeConfig is provided, its TimeInterval takes precedence.
- TimeInterval int `json:"time_interval,omitempty"`
// Action button configuration (type "action_button")
ActionButton *DialogActionButton `json:"action_button,omitempty"`
}
-// EffectiveDateTimeConfig returns the resolved date/datetime configuration by
-// merging DateTimeConfig over the deprecated top-level fields (MinDate, MaxDate,
-// TimeInterval). DateTimeConfig values take precedence when set.
+// EffectiveDateTimeConfig returns the resolved date/datetime configuration,
+// treating a nil DateTimeConfig as the zero value.
func (e *DialogElement) EffectiveDateTimeConfig() DialogDateTimeConfig {
- cfg := DialogDateTimeConfig{
- MinDate: e.MinDate,
- MaxDate: e.MaxDate,
- TimeInterval: e.TimeInterval,
- }
if e.DateTimeConfig != nil {
- if e.DateTimeConfig.MinDate != "" {
- cfg.MinDate = e.DateTimeConfig.MinDate
- }
- if e.DateTimeConfig.MaxDate != "" {
- cfg.MaxDate = e.DateTimeConfig.MaxDate
- }
- if e.DateTimeConfig.TimeInterval != 0 {
- cfg.TimeInterval = e.DateTimeConfig.TimeInterval
- }
- cfg.LocationTimezone = e.DateTimeConfig.LocationTimezone
- // ManualTimeEntry is OR'd with the deprecated AllowManualTimeEntry. Booleans can't
- // distinguish explicit-false from not-set across JSON (omitempty drops the zero value),
- // so either field being true must enable the feature during the deprecation window.
- cfg.ManualTimeEntry = e.DateTimeConfig.ManualTimeEntry || e.DateTimeConfig.AllowManualTimeEntry
+ return *e.DateTimeConfig
}
- return cfg
+ return DialogDateTimeConfig{}
}
type DialogActionButton struct {
diff --git a/server/public/model/integration_action_test.go b/server/public/model/integration_action_test.go
index 8b1526ef7501..592850b17edf 100644
--- a/server/public/model/integration_action_test.go
+++ b/server/public/model/integration_action_test.go
@@ -1391,9 +1391,11 @@ func TestDialogElementDateTimeValidation(t *testing.T) {
DisplayName: "Test Date",
Name: "test_date",
Type: "date",
- MinDate: "2025-01-01",
- MaxDate: "2025-12-31",
- Optional: false,
+ DateTimeConfig: &DialogDateTimeConfig{
+ MinDate: "2025-01-01",
+ MaxDate: "2025-12-31",
+ },
+ Optional: false,
}
err := element.IsValid()
assert.NoError(t, err)
@@ -1401,13 +1403,15 @@ func TestDialogElementDateTimeValidation(t *testing.T) {
t.Run("should validate DialogElement with datetime type and time properties", func(t *testing.T) {
element := DialogElement{
- DisplayName: "Test DateTime",
- Name: "test_datetime",
- Type: "datetime",
- MinDate: "2025-01-01T00:00:00Z",
- MaxDate: "2025-12-31T23:59:59Z",
- TimeInterval: 30,
- Optional: false,
+ DisplayName: "Test DateTime",
+ Name: "test_datetime",
+ Type: "datetime",
+ DateTimeConfig: &DialogDateTimeConfig{
+ MinDate: "2025-01-01T00:00:00Z",
+ MaxDate: "2025-12-31T23:59:59Z",
+ TimeInterval: 30,
+ },
+ Optional: false,
}
err := element.IsValid()
assert.NoError(t, err)
@@ -1415,13 +1419,15 @@ func TestDialogElementDateTimeValidation(t *testing.T) {
t.Run("should validate DialogElement with datetime type and relative min/max", func(t *testing.T) {
element := DialogElement{
- DisplayName: "Test DateTime",
- Name: "test_datetime",
- Type: "datetime",
- MinDate: "+2H",
- MaxDate: "+7d",
- TimeInterval: 30,
- Optional: false,
+ DisplayName: "Test DateTime",
+ Name: "test_datetime",
+ Type: "datetime",
+ DateTimeConfig: &DialogDateTimeConfig{
+ MinDate: "+2H",
+ MaxDate: "+7d",
+ TimeInterval: 30,
+ },
+ Optional: false,
}
err := element.IsValid()
assert.NoError(t, err)
@@ -1429,13 +1435,15 @@ func TestDialogElementDateTimeValidation(t *testing.T) {
t.Run("should accept datetime DialogElement with date-only min/max for backward compatibility", func(t *testing.T) {
element := DialogElement{
- DisplayName: "Test DateTime",
- Name: "test_datetime",
- Type: "datetime",
- MinDate: "2025-01-01",
- MaxDate: "2025-12-31",
- TimeInterval: 30,
- Optional: false,
+ DisplayName: "Test DateTime",
+ Name: "test_datetime",
+ Type: "datetime",
+ DateTimeConfig: &DialogDateTimeConfig{
+ MinDate: "2025-01-01",
+ MaxDate: "2025-12-31",
+ TimeInterval: 30,
+ },
+ Optional: false,
}
err := element.IsValid()
assert.NoError(t, err)
@@ -1446,8 +1454,10 @@ func TestDialogElementDateTimeValidation(t *testing.T) {
DisplayName: "Test Date",
Name: "test_date",
Type: "date",
- MinDate: "invalid-date",
- Optional: false,
+ DateTimeConfig: &DialogDateTimeConfig{
+ MinDate: "invalid-date",
+ },
+ Optional: false,
}
err := element.IsValid()
assert.Error(t, err)
@@ -1456,11 +1466,13 @@ func TestDialogElementDateTimeValidation(t *testing.T) {
t.Run("should reject DialogElement with invalid time_interval", func(t *testing.T) {
element := DialogElement{
- DisplayName: "Test DateTime",
- Name: "test_datetime",
- Type: "datetime",
- TimeInterval: -1, // Invalid
- Optional: false,
+ DisplayName: "Test DateTime",
+ Name: "test_datetime",
+ Type: "datetime",
+ DateTimeConfig: &DialogDateTimeConfig{
+ TimeInterval: -1, // Invalid
+ },
+ Optional: false,
}
err := element.IsValid()
assert.Error(t, err)
@@ -1469,11 +1481,13 @@ func TestDialogElementDateTimeValidation(t *testing.T) {
t.Run("should reject DialogElement with time_interval that is not a divisor of 1440", func(t *testing.T) {
element := DialogElement{
- DisplayName: "Test DateTime",
- Name: "test_datetime",
- Type: "datetime",
- TimeInterval: 729, // Invalid - not a divisor of 1440
- Optional: false,
+ DisplayName: "Test DateTime",
+ Name: "test_datetime",
+ Type: "datetime",
+ DateTimeConfig: &DialogDateTimeConfig{
+ TimeInterval: 729, // Invalid - not a divisor of 1440
+ },
+ Optional: false,
}
err := element.IsValid()
assert.Error(t, err)
@@ -1485,11 +1499,13 @@ func TestDialogElementDateTimeValidation(t *testing.T) {
for _, interval := range validIntervals {
element := DialogElement{
- DisplayName: "Test DateTime",
- Name: "test_datetime",
- Type: "datetime",
- TimeInterval: interval,
- Optional: false,
+ DisplayName: "Test DateTime",
+ Name: "test_datetime",
+ Type: "datetime",
+ DateTimeConfig: &DialogDateTimeConfig{
+ TimeInterval: interval,
+ },
+ Optional: false,
}
err := element.IsValid()
assert.NoError(t, err, "time_interval %d should be valid", interval)
@@ -1501,11 +1517,13 @@ func TestDialogElementDateTimeValidation(t *testing.T) {
for _, interval := range invalidIntervals {
element := DialogElement{
- DisplayName: "Test DateTime",
- Name: "test_datetime",
- Type: "datetime",
- TimeInterval: interval,
- Optional: false,
+ DisplayName: "Test DateTime",
+ Name: "test_datetime",
+ Type: "datetime",
+ DateTimeConfig: &DialogDateTimeConfig{
+ TimeInterval: interval,
+ },
+ Optional: false,
}
err := element.IsValid()
assert.Error(t, err, "time_interval %d should be invalid", interval)
@@ -1516,22 +1534,26 @@ func TestDialogElementDateTimeValidation(t *testing.T) {
t.Run("should use default time_interval of 60 minutes when zero", func(t *testing.T) {
// Valid with explicit 60-minute interval
element := DialogElement{
- DisplayName: "Test DateTime",
- Name: "test_datetime",
- Type: "datetime",
- TimeInterval: DefaultTimeIntervalMinutes,
- Optional: false,
+ DisplayName: "Test DateTime",
+ Name: "test_datetime",
+ Type: "datetime",
+ DateTimeConfig: &DialogDateTimeConfig{
+ TimeInterval: DefaultTimeIntervalMinutes,
+ },
+ Optional: false,
}
err := element.IsValid()
assert.NoError(t, err)
// time_interval=0 means omitted — treated as default, should pass validation
element = DialogElement{
- DisplayName: "Test DateTime",
- Name: "test_datetime",
- Type: "datetime",
- TimeInterval: 0,
- Optional: false,
+ DisplayName: "Test DateTime",
+ Name: "test_datetime",
+ Type: "datetime",
+ DateTimeConfig: &DialogDateTimeConfig{
+ TimeInterval: 0,
+ },
+ Optional: false,
}
err = element.IsValid()
assert.NoError(t, err)
@@ -1594,64 +1616,6 @@ func TestDialogElementDateTimeValidation(t *testing.T) {
assert.Contains(t, err.Error(), "divisor of 1440")
})
- t.Run("DateTimeConfig should take precedence over legacy fields", func(t *testing.T) {
- element := DialogElement{
- DisplayName: "Test Date",
- Name: "test_date",
- Type: "date",
- MinDate: "invalid-date",
- DateTimeConfig: &DialogDateTimeConfig{
- MinDate: "2025-01-01",
- },
- }
- cfg := element.EffectiveDateTimeConfig()
- assert.Equal(t, "2025-01-01", cfg.MinDate)
- })
-
- t.Run("legacy fields used when DateTimeConfig not provided", func(t *testing.T) {
- element := DialogElement{
- DisplayName: "Test DateTime",
- Name: "test_datetime",
- Type: "datetime",
- MinDate: "2025-01-01T00:00:00Z",
- MaxDate: "2025-12-31T23:59:59Z",
- TimeInterval: 30,
- }
- cfg := element.EffectiveDateTimeConfig()
- assert.Equal(t, "2025-01-01T00:00:00Z", cfg.MinDate)
- assert.Equal(t, "2025-12-31T23:59:59Z", cfg.MaxDate)
- assert.Equal(t, 30, cfg.TimeInterval)
- })
-
- t.Run("ManualTimeEntry resolves via OR across new and deprecated fields", func(t *testing.T) {
- cases := []struct {
- name string
- newField bool
- oldField bool
- expected bool
- }{
- {"both false", false, false, false},
- {"only new true", true, false, true},
- {"only deprecated true", false, true, true},
- {"both true", true, true, true},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- element := DialogElement{
- DisplayName: "Test DateTime",
- Name: "test_datetime",
- Type: "datetime",
- DateTimeConfig: &DialogDateTimeConfig{
- ManualTimeEntry: tc.newField,
- AllowManualTimeEntry: tc.oldField,
- },
- }
- cfg := element.EffectiveDateTimeConfig()
- assert.Equal(t, tc.expected, cfg.ManualTimeEntry)
- })
- }
- })
-
t.Run("ManualTimeEntry marshals under manual_time_entry JSON key", func(t *testing.T) {
cfg := DialogDateTimeConfig{ManualTimeEntry: true}
b, err := json.Marshal(cfg)
@@ -1659,22 +1623,31 @@ func TestDialogElementDateTimeValidation(t *testing.T) {
assert.Contains(t, string(b), `"manual_time_entry":true`)
})
- t.Run("deprecated allow_manual_time_entry payload still enables manual entry end-to-end", func(t *testing.T) {
- // Simulate a legacy integrator sending only the deprecated field.
- payload := []byte(`{"allow_manual_time_entry":true}`)
+ t.Run("removed legacy top-level fields are silently ignored on unmarshal", func(t *testing.T) {
+ // MM-68396: min_date, max_date, and time_interval are no longer DialogElement
+ // fields (moved to DateTimeConfig). This documents the intended breaking-change
+ // behavior for integrations still sending them at the top level: encoding/json
+ // drops unrecognized keys, so the element ends up with no date/datetime config
+ // and IsValid() no longer applies constraints derived from them.
+ payload := []byte(`{
+ "display_name": "Test Date",
+ "name": "test_date",
+ "type": "date",
+ "min_date": "invalid-date",
+ "max_date": "2025-12-31"
+ }`)
+ var element DialogElement
+ require.NoError(t, json.Unmarshal(payload, &element))
+
+ assert.Nil(t, element.DateTimeConfig, "legacy top-level fields must not populate DateTimeConfig")
+ assert.NoError(t, element.IsValid(), "an invalid legacy min_date must no longer fail validation since the field is unrecognized")
+ })
+
+ t.Run("removed deprecated AllowManualTimeEntry is silently ignored on unmarshal", func(t *testing.T) {
+ payload := []byte(`{"allow_manual_time_entry": true}`)
var cfg DialogDateTimeConfig
require.NoError(t, json.Unmarshal(payload, &cfg))
- require.False(t, cfg.ManualTimeEntry, "new field should remain zero-value after unmarshal")
- require.True(t, cfg.AllowManualTimeEntry, "deprecated field should unmarshal under its legacy tag")
-
- element := DialogElement{
- DisplayName: "Test",
- Name: "t",
- Type: "datetime",
- DateTimeConfig: &cfg,
- }
- effective := element.EffectiveDateTimeConfig()
- assert.True(t, effective.ManualTimeEntry, "deprecated field alone should enable manual entry after EffectiveDateTimeConfig")
+ assert.False(t, cfg.ManualTimeEntry, "the deprecated key must no longer populate ManualTimeEntry")
})
}
diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx
index 9861b2d3ae69..5e132d6860a7 100644
--- a/webapp/channels/src/components/admin_console/admin_definition.tsx
+++ b/webapp/channels/src/components/admin_console/admin_definition.tsx
@@ -3814,6 +3814,26 @@ const AdminDefinition: AdminDefinitionType = {
return new ValidationResult(true, '');
},
},
+ {
+ type: 'bool',
+ key: 'ServiceSettings.EnableUserTypingMessages',
+ label: defineMessage({id: 'admin.posts.enableUserTypingMessages.title', defaultMessage: 'Enable User Typing Messages:'}),
+ help_text: defineMessage({id: 'admin.posts.enableUserTypingMessages.desc', defaultMessage: 'This setting determines whether "user is typing..." messages are displayed below the message box. Disabling the setting in larger deployments may improve server performance.'}),
+ help_text_markdown: false,
+ isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.POSTS)),
+ },
+ {
+ type: 'number',
+ key: 'ServiceSettings.TimeBetweenUserTypingUpdatesMilliseconds',
+ label: defineMessage({id: 'admin.posts.timeBetweenUserTypingUpdatesMilliseconds.title', defaultMessage: 'User Typing Timeout (milliseconds):'}),
+ help_text: defineMessage({id: 'admin.posts.timeBetweenUserTypingUpdatesMilliseconds.desc', defaultMessage: 'The number of milliseconds to wait between emitting user typing websocket events.'}),
+ help_text_markdown: false,
+ placeholder: defineMessage({id: 'admin.posts.timeBetweenUserTypingUpdatesMilliseconds.example', defaultMessage: 'E.g.: "5000"'}),
+ isDisabled: it.any(
+ it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.POSTS)),
+ it.stateIsFalse('ServiceSettings.EnableUserTypingMessages'),
+ ),
+ },
],
},
],
@@ -6679,33 +6699,13 @@ const AdminDefinition: AdminDefinitionType = {
help_text_markdown: false,
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
- {
- type: 'bool',
- key: 'ServiceSettings.EnableUserTypingMessages',
- label: defineMessage({id: 'admin.experimental.enableUserTypingMessages.title', defaultMessage: 'Enable User Typing Messages:'}),
- help_text: defineMessage({id: 'admin.experimental.enableUserTypingMessages.desc', defaultMessage: 'This setting determines whether "user is typing..." messages are displayed below the message box. Disabling the setting in larger deployments may improve server performance.'}),
- help_text_markdown: false,
- isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
- },
- {
- type: 'number',
- key: 'ServiceSettings.TimeBetweenUserTypingUpdatesMilliseconds',
- label: defineMessage({id: 'admin.experimental.timeBetweenUserTypingUpdatesMilliseconds.title', defaultMessage: 'User Typing Timeout:'}),
- help_text: defineMessage({id: 'admin.experimental.timeBetweenUserTypingUpdatesMilliseconds.desc', defaultMessage: 'The number of milliseconds to wait between emitting user typing websocket events.'}),
- help_text_markdown: false,
- placeholder: defineMessage({id: 'admin.experimental.timeBetweenUserTypingUpdatesMilliseconds.example', defaultMessage: 'E.g.: "5000"'}),
- isDisabled: it.any(
- it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
- it.stateIsFalse('ServiceSettings.EnableUserTypingMessages'),
- ),
- },
{
type: 'number',
key: 'ExperimentalSettings.UsersStatusAndProfileFetchingPollIntervalMilliseconds',
label: defineMessage({id: 'admin.experimental.UsersStatusAndProfileFetchingPollIntervalMilliseconds.title', defaultMessage: 'User\'s Status and Profile Fetching Poll Interval:'}),
help_text: defineMessage({id: 'admin.experimental.UsersStatusAndProfileFetchingPollIntervalMilliseconds.desc', defaultMessage: 'The number of milliseconds to wait between fetching user statuses and profiles periodically.'}),
help_text_markdown: false,
- placeholder: defineMessage({id: 'admin.experimental.timeBetweenUserTypingUpdatesMilliseconds.example', defaultMessage: 'E.g.: "5000"'}),
+ placeholder: defineMessage({id: 'admin.experimental.UsersStatusAndProfileFetchingPollIntervalMilliseconds.example', defaultMessage: 'E.g.: "5000"'}),
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
{
diff --git a/webapp/channels/src/components/admin_console/global_attributes/global_attribute_delete_modal.test.tsx b/webapp/channels/src/components/admin_console/global_attributes/global_attribute_delete_modal.test.tsx
new file mode 100644
index 000000000000..b13603752f42
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/global_attributes/global_attribute_delete_modal.test.tsx
@@ -0,0 +1,68 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+
+import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
+
+import GlobalAttributeDeleteModal from './global_attribute_delete_modal';
+
+describe('GlobalAttributeDeleteModal', () => {
+ const renderModal = (overrides: Partial> = {}) => {
+ const props = {
+ name: 'Department',
+ onConfirm: jest.fn(),
+ onExited: jest.fn(),
+ ...overrides,
+ };
+ renderWithContext();
+ return props;
+ };
+
+ it('names the attribute being deleted in the title, rather than a generic prompt', () => {
+ renderModal({name: 'Department'});
+
+ expect(screen.getByRole('heading', {name: /delete department attribute/i})).toBeInTheDocument();
+ expect(screen.getByText(/permanently remove its definition/i)).toBeInTheDocument();
+ });
+
+ it('invokes onConfirm when the Delete button is clicked', async () => {
+ const props = renderModal();
+
+ await userEvent.click(screen.getByRole('button', {name: /^delete$/i}));
+
+ expect(props.onConfirm).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not invoke onConfirm when the Cancel button is clicked', async () => {
+ const props = renderModal();
+
+ await userEvent.click(screen.getByRole('button', {name: /cancel/i}));
+
+ expect(props.onConfirm).not.toHaveBeenCalled();
+ });
+
+ describe('orphaned attribute', () => {
+ it('names the uninstalled plugin the attribute was left behind by', () => {
+ renderModal({isOrphaned: true, sourcePluginId: 'com.acme.plugin'});
+
+ expect(screen.getByText(/was created by the plugin "com\.acme\.plugin", which is no longer installed/i)).toBeInTheDocument();
+
+ // * The standard warning is kept alongside it rather than replaced — an
+ // orphaned attribute is just as permanently deleted as any other
+ expect(screen.getByText(/permanently remove its definition/i)).toBeInTheDocument();
+ });
+
+ it('falls back to "unknown" when the source plugin id is missing', () => {
+ renderModal({isOrphaned: true});
+
+ expect(screen.getByText(/was created by the plugin "unknown"/i)).toBeInTheDocument();
+ });
+
+ it('says nothing about plugins for an ordinary attribute', () => {
+ renderModal();
+
+ expect(screen.queryByText(/no longer installed/i)).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/webapp/channels/src/components/admin_console/global_attributes/global_attribute_delete_modal.tsx b/webapp/channels/src/components/admin_console/global_attributes/global_attribute_delete_modal.tsx
new file mode 100644
index 000000000000..edac4752953d
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/global_attributes/global_attribute_delete_modal.tsx
@@ -0,0 +1,106 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {FormattedMessage, useIntl} from 'react-intl';
+import {useDispatch} from 'react-redux';
+
+import {GenericModal} from '@mattermost/components';
+
+import {openModal} from 'actions/views/modals';
+
+import {ModalIdentifiers} from 'utils/constants';
+
+type Props = {
+ name: string;
+ onConfirm: () => void;
+ onExited: () => void;
+ isOrphaned?: boolean;
+ sourcePluginId?: string;
+};
+
+// GenericModal only renders a Cancel button when handleCancel is supplied, and
+// cancelling needs no side effect here beyond closing — same shape as
+// user_properties_delete_modal.
+const noop = () => {};
+
+/**
+ * Opens the delete-confirmation modal for a Global Attribute. The modal is
+ * display-only: `onConfirm` fires the caller's own delete logic, which owns the
+ * API call and its error handling. Mirrors useUserPropertyFieldDelete /
+ * useBoardAttributeFieldDelete, but passes the callback in rather than resolving
+ * a Promise, since the caller's handler is async and reports its own failures.
+ *
+ * Pass `orphan` when the field's source plugin is no longer installed, so the
+ * confirmation can explain where the leftover attribute came from. It is an
+ * object rather than a bare flag so there is no way to declare a field orphaned
+ * without supplying the plugin it came from.
+ *
+ * `onExited` runs once the modal has finished closing, whether it was confirmed
+ * or cancelled. ModalController composes it with its own close handling, so the
+ * caller's callback runs after the dialog is actually gone -- which is when it is
+ * safe to move focus or scroll, since react-bootstrap restores focus to whatever
+ * opened the modal on the way out.
+ */
+export const useGlobalAttributeFieldDelete = () => {
+ const dispatch = useDispatch();
+
+ return (name: string, onConfirm: () => void, orphan?: {sourcePluginId?: string}, onExited?: () => void) => {
+ dispatch(openModal({
+ modalId: ModalIdentifiers.GLOBAL_ATTRIBUTE_FIELD_DELETE,
+ dialogType: GlobalAttributeDeleteModal,
+ dialogProps: {
+ name,
+ onConfirm,
+ isOrphaned: Boolean(orphan),
+ sourcePluginId: orphan?.sourcePluginId,
+ onExited,
+ },
+ }));
+ };
+};
+
+function GlobalAttributeDeleteModal({name, onConfirm, onExited, isOrphaned = false, sourcePluginId}: Props) {
+ const {formatMessage} = useIntl();
+
+ const title = formatMessage({
+ id: 'admin.global_attributes.confirm.delete.title',
+ defaultMessage: 'Delete {name} attribute',
+ }, {name});
+
+ const confirmButtonText = formatMessage({
+ id: 'admin.system_properties.confirm.delete.button',
+ defaultMessage: 'Delete',
+ });
+
+ return (
+
+ {/* An uninstalled plugin leaves no manifest behind to resolve a display
+ name from, so the raw source_plugin_id is the only identifier we can
+ honestly show here. */}
+ {isOrphaned && (
+
+
+
+ )}
+
+
+ );
+}
+
+export default GlobalAttributeDeleteModal;
diff --git a/webapp/channels/src/components/admin_console/global_attributes/global_attributes_table.test.tsx b/webapp/channels/src/components/admin_console/global_attributes/global_attributes_table.test.tsx
index 14938ccd62cc..e1cbe40da12e 100644
--- a/webapp/channels/src/components/admin_console/global_attributes/global_attributes_table.test.tsx
+++ b/webapp/channels/src/components/admin_console/global_attributes/global_attributes_table.test.tsx
@@ -1,9 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
-import {screen, waitFor} from '@testing-library/react';
+import {act, screen, waitFor, within} from '@testing-library/react';
import React from 'react';
+import {ClientError} from '@mattermost/client';
import {ChevronDownCircleOutlineIcon, FormatListBulletedIcon, MenuVariantIcon, PowerPlugOutlineIcon, SortAscendingIcon, SyncIcon} from '@mattermost/compass-icons/components';
import type {PropertyField} from '@mattermost/types/properties';
import type {DeepPartial} from '@mattermost/types/utilities';
@@ -15,6 +16,7 @@ import {
CLASSIFICATIONS_TEMPLATE_FIELD_NAME,
CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE,
} from 'components/admin_console/classification_markings/utils';
+import ModalController from 'components/modal_controller';
import {renderWithContext, userEvent} from 'tests/react_testing_utils';
import {WindowSizes} from 'utils/constants';
@@ -47,6 +49,14 @@ function makeField(overrides: Partial = {}): PropertyField {
} as PropertyField;
}
+function makeClientError(statusCode: number): ClientError {
+ return new ClientError('https://example.com', {
+ message: 'error',
+ status_code: statusCode,
+ url: 'https://example.com/api/v4/properties/groups/access_control/template/fields/field-1',
+ });
+}
+
function getBaseState(): DeepPartial {
return {
entities: {
@@ -348,6 +358,50 @@ describe('GlobalAttributesTable', () => {
expect(cell.querySelector('svg')).toBeInTheDocument();
});
+ it('resolves a server-only plugin name from the admin plugin statuses rather than showing the raw plugin ID', async () => {
+ const getPluginStatuses = jest.spyOn(Client4, 'getPluginStatuses').mockResolvedValue([{
+ plugin_id: 'com.mattermost.gahelper',
+ name: 'Global Attributes Helper',
+ description: '',
+ version: '1.0.0',
+ cluster_id: '',
+ plugin_path: '',
+ state: 1,
+ }]);
+
+ // No entry in state.plugins: a server-only plugin ships no webapp bundle,
+ // so it never registers a client manifest.
+ getPropertyFields.mockResolvedValueOnce([makeField({
+ attrs: {source_plugin_id: 'com.mattermost.gahelper', protected: true},
+ })]).mockResolvedValue([]);
+
+ renderWithContext(, getBaseState());
+
+ await waitFor(() => {
+ expect(getPluginStatuses).toHaveBeenCalled();
+ });
+
+ const cell = await screen.findByTestId('global-attribute-source');
+ expect(cell).toHaveTextContent('Global Attributes Helper');
+ expect(cell).not.toHaveTextContent('com.mattermost.gahelper');
+
+ getPluginStatuses.mockRestore();
+ });
+
+ it('does not fetch plugin statuses when no row is plugin-owned', async () => {
+ const getPluginStatuses = jest.spyOn(Client4, 'getPluginStatuses').mockResolvedValue([]);
+
+ getPropertyFields.mockResolvedValueOnce([makeField({attrs: {ldap: 'someAttribute'}})]).mockResolvedValue([]);
+
+ renderWithContext(, getBaseState());
+
+ await screen.findByTestId('global-attribute-source');
+
+ expect(getPluginStatuses).not.toHaveBeenCalled();
+
+ getPluginStatuses.mockRestore();
+ });
+
it('shows AD/LDAP when attrs.ldap is set', async () => {
getPropertyFields.mockResolvedValueOnce([makeField({attrs: {ldap: 'someAttribute'}})]).mockResolvedValue([]);
@@ -403,7 +457,7 @@ describe('GlobalAttributesTable', () => {
});
describe('Actions column', () => {
- it('opens the menu with Edit/Duplicate/Delete rendered visibly disabled, not a silent no-op', async () => {
+ it('opens the menu with Edit/Duplicate still visibly disabled and Delete enabled', async () => {
getPropertyFields.mockResolvedValueOnce([makeField()]).mockResolvedValue([]);
renderWithContext(, getBaseState());
@@ -427,12 +481,341 @@ describe('GlobalAttributesTable', () => {
expect(edit!).toHaveAttribute('aria-disabled', 'true');
expect(duplicate!).toHaveAttribute('aria-disabled', 'true');
- expect(del!).toHaveAttribute('aria-disabled', 'true');
- // * Each disabled item explains why, rather than silently doing nothing
+ // * Each still-stubbed item explains why, rather than silently doing nothing
expect(edit!).toHaveTextContent('Coming soon');
expect(duplicate!).toHaveTextContent('Coming soon');
- expect(del!).toHaveTextContent('Coming soon');
+
+ // * Delete is live now, so it carries neither the disabled state nor the stub label
+ expect(del!).not.toHaveAttribute('aria-disabled', 'true');
+ expect(del!).not.toHaveTextContent('Coming soon');
+ });
+ });
+
+ describe('Delete action', () => {
+ const deletePropertyField = jest.spyOn(Client4, 'deletePropertyField');
+
+ beforeEach(() => {
+ deletePropertyField.mockReset();
+ });
+
+ // The class here is not decoration: the System Console scrolls in
+ // .admin-console__wrapper, and that is the ancestor the table pulls back to
+ // the top when a delete fails. jsdom implements no scrolling at all, so the
+ // method is stubbed to record the call.
+ function renderTable(fields: PropertyField[], state: DeepPartial = getBaseState()) {
+ getPropertyFields.mockResolvedValueOnce(fields).mockResolvedValue([]);
+
+ renderWithContext(
+
+
+
+
,
+ state,
+ );
+
+ const scrollTo = jest.fn();
+ Object.assign(document.querySelector('.admin-console__wrapper')!, {scrollTo});
+
+ return {scrollTo};
+ }
+
+ // A plugin-owned row is server-protected only while its plugin is still
+ // installed, so these tests have to state which plugins the admin console
+ // believes are installed. Without this the row reads as orphaned.
+ function getStateWithInstalledPlugin(pluginId: string): DeepPartial {
+ const state = getBaseState();
+ state.entities!.admin = {
+ pluginStatuses: {[pluginId]: {id: pluginId}},
+ } as EntitiesPartial['admin'];
+ return state;
+ }
+
+ const PLUGIN_ID = 'com.acme.plugin';
+
+ function makePluginOwnedField() {
+ return makeField({attrs: {display_name: 'Department', source_plugin_id: PLUGIN_ID, protected: true}});
+ }
+
+ async function openDeleteModal(fieldId = 'field-1') {
+ await userEvent.click(await screen.findByTestId(`global-attribute-actions-${fieldId}`));
+
+ const del = screen.getAllByRole('menuitem').find((el) => el.textContent?.includes('Delete attribute'));
+ await userEvent.click(del!);
+ }
+
+ it('names the attribute in the confirmation modal instead of deleting straight from the menu', async () => {
+ renderTable([makeField({attrs: {display_name: 'Department'}})]);
+
+ await openDeleteModal();
+
+ expect(await screen.findByRole('heading', {name: /delete department attribute/i})).toBeInTheDocument();
+
+ // * Opening the modal alone must not have fired the destructive call
+ expect(deletePropertyField).not.toHaveBeenCalled();
+ });
+
+ it('leaves the row and the API untouched when the modal is cancelled', async () => {
+ renderTable([makeField({attrs: {display_name: 'Department'}})]);
+
+ await openDeleteModal();
+ await userEvent.click(await screen.findByRole('button', {name: /cancel/i}));
+
+ expect(deletePropertyField).not.toHaveBeenCalled();
+ expect(screen.getByTestId('global-attribute-name')).toHaveTextContent('Department');
+ });
+
+ it('deletes via the access_control/template scope and drops the row on success', async () => {
+ deletePropertyField.mockResolvedValue({status: 'OK'});
+ renderTable([makeField({attrs: {display_name: 'Department'}})]);
+
+ await openDeleteModal();
+ await userEvent.click(await screen.findByRole('button', {name: /^delete$/i}));
+
+ await waitFor(() => {
+ expect(deletePropertyField).toHaveBeenCalledWith('access_control', 'template', 'field-1');
+ });
+
+ // * The row is gone because the reducer removed the field, not because the
+ // component hid it locally — the last-attribute empty state proves the store changed
+ expect(await screen.findByTestId('global-attributes-empty')).toBeInTheDocument();
+ });
+
+ it('surfaces a generic banner above the table and keeps the row when the delete fails', async () => {
+ deletePropertyField.mockRejectedValue(makeClientError(500));
+ renderTable([makeField({attrs: {display_name: 'Department'}})]);
+
+ await openDeleteModal();
+ await userEvent.click(await screen.findByRole('button', {name: /^delete$/i}));
+
+ const banner = await screen.findByTestId('global-attributes-delete-error');
+ expect(banner).toHaveTextContent('An error occurred while deleting this attribute. Please try again.');
+
+ // * The row survives a failed delete
+ expect(screen.getByTestId('global-attribute-name')).toHaveTextContent('Department');
+ });
+
+ it('explains the blocking dependency rather than showing the generic error on a 409', async () => {
+ deletePropertyField.mockRejectedValue(makeClientError(409));
+ renderTable([makeField({attrs: {display_name: 'Department'}})]);
+
+ await openDeleteModal();
+ await userEvent.click(await screen.findByRole('button', {name: /^delete$/i}));
+
+ const banner = await screen.findByTestId('global-attributes-delete-error');
+ expect(banner).toHaveTextContent(/other attributes are still linked to it/i);
+ expect(banner).not.toHaveTextContent('An error occurred while deleting this attribute');
+ });
+
+ it('dismisses the error banner without re-running the delete', async () => {
+ deletePropertyField.mockRejectedValue(makeClientError(500));
+ renderTable([makeField({attrs: {display_name: 'Department'}})]);
+
+ await openDeleteModal();
+ await userEvent.click(await screen.findByRole('button', {name: /^delete$/i}));
+
+ const banner = await screen.findByTestId('global-attributes-delete-error');
+
+ // The modal aria-hides the page behind it, so wait for it to tear down before
+ // reaching for the banner's own dismiss control by role
+ await waitFor(() => {
+ expect(screen.queryByText(/permanently remove its definition/i)).not.toBeInTheDocument();
+ });
+
+ await userEvent.click(within(banner).getByRole('button', {name: /close/i}));
+
+ expect(screen.queryByTestId('global-attributes-delete-error')).not.toBeInTheDocument();
+ expect(deletePropertyField).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps the error live region mounted so the banner is announced when it appears', async () => {
+ deletePropertyField.mockRejectedValue(makeClientError(500));
+ renderTable([makeField({attrs: {display_name: 'Department'}})]);
+
+ // * The region exists before any error, so the banner arriving is a content
+ // change inside a live region rather than a newly-inserted region — the
+ // latter is not reliably announced
+ const liveRegion = await screen.findByRole('alert');
+ expect(liveRegion).toBeEmptyDOMElement();
+
+ await openDeleteModal();
+ await userEvent.click(await screen.findByRole('button', {name: /^delete$/i}));
+
+ // * The node captured before the error now carries the message. A region
+ // remounted alongside its content would have left this reference detached
+ // and empty, so this also proves the region persisted.
+ await waitFor(() => {
+ expect(liveRegion).toHaveTextContent('An error occurred while deleting this attribute');
+ });
+ expect(liveRegion).toBeInTheDocument();
+ });
+
+ it('scrolls the page back to the top and takes focus once a failed delete has closed the modal', async () => {
+ deletePropertyField.mockRejectedValue(makeClientError(500));
+ const {scrollTo} = renderTable([makeField({attrs: {display_name: 'Department'}})]);
+
+ await openDeleteModal();
+ await userEvent.click(await screen.findByRole('button', {name: /^delete$/i}));
+
+ const liveRegion = await screen.findByRole('alert');
+
+ await waitFor(() => {
+ expect(scrollTo).toHaveBeenCalledWith({top: 0});
+ });
+
+ // * Focus lands on the banner rather than being restored to the row's
+ // actions button, which is what would otherwise scroll the page away
+ // from the error again
+ expect(liveRegion).toHaveFocus();
+ });
+
+ it('still scrolls to the error when the delete outlasts the modal close animation', async () => {
+ // GenericModal starts closing before it calls handleConfirm, so a slow
+ // request can land after the modal is already gone — the reverse of the
+ // usual order, and the case a plain onExited hook would miss
+ let failDelete: (error: unknown) => void = () => {};
+ deletePropertyField.mockImplementation(() => new Promise((_resolve, reject) => {
+ failDelete = reject;
+ }));
+
+ const {scrollTo} = renderTable([makeField({attrs: {display_name: 'Department'}})]);
+
+ await openDeleteModal();
+ await userEvent.click(await screen.findByRole('button', {name: /^delete$/i}));
+
+ await waitFor(() => {
+ expect(screen.queryByText(/permanently remove its definition/i)).not.toBeInTheDocument();
+ });
+ expect(scrollTo).not.toHaveBeenCalled();
+
+ await act(async () => {
+ failDelete(makeClientError(500));
+ });
+
+ await waitFor(() => {
+ expect(scrollTo).toHaveBeenCalledWith({top: 0});
+ });
+ });
+
+ it('leaves the scroll position alone when the delete succeeds', async () => {
+ deletePropertyField.mockResolvedValue({status: 'OK'});
+ const {scrollTo} = renderTable([makeField({attrs: {display_name: 'Department'}})]);
+
+ await openDeleteModal();
+ await userEvent.click(await screen.findByRole('button', {name: /^delete$/i}));
+
+ expect(await screen.findByTestId('global-attributes-empty')).toBeInTheDocument();
+ expect(scrollTo).not.toHaveBeenCalled();
+ });
+
+ it('leaves the scroll position alone when the modal is cancelled', async () => {
+ const {scrollTo} = renderTable([makeField({attrs: {display_name: 'Department'}})]);
+
+ await openDeleteModal();
+ await userEvent.click(await screen.findByRole('button', {name: /cancel/i}));
+
+ await waitFor(() => {
+ expect(screen.queryByText(/permanently remove its definition/i)).not.toBeInTheDocument();
+ });
+ expect(scrollTo).not.toHaveBeenCalled();
+ });
+
+ it('keeps scrolling to the error on a second failed delete', async () => {
+ deletePropertyField.mockRejectedValue(makeClientError(500));
+ const {scrollTo} = renderTable([makeField({attrs: {display_name: 'Department'}})]);
+
+ await openDeleteModal();
+ await userEvent.click(await screen.findByRole('button', {name: /^delete$/i}));
+ await waitFor(() => {
+ expect(scrollTo).toHaveBeenCalledTimes(1);
+ });
+
+ await openDeleteModal();
+ await userEvent.click(await screen.findByRole('button', {name: /^delete$/i}));
+
+ // * The behaviour is per-attempt rather than one-shot. Note this does not
+ // pin down *when* the second scroll fires: jsdom completes the modal's
+ // fade before the rejection lands, so the ordering the component re-arms
+ // for is not reproducible here.
+ await waitFor(() => {
+ expect(scrollTo).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it('keeps Delete disabled with a reason on a plugin-owned row while the plugin is installed', async () => {
+ renderTable([makePluginOwnedField()], getStateWithInstalledPlugin(PLUGIN_ID));
+
+ await userEvent.click(await screen.findByTestId('global-attribute-actions-field-1'));
+
+ const del = screen.getAllByRole('menuitem').find((el) => el.textContent?.includes('Delete attribute'));
+ expect(del!).toHaveAttribute('aria-disabled', 'true');
+ expect(del!).toHaveTextContent('Plugin-managed');
+
+ // pointerEventsCheck: 0 forces the click past the disabled item's
+ // `pointer-events: none`, proving no handler is wired underneath the styling
+ await userEvent.click(del!, {pointerEventsCheck: 0});
+
+ // * No modal, no API call — the disabled item is inert, not just styled as disabled
+ expect(screen.queryByRole('heading', {name: /delete department attribute/i})).not.toBeInTheDocument();
+ expect(deletePropertyField).not.toHaveBeenCalled();
+ });
+
+ it('re-enables Delete on a plugin-owned row once the plugin is uninstalled, so the leftover can be cleaned up', async () => {
+ deletePropertyField.mockResolvedValue({status: 'OK'});
+
+ // No plugin statuses at all: the source plugin is gone, which is what the
+ // server itself keys the delete allowance off (checkFieldDeleteAccess)
+ renderTable([makePluginOwnedField()]);
+
+ await userEvent.click(await screen.findByTestId('global-attribute-actions-field-1'));
+
+ const del = screen.getAllByRole('menuitem').find((el) => el.textContent?.includes('Delete attribute'));
+ expect(del!).not.toHaveAttribute('aria-disabled', 'true');
+ expect(del!).not.toHaveTextContent('Plugin-managed');
+
+ await userEvent.click(del!);
+
+ // * The confirmation names the plugin the leftover came from, since an
+ // uninstalled plugin is otherwise invisible to the admin
+ expect(await screen.findByRole('heading', {name: /delete department attribute/i})).toBeInTheDocument();
+ expect(screen.getByText(/was created by the plugin "com\.acme\.plugin", which is no longer installed/i)).toBeInTheDocument();
+
+ await userEvent.click(await screen.findByRole('button', {name: /^delete$/i}));
+
+ await waitFor(() => {
+ expect(deletePropertyField).toHaveBeenCalledWith('access_control', 'template', 'field-1');
+ });
+ });
+
+ it('treats a plugin-owned row as protected while the plugin inventory is still in flight', async () => {
+ // An inventory that has not arrived looks byte-for-byte like a server with
+ // the plugin uninstalled, so only the settled fetch tells the two apart.
+ // This one never settles, pinning the row in the not-yet-known state; the
+ // 're-enables Delete' test above covers the settled side.
+ const getPluginStatuses = jest.spyOn(Client4, 'getPluginStatuses').
+ mockImplementation(() => new Promise(() => {}));
+
+ renderTable([makePluginOwnedField()]);
+
+ await userEvent.click(await screen.findByTestId('global-attribute-actions-field-1'));
+
+ const del = (await screen.findAllByRole('menuitem')).find((el) => el.textContent?.includes('Delete attribute'));
+
+ // * Without the gate the empty inventory reads as "plugin gone", offering
+ // Delete behind a dialog that wrongly says the plugin was uninstalled
+ expect(del!).toHaveAttribute('aria-disabled', 'true');
+ expect(del!).toHaveTextContent('Plugin-managed');
+
+ getPluginStatuses.mockRestore();
+ });
+
+ it('omits the plugin explanation for an ordinary attribute', async () => {
+ renderTable([makeField({attrs: {display_name: 'Department'}})]);
+
+ await openDeleteModal();
+
+ expect(await screen.findByText(/permanently remove its definition/i)).toBeInTheDocument();
+ expect(screen.queryByText(/no longer installed/i)).not.toBeInTheDocument();
});
});
diff --git a/webapp/channels/src/components/admin_console/global_attributes/global_attributes_table.tsx b/webapp/channels/src/components/admin_console/global_attributes/global_attributes_table.tsx
index a02eb1df7155..90f8943384d3 100644
--- a/webapp/channels/src/components/admin_console/global_attributes/global_attributes_table.tsx
+++ b/webapp/channels/src/components/admin_console/global_attributes/global_attributes_table.tsx
@@ -4,18 +4,21 @@
import {createColumnHelper, getCoreRowModel, useReactTable, type ColumnDef} from '@tanstack/react-table';
import classNames from 'classnames';
import type {ComponentType} from 'react';
-import React, {useEffect, useMemo, useState} from 'react';
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import type {MessageDescriptor} from 'react-intl';
import {FormattedMessage, defineMessages, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {Link} from 'react-router-dom';
+import type {ClientError} from '@mattermost/client';
import {ChevronDownCircleOutlineIcon, ContentCopyIcon, DotsHorizontalIcon, FormatListBulletedIcon, MenuVariantIcon, OpenInNewIcon, PencilOutlineIcon, PowerPlugOutlineIcon, SortAscendingIcon, SyncIcon, TrashCanOutlineIcon} from '@mattermost/compass-icons/components';
import type IconProps from '@mattermost/compass-icons/components/props';
import {WithTooltip} from '@mattermost/shared/components/tooltip';
import type {FieldType, PropertyField, PropertyFieldOption} from '@mattermost/types/properties';
import {supportsOptions} from '@mattermost/types/properties';
+import PropertyTypes from 'mattermost-redux/action_types/properties';
+import {getPluginStatuses} from 'mattermost-redux/actions/admin';
import {fetchPropertyFields} from 'mattermost-redux/actions/properties';
import {getConfig as getAdminConfig} from 'mattermost-redux/selectors/entities/admin';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
@@ -29,6 +32,8 @@ import {
CLASSIFICATIONS_TEMPLATE_FIELD_NAME,
CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE,
} from 'components/admin_console/classification_markings/utils';
+import AlertBanner from 'components/alert_banner';
+import {useIsFieldOrphaned} from 'components/common/hooks/use_field_orphaned';
import LoadingScreen from 'components/loading_screen';
import * as Menu from 'components/menu';
@@ -37,6 +42,8 @@ import {LicenseSkus} from 'utils/constants';
import type {GlobalState} from 'types/store';
import {GLOBAL_ATTRIBUTES_GROUP_NAME, GLOBAL_ATTRIBUTES_OBJECT_TYPE, GLOBAL_ATTRIBUTES_TARGET_TYPE} from './constants';
+import {useGlobalAttributeFieldDelete} from './global_attribute_delete_modal';
+import {deleteAttributeField} from './utils';
import {it} from '../admin_definition_helpers';
import {AdminConsoleListTable} from '../list_table';
@@ -204,10 +211,42 @@ function AttributeCell({field, isClassificationRow}: ClassificationAwareCellProp
);
}
-function ActionsCell({field, isClassificationRow, isMobileView}: ClassificationAwareCellProps & {isMobileView: boolean}) {
+type ActionsCellProps = ClassificationAwareCellProps & {
+ isMobileView: boolean;
+ pluginInventoryLoaded: boolean;
+ onDeleteError: (message: string | null) => void;
+ onDeleteModalExited: () => void;
+};
+
+function ActionsCell({field, isClassificationRow, isMobileView, pluginInventoryLoaded, onDeleteError, onDeleteModalExited}: ActionsCellProps) {
const {formatMessage} = useIntl();
+ const dispatch = useDispatch();
+ const promptDelete = useGlobalAttributeFieldDelete();
const menuId = `global-attribute-actions-${field.id}`;
+ // A plugin-owned field is server-protected only while its plugin is installed,
+ // so the item stays disabled with a reason rather than offering a dead action.
+ // Once the plugin is uninstalled the server allows the delete (see
+ // checkFieldDeleteAccess in server/channels/app/properties/access_control.go) —
+ // that is how an admin cleans up what the plugin left behind.
+ // Not short-circuited into the hook call, which has to run unconditionally.
+ const fieldLooksOrphaned = useIsFieldOrphaned(field);
+ const isOrphaned = pluginInventoryLoaded && fieldLooksOrphaned;
+ const isPluginManaged = getSourceKind(field) === 'plugin' && !isOrphaned;
+
+ const handleConfirmed = useCallback(async () => {
+ onDeleteError(null);
+
+ try {
+ await deleteAttributeField(field.id);
+ dispatch({type: PropertyTypes.PROPERTY_FIELD_DELETED, data: {fieldId: field.id}});
+ } catch (error) {
+ onDeleteError(formatMessage(
+ (error as ClientError)?.status_code === 409 ? actionsLabels.deleteErrorHasDependents : actionsLabels.deleteErrorGeneric,
+ ));
+ }
+ }, [dispatch, field.id, formatMessage, onDeleteError]);
+
if (isClassificationRow) {
const classificationLinkLabel = formatMessage(actionsLabels.classificationLink);
@@ -273,13 +312,19 @@ function ActionsCell({field, isClassificationRow, isMobileView}: ClassificationA
/>