From 2b7b398a2225d166cfe6eb7b6796d037c0dcef87 Mon Sep 17 00:00:00 2001 From: David Krauser Date: Tue, 28 Apr 2026 16:02:41 -0400 Subject: [PATCH] [MM-68102] Add Classification Markings admin console page (#35934) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: David Krauser Co-authored-by: avasconcelos114 --- e2e-tests/.ci/server.generate.sh | 1 + e2e-tests/playwright/lib/src/index.ts | 3 + .../playwright/lib/src/license_helpers.ts | 17 + .../lib/src/server/default_config.ts | 1 + .../classification_markings.spec.ts | 246 +++++++++ .../classification_markings_helpers.ts | 45 ++ server/channels/api4/properties.go | 2 +- server/channels/api4/properties_test.go | 36 ++ server/public/model/feature_flags.go | 5 + server/public/model/feature_flags_test.go | 18 + .../admin_console/admin_definition.tsx | 15 + .../classification_markings.test.tsx | 511 ++++++++++++++++++ .../classification_markings.tsx | 445 +++++++++++++++ .../classification_markings_styled.tsx | 199 +++++++ .../classification_color_input.scss | 77 +++ .../classification_color_input.test.tsx | 134 +++++ .../components/classification_color_input.tsx | 167 ++++++ .../classification_levels_table.test.tsx | 111 ++++ .../classification_levels_table.tsx | 157 ++++++ .../components/level_color_cell.test.tsx | 59 ++ .../components/level_color_cell.tsx | 40 ++ .../components/level_name_cell.test.tsx | 76 +++ .../components/level_name_cell.tsx | 39 ++ .../classification_markings/index.ts | 6 + .../classification_markings/utils/index.ts | 109 ++++ .../utils/preset_dropdown_styles.ts | 71 +++ .../classification_markings/utils/presets.ts | 77 +++ .../src/components/section_notice/index.tsx | 4 +- webapp/channels/src/i18n/en.json | 28 + webapp/platform/client/src/client4.test.ts | 94 ++++ webapp/platform/client/src/client4.ts | 60 +- webapp/platform/types/src/properties.ts | 2 + 32 files changed, 2845 insertions(+), 10 deletions(-) create mode 100644 e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings.spec.ts create mode 100644 e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings_helpers.ts create mode 100644 webapp/channels/src/components/admin_console/classification_markings/classification_markings.test.tsx create mode 100644 webapp/channels/src/components/admin_console/classification_markings/classification_markings.tsx create mode 100644 webapp/channels/src/components/admin_console/classification_markings/classification_markings_styled.tsx create mode 100644 webapp/channels/src/components/admin_console/classification_markings/components/classification_color_input.scss create mode 100644 webapp/channels/src/components/admin_console/classification_markings/components/classification_color_input.test.tsx create mode 100644 webapp/channels/src/components/admin_console/classification_markings/components/classification_color_input.tsx create mode 100644 webapp/channels/src/components/admin_console/classification_markings/components/classification_levels_table.test.tsx create mode 100644 webapp/channels/src/components/admin_console/classification_markings/components/classification_levels_table.tsx create mode 100644 webapp/channels/src/components/admin_console/classification_markings/components/level_color_cell.test.tsx create mode 100644 webapp/channels/src/components/admin_console/classification_markings/components/level_color_cell.tsx create mode 100644 webapp/channels/src/components/admin_console/classification_markings/components/level_name_cell.test.tsx create mode 100644 webapp/channels/src/components/admin_console/classification_markings/components/level_name_cell.tsx create mode 100644 webapp/channels/src/components/admin_console/classification_markings/index.ts create mode 100644 webapp/channels/src/components/admin_console/classification_markings/utils/index.ts create mode 100644 webapp/channels/src/components/admin_console/classification_markings/utils/preset_dropdown_styles.ts create mode 100644 webapp/channels/src/components/admin_console/classification_markings/utils/presets.ts diff --git a/e2e-tests/.ci/server.generate.sh b/e2e-tests/.ci/server.generate.sh index 3a571103abc..d53b339aa78 100755 --- a/e2e-tests/.ci/server.generate.sh +++ b/e2e-tests/.ci/server.generate.sh @@ -67,6 +67,7 @@ services: MM_FEATUREFLAGS_MOVETHREADSENABLED: "true" MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES: "true" MM_FEATUREFLAGS_PERMISSIONPOLICIES: "true" + MM_FEATUREFLAGS_CLASSIFICATIONMARKINGS: "true" MM_LOGSETTINGS_ENABLEDIAGNOSTICS: "false" MM_LOGSETTINGS_CONSOLELEVEL: "DEBUG" network_mode: host diff --git a/e2e-tests/playwright/lib/src/index.ts b/e2e-tests/playwright/lib/src/index.ts index c69303492c6..ba52c85af0b 100644 --- a/e2e-tests/playwright/lib/src/index.ts +++ b/e2e-tests/playwright/lib/src/index.ts @@ -65,6 +65,8 @@ export { export {TestArgs, ScreenshotOptions} from './types'; +export {getAdminClient} from './server'; + export { enableAutotranslationConfig, disableAutotranslationConfig, @@ -78,6 +80,7 @@ export { hasAutotranslationLicense, hasSharedChannelsLicense, hasCustomPermissionsSchemesLicense, + licenseTier, } from './license_helpers'; // ABAC (Attribute-Based Access Control) helpers export { diff --git a/e2e-tests/playwright/lib/src/license_helpers.ts b/e2e-tests/playwright/lib/src/license_helpers.ts index e341d74b6e0..361ea958a7a 100644 --- a/e2e-tests/playwright/lib/src/license_helpers.ts +++ b/e2e-tests/playwright/lib/src/license_helpers.ts @@ -36,3 +36,20 @@ export function hasSharedChannelsLicense(license: ClientLicense | null | undefin export function hasCustomPermissionsSchemesLicense(license: ClientLicense | null | undefined): boolean { return license?.CustomPermissionsSchemes === 'true'; } + +/** + * Mirrors webapp `getLicenseTier` (utils/constants) for client `SkuShortName` values. + */ +export function licenseTier(skuShortName: string): number { + switch (skuShortName) { + case 'professional': + return 10; + case 'enterprise': + return 20; + case 'entry': + case 'advanced': + return 30; + default: + return 0; + } +} diff --git a/e2e-tests/playwright/lib/src/server/default_config.ts b/e2e-tests/playwright/lib/src/server/default_config.ts index a35e5cea61f..744458089c3 100644 --- a/e2e-tests/playwright/lib/src/server/default_config.ts +++ b/e2e-tests/playwright/lib/src/server/default_config.ts @@ -786,6 +786,7 @@ const defaultServerConfig: AdminConfig = { BurnOnRead: true, EnableAIPluginBridge: false, EnableAIRecaps: false, + ClassificationMarkings: true, IntegratedBoards: false, CJKSearch: false, }, diff --git a/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings.spec.ts b/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings.spec.ts new file mode 100644 index 00000000000..0de3fb39d71 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings.spec.ts @@ -0,0 +1,246 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/** + * System Console — Classification markings (Enterprise-tier license + ClassificationMarkings feature flag). + * Covers admin UI for presets, save validation, preset-change confirmation, and custom preset detection. + * + * Local runs: upload or use a license with SkuShortName `enterprise`, `entry`, or `advanced`. + * Professional-only licenses hide this admin route (React Router redirects to /admin_console/about/license). + */ + +import type {Page} from '@playwright/test'; + +import {expect, test, getAdminClient, licenseTier} from '@mattermost/playwright-lib'; + +import { + CLASSIFICATION_MARKINGS_ADMIN_PATH, + deleteClassificationMarkingsFieldIfExists, + setClassificationMarkingsFeatureFlag, +} from './classification_markings_helpers'; + +async function selectClassificationPreset(page: Page, optionLabel: string) { + await page.getByTestId('classificationPreset').click(); + const menu = page.locator('.DropDown__menu'); + await expect(menu).toBeVisible(); + await menu.getByText(optionLabel, {exact: true}).click(); +} + +test.describe('System Console - Classification markings', () => { + test.describe.configure({mode: 'serial'}); + + test.beforeEach(async ({pw}) => { + await pw.skipIfNoLicense(); + const {adminClient} = await getAdminClient(); + const license = await adminClient.getClientLicenseOld(); + test.skip( + licenseTier(license.SkuShortName) < 20, + 'Classification markings requires Enterprise-tier license (SkuShortName enterprise, entry, or advanced). Professional/trial Professional is not sufficient—the admin route is hidden and redirects to /admin_console/about/license.', + ); + }); + + /** + * @objective Ensure the classification markings admin route is unavailable when the feature flag is off (when the server allows disabling it). + */ + test( + 'MM-T6201 classification markings: feature flag off redirects away from admin URL', + {tag: ['@system_console', '@classification_markings']}, + async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + + // # Turn off ClassificationMarkings in server config + await setClassificationMarkingsFeatureFlag(adminClient, false); + const {FeatureFlags} = await adminClient.getConfig(); + test.skip( + FeatureFlags.ClassificationMarkings === true, + 'ClassificationMarkings stays enabled (e.g. MM_FEATUREFLAGS or split-key overrides); cannot assert flag-off in this environment.', + ); + + // # Open system console and navigate directly to the classification markings path + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + await systemConsolePage.goto(); + await systemConsolePage.page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH); + await systemConsolePage.page.waitForLoadState('networkidle'); + + // * User is redirected away from the hidden route (no Route registered) + await expect(systemConsolePage.page).not.toHaveURL(/classification_markings/); + // * Classification markings page title is not shown + await expect(systemConsolePage.page.getByText('Classification Markings').first()).not.toBeVisible(); + }, + ); + + /** + * @objective Ensure the classification markings page is reachable when the feature flag is on. + */ + test( + 'MM-T6202 classification markings: feature flag on loads configuration page', + {tag: ['@system_console', '@classification_markings']}, + async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + + // # Enable flag and clear any existing classification field + await setClassificationMarkingsFeatureFlag(adminClient, true); + await deleteClassificationMarkingsFieldIfExists(adminClient); + + // # Log in and open the classification markings URL + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + await systemConsolePage.page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH); + await systemConsolePage.page.waitForLoadState('networkidle'); + + // * URL stays on the classification markings section + await expect(systemConsolePage.page).toHaveURL(/classification_markings/); + // * Page title is visible + await expect(systemConsolePage.page.getByText('Classification Markings').first()).toBeVisible(); + }, + ); + + /** + * @objective Validate that enabling classification without any levels shows a save error. + */ + test( + 'MM-T6203 classification markings: save fails when enabled with zero levels', + {tag: ['@system_console', '@classification_markings']}, + async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + + // # Enable feature flag and ensure no classification field exists + await setClassificationMarkingsFeatureFlag(adminClient, true); + await deleteClassificationMarkingsFieldIfExists(adminClient); + + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + await systemConsolePage.page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH); + await systemConsolePage.page.waitForLoadState('networkidle'); + + // # Enable classification markings without choosing a preset or adding levels + await systemConsolePage.page.locator('input[name="classificationEnabled"][value="true"]').click(); + await systemConsolePage.page.getByRole('button', {name: 'Save', exact: true}).click(); + + // * Validation error is shown + await expect( + systemConsolePage.page.getByText(/At least one classification level is required/i), + ).toBeVisible(); + }, + ); + + /** + * @objective Verify selecting a built-in preset and saving creates the classification field successfully. + */ + test( + 'MM-T6204 classification markings: select NATO preset and save', + {tag: ['@system_console', '@classification_markings']}, + async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + + // # Enable flag and start from no classification field + await setClassificationMarkingsFeatureFlag(adminClient, true); + await deleteClassificationMarkingsFieldIfExists(adminClient); + + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + const {page} = systemConsolePage; + await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH); + await page.waitForLoadState('networkidle'); + + // # Enable markings and choose NATO preset + await page.locator('input[name="classificationEnabled"][value="true"]').click(); + await selectClassificationPreset(page, 'NATO'); + + const firstLevelNameInput = page.getByLabel('Classification level name').first(); + // * Preset levels appear in the table + await expect(firstLevelNameInput).toHaveValue('NATO UNCLASSIFIED'); + + // # Save + await page.getByRole('button', {name: 'Save', exact: true}).click(); + await page.waitForLoadState('networkidle'); + + // * No server error and first level name is unchanged after save + await expect(page.locator('.admin-console-save .error-message')).toBeEmpty(); + await expect(firstLevelNameInput).toHaveValue('NATO UNCLASSIFIED'); + }, + ); + + /** + * @objective When a classification field already exists, changing preset shows a warning modal; confirming applies the new preset. + */ + test( + 'MM-T6205 classification markings: preset change shows confirm modal then applies', + {tag: ['@system_console', '@classification_markings']}, + async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + + // # Enable flag and clear field, then prepare saved UK levels + await setClassificationMarkingsFeatureFlag(adminClient, true); + await deleteClassificationMarkingsFieldIfExists(adminClient); + + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + const {page} = systemConsolePage; + await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH); + await page.waitForLoadState('networkidle'); + + await page.locator('input[name="classificationEnabled"][value="true"]').click(); + await selectClassificationPreset(page, 'UK (GSCP)'); + await page.getByRole('button', {name: 'Save', exact: true}).click(); + await page.waitForLoadState('networkidle'); + + // * UK preset first level is present + await expect(page.getByLabel('Classification level name').first()).toHaveValue('OFFICIAL'); + + // # Select a different preset while a field exists on the server + await selectClassificationPreset(page, 'United States'); + + // * Warning modal appears with expected copy + await expect(page.getByText('Change classification preset?')).toBeVisible(); + await expect( + page.getByText(/Changing the classification preset will affect all existing classifications/i), + ).toBeVisible(); + + // # Confirm preset change + await page.getByRole('button', {name: 'Change preset'}).click(); + + // * Modal closes and US preset first level is shown + await expect(page.getByText('Change classification preset?')).not.toBeVisible(); + await expect(page.getByLabel('Classification level name').first()).toHaveValue('UNCLASSIFIED'); + }, + ); + + /** + * @objective After saving a preset, deleting a level switches the preset dropdown to Custom and save still succeeds. + */ + test( + 'MM-T6206 classification markings: delete level switches to custom and saves', + {tag: ['@system_console', '@classification_markings']}, + async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + + // # Enable flag and save Canada preset as baseline + await setClassificationMarkingsFeatureFlag(adminClient, true); + await deleteClassificationMarkingsFieldIfExists(adminClient); + + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + const {page} = systemConsolePage; + await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH); + await page.waitForLoadState('networkidle'); + + await page.locator('input[name="classificationEnabled"][value="true"]').click(); + await selectClassificationPreset(page, 'Canada'); + await page.getByRole('button', {name: 'Save', exact: true}).click(); + await page.waitForLoadState('networkidle'); + + await expect(page.getByLabel('Classification level name').first()).toHaveValue('PROTECTED A'); + + // # Remove one level from the saved preset + await page.getByRole('button', {name: 'Delete level'}).first().click(); + + const presetControl = page.getByTestId('classificationPreset'); + // * Preset selection switches to custom + await expect(presetControl).toContainText('Custom classification levels'); + + // # Save custom levels + await page.getByRole('button', {name: 'Save', exact: true}).click(); + await page.waitForLoadState('networkidle'); + + // * No error and preset remains custom + await expect(page.locator('.admin-console-save .error-message')).toBeEmpty(); + await expect(presetControl).toContainText('Custom classification levels'); + }, + ); +}); diff --git a/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings_helpers.ts b/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings_helpers.ts new file mode 100644 index 00000000000..fd10d7582d1 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings_helpers.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Client4} from '@mattermost/client'; + +// Canonical values: webapp/channels/src/components/admin_console/classification_markings/utils/index.ts +// (cross-package import not feasible between e2e-tests and webapp) +const PROPERTY_GROUP = 'custom_profile_attributes'; +const PROPERTY_OBJECT = 'template'; // template field is the schema source of truth (Linked Properties) +const TARGET_TYPE = 'system'; +const CLASSIFICATION_FIELD_NAME = 'classification'; + +export const CLASSIFICATION_MARKINGS_ADMIN_PATH = '/admin_console/site_config/classification_markings'; + +/** + * Toggle via System Console config API. On servers without SplitKey, feature flags are + * read-only from config (see server/config/store.go); effective values come from env + * (e.g. MM_FEATUREFLAGS_CLASSIFICATIONMARKINGS). E2E docker sets that env in server.generate.sh. + */ +export async function setClassificationMarkingsFeatureFlag(adminClient: Client4, enabled: boolean) { + const config = await adminClient.getConfig(); + // Full config round-trip; FeatureFlags is a wide record on the client type. + await adminClient.updateConfig({ + ...config, + FeatureFlags: { + ...config.FeatureFlags, + ClassificationMarkings: enabled, + }, + } as Awaited>); +} + +/** + * Removes the system classification property field if present (clean slate for E2E). + */ +export async function deleteClassificationMarkingsFieldIfExists(adminClient: Client4) { + try { + const fields = await adminClient.getPropertyFields(PROPERTY_GROUP, PROPERTY_OBJECT, TARGET_TYPE); + const field = fields.find((f) => f.name === CLASSIFICATION_FIELD_NAME && f.delete_at === 0); + if (field?.id) { + await adminClient.deletePropertyField(PROPERTY_GROUP, PROPERTY_OBJECT, field.id); + } + } catch { + // Property routes may be unavailable when the feature flag is off; ignore. + } +} diff --git a/server/channels/api4/properties.go b/server/channels/api4/properties.go index 667a0b676ff..16ee192412a 100644 --- a/server/channels/api4/properties.go +++ b/server/channels/api4/properties.go @@ -19,7 +19,7 @@ const maxPropertyValuePatchItems = 50 func (api *API) InitProperties() { api.BaseRoutes.PropertyFields.Handle("", api.APISessionRequired(getPropertyFields)).Methods(http.MethodGet) api.BaseRoutes.PropertyValues.Handle("", api.APISessionRequired(getPropertyValues)).Methods(http.MethodGet) - if api.srv.Config().FeatureFlags.IntegratedBoards { + if api.srv.Config().FeatureFlags.IntegratedBoards || api.srv.Config().FeatureFlags.ClassificationMarkings { api.BaseRoutes.PropertyFields.Handle("", api.APISessionRequired(createPropertyField)).Methods(http.MethodPost) api.BaseRoutes.PropertyField.Handle("", api.APISessionRequired(patchPropertyField)).Methods(http.MethodPatch) api.BaseRoutes.PropertyField.Handle("", api.APISessionRequired(deletePropertyField)).Methods(http.MethodDelete) diff --git a/server/channels/api4/properties_test.go b/server/channels/api4/properties_test.go index ed135fdd580..5bee7b2c1e1 100644 --- a/server/channels/api4/properties_test.go +++ b/server/channels/api4/properties_test.go @@ -15,6 +15,42 @@ import ( "github.com/mattermost/mattermost/server/public/model" ) +func TestPropertyRoutesWithClassificationMarkingsFlag(t *testing.T) { + mainHelper.Parallel(t) + + // Routes should be available when ClassificationMarkings=true even with IntegratedBoards=false + th := SetupConfig(t, func(cfg *model.Config) { + cfg.FeatureFlags.IntegratedBoards = false + cfg.FeatureFlags.ClassificationMarkings = true + }).InitBasic(t) + + group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{ + Name: "classification_test", + Version: model.PropertyGroupVersionV2, + }) + require.Nil(t, err) + require.NotNil(t, group) + + t.Run("create field should succeed with ClassificationMarkings flag", func(t *testing.T) { + field := &model.PropertyField{ + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + TargetType: "system", + } + + createdField, resp, err := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, "post", field) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.NotEmpty(t, createdField.ID) + }) + + t.Run("get fields should succeed with ClassificationMarkings flag", func(t *testing.T) { + _, resp, err := th.SystemAdminClient.GetPropertyFields(context.Background(), group.Name, "post", model.PropertyFieldSearch{TargetType: "system"}) + require.NoError(t, err) + CheckOKStatus(t, resp) + }) +} + func TestCreatePropertyField(t *testing.T) { mainHelper.Parallel(t) th := SetupConfig(t, func(cfg *model.Config) { diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index cd2f2ee98de..2773990516d 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -89,6 +89,9 @@ type FeatureFlags struct { // Enable auto-translation feature for messages in channels AutoTranslation bool + // Enable classification markings for banners at the system and channel level + ClassificationMarkings bool + // Enable burn-on-read messages that automatically delete after viewing BurnOnRead bool @@ -145,6 +148,8 @@ func (f *FeatureFlags) SetDefaults() { f.AutoTranslation = true + f.ClassificationMarkings = false + f.BurnOnRead = true // FEATURE_FLAG_REMOVAL: EnableAIPluginBridge - Remove this default when MVP is to be released diff --git a/server/public/model/feature_flags_test.go b/server/public/model/feature_flags_test.go index 991b3aaef5d..1d92a601d85 100644 --- a/server/public/model/feature_flags_test.go +++ b/server/public/model/feature_flags_test.go @@ -9,6 +9,24 @@ import ( "github.com/stretchr/testify/require" ) +func TestFeatureFlagsSetDefaults(t *testing.T) { + f := &FeatureFlags{} + f.SetDefaults() + + t.Run("ClassificationMarkings should default to false", func(t *testing.T) { + require.False(t, f.ClassificationMarkings) + }) + + t.Run("ClassificationMarkings should serialize correctly", func(t *testing.T) { + m := f.ToMap() + require.Equal(t, "false", m["ClassificationMarkings"]) + + f.ClassificationMarkings = true + m = f.ToMap() + require.Equal(t, "true", m["ClassificationMarkings"]) + }) +} + func TestFeatureFlagsToMap(t *testing.T) { for name, tc := range map[string]struct { Flags FeatureFlags diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index b104166549d..81892c3f207 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -57,6 +57,7 @@ import BillingSubscriptions, {searchableStrings as billingSubscriptionSearchable import CompanyInfo, {searchableStrings as billingCompanyInfoSearchableStrings} from './billing/company_info'; import CompanyInfoEdit from './billing/company_info_edit'; import BrandImageSetting from './brand_image_setting/brand_image_setting'; +import ClassificationMarkings, {searchableStrings as classificationMarkingsSearchableStrings} from './classification_markings'; import ClientSideUserIdsSetting from './client_side_userids_setting'; import ClusterSettings, {searchableStrings as clusterSearchableStrings} from './cluster_settings'; import CustomEnableDisableGuestAccountsMagicLinkSetting, {searchableStrings as magicLinkSearchableStrings} from './custom_enable_disable_guest_accounts_magic_link_setting'; @@ -3015,6 +3016,20 @@ const AdminDefinition: AdminDefinitionType = { ], }, }, + classification_markings: { + url: 'site_config/classification_markings', + title: defineMessage({id: 'admin.sidebar.classificationMarkings', defaultMessage: 'Classification Markings'}), + searchableStrings: classificationMarkingsSearchableStrings, + isHidden: it.any( + it.not(it.minLicenseTier(LicenseSkus.Enterprise)), + it.not(it.configIsTrue('FeatureFlags', 'ClassificationMarkings')), + ), + isDisabled: it.not(it.isSystemAdmin), + schema: { + id: 'ClassificationMarkings', + component: ClassificationMarkings, + }, + }, announcement_banner: { url: 'site_config/announcement_banner', title: defineMessage({id: 'admin.sidebar.announcement', defaultMessage: 'System-wide Notifications'}), diff --git a/webapp/channels/src/components/admin_console/classification_markings/classification_markings.test.tsx b/webapp/channels/src/components/admin_console/classification_markings/classification_markings.test.tsx new file mode 100644 index 00000000000..cdf3597f767 --- /dev/null +++ b/webapp/channels/src/components/admin_console/classification_markings/classification_markings.test.tsx @@ -0,0 +1,511 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import type {PropertyField, PropertyFieldOption} from '@mattermost/types/properties'; + +import {Client4} from 'mattermost-redux/client'; + +import {act, renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; + +import ClassificationMarkings from './classification_markings'; +import { + detectPreset, + optionsToLevels, + levelsToOptions, + processClassificationField, + fetchClassificationField, + GROUP_NAME, + OBJECT_TYPE, + TARGET_TYPE, + FIELD_NAME, +} from './utils'; +import type {ClassificationLevel} from './utils/presets'; +import {PRESET_CUSTOM, presets} from './utils/presets'; + +jest.mock('mattermost-redux/client'); + +// Helper to build a minimal PropertyField for testing +function makePropertyField(overrides: Partial = {}): PropertyField { + return { + id: 'field1', + group_id: GROUP_NAME, + name: FIELD_NAME, + type: 'select', + attrs: {options: []}, + target_id: '', + target_type: TARGET_TYPE, + object_type: OBJECT_TYPE, + create_at: 1000, + update_at: 1000, + delete_at: 0, + created_by: 'user1', + updated_by: 'user1', + ...overrides, + }; +} + +describe('detectPreset', () => { + test('should match each built-in preset', () => { + for (const preset of presets) { + expect(detectPreset(preset.levels)).toBe(preset.id); + } + }); + + test('should return custom when levels length differs', () => { + const usPreset = presets.find((p) => p.id === 'us')!; + const truncated = usPreset.levels.slice(0, 2); + expect(detectPreset(truncated)).toBe(PRESET_CUSTOM); + }); + + test('should return custom when a name differs', () => { + const usPreset = presets.find((p) => p.id === 'us')!; + const modified = usPreset.levels.map((l) => ({...l})); + modified[0].name = 'MODIFIED'; + expect(detectPreset(modified)).toBe(PRESET_CUSTOM); + }); + + test('should return custom when a rank differs', () => { + const usPreset = presets.find((p) => p.id === 'us')!; + const modified = usPreset.levels.map((l) => ({...l})); + modified[0].rank = 999; + expect(detectPreset(modified)).toBe(PRESET_CUSTOM); + }); + + test('should match colors case-insensitively', () => { + const usPreset = presets.find((p) => p.id === 'us')!; + const lowered = usPreset.levels.map((l) => ({...l, color: l.color.toLowerCase()})); + expect(detectPreset(lowered)).toBe('us'); + }); + + test('should return custom for empty levels', () => { + expect(detectPreset([])).toBe(PRESET_CUSTOM); + }); +}); + +describe('optionsToLevels', () => { + test('should convert options to levels with explicit rank and color', () => { + const options: PropertyFieldOption[] = [ + {id: 'a', name: 'Alpha', color: '#FF0000', rank: 2}, + {id: 'b', name: 'Beta', color: '#00FF00', rank: 1}, + ]; + + const levels = optionsToLevels(options); + + // Should be sorted by rank ascending + expect(levels).toHaveLength(2); + expect(levels[0]).toEqual({id: 'b', name: 'Beta', color: '#00FF00', rank: 1}); + expect(levels[1]).toEqual({id: 'a', name: 'Alpha', color: '#FF0000', rank: 2}); + }); + + test('should default color to #000000 when missing', () => { + const options: PropertyFieldOption[] = [ + {id: 'a', name: 'NoColor'}, + ]; + + const levels = optionsToLevels(options); + expect(levels[0].color).toBe('#000000'); + }); + + test('should default rank to index+1 when missing', () => { + const options: PropertyFieldOption[] = [ + {id: 'a', name: 'First'}, + {id: 'b', name: 'Second'}, + {id: 'c', name: 'Third'}, + ]; + + const levels = optionsToLevels(options); + expect(levels[0].rank).toBe(1); + expect(levels[1].rank).toBe(2); + expect(levels[2].rank).toBe(3); + }); + + test('should handle empty options', () => { + expect(optionsToLevels([])).toEqual([]); + }); +}); + +describe('levelsToOptions', () => { + test('should convert levels to options preserving name, color, rank', () => { + const levels: ClassificationLevel[] = [ + {id: 'real_id', name: 'SECRET', color: '#C8102E', rank: 1}, + ]; + + const options = levelsToOptions(levels); + expect(options).toEqual([{id: 'real_id', name: 'SECRET', color: '#C8102E', rank: 1}]); + }); + + test('should strip pending_ IDs to empty string', () => { + const levels: ClassificationLevel[] = [ + {id: 'pending_12345', name: 'NEW', color: '#000000', rank: 1}, + {id: 'existing_id', name: 'OLD', color: '#111111', rank: 2}, + ]; + + const options = levelsToOptions(levels); + expect(options[0].id).toBe(''); + expect(options[1].id).toBe('existing_id'); + }); + + test('should handle empty levels', () => { + expect(levelsToOptions([])).toEqual([]); + }); +}); + +describe('processClassificationField', () => { + test('should extract levels and detect preset from a field', () => { + const usPreset = presets.find((p) => p.id === 'us')!; + const field = makePropertyField({ + attrs: { + options: usPreset.levels.map((l) => ({ + id: l.id, + name: l.name, + color: l.color, + rank: l.rank, + })), + }, + }); + + const result = processClassificationField(field); + expect(result.presetId).toBe('us'); + expect(result.levels).toHaveLength(usPreset.levels.length); + expect(result.levels[0].name).toBe('UNCLASSIFIED'); + }); + + test('should return custom preset for non-matching options', () => { + const field = makePropertyField({ + attrs: { + options: [ + {id: 'x', name: 'CUSTOM_LEVEL', color: '#123456', rank: 1}, + ], + }, + }); + + const result = processClassificationField(field); + expect(result.presetId).toBe(PRESET_CUSTOM); + expect(result.levels).toHaveLength(1); + }); + + test('should handle field with no attrs', () => { + const field = makePropertyField({attrs: undefined}); + const result = processClassificationField(field); + expect(result.presetId).toBe(PRESET_CUSTOM); + expect(result.levels).toEqual([]); + }); +}); + +describe('fetchClassificationField', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should return the matching field from first page', async () => { + const expected = makePropertyField({delete_at: 0}); + jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([ + makePropertyField({id: 'other', name: 'other_field', delete_at: 0}), + expected, + ]); + + const result = await fetchClassificationField(); + expect(result).toEqual(expected); + expect(Client4.getPropertyFields).toHaveBeenCalledTimes(1); + }); + + test('should skip soft-deleted fields', async () => { + const active = makePropertyField({id: 'active', delete_at: 0}); + jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([ + makePropertyField({id: 'deleted', delete_at: 1234}), + active, + ]); + + const result = await fetchClassificationField(); + expect(result).toEqual(active); + }); + + test('should paginate when field not found on first page', async () => { + const page1 = [ + makePropertyField({id: 'p1', name: 'other1', delete_at: 0, create_at: 100}), + makePropertyField({id: 'p2', name: 'other2', delete_at: 0, create_at: 200}), + ]; + const expected = makePropertyField({id: 'found', delete_at: 0}); + const page2 = [expected]; + + jest.spyOn(Client4, 'getPropertyFields'). + mockResolvedValueOnce(page1). + mockResolvedValueOnce(page2); + + const result = await fetchClassificationField(); + expect(result).toEqual(expected); + expect(Client4.getPropertyFields).toHaveBeenCalledTimes(2); + + // Verify cursor params were passed for second call + const secondCallArgs = (Client4.getPropertyFields as jest.Mock).mock.calls[1]; + expect(secondCallArgs[4]).toEqual({cursorId: 'p2', cursorCreateAt: 200}); + }); + + test('should return undefined when no pages contain the field', async () => { + jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]); + + const result = await fetchClassificationField(); + expect(result).toBeUndefined(); + }); + + test('should stop after 500 items to avoid infinite loop', async () => { + // Create pages of 100 items each, none matching + const makePage = (startId: number) => + Array.from({length: 100}, (_, i) => + makePropertyField({id: `id_${startId + i}`, name: `other_${startId + i}`, delete_at: 0, create_at: startId + i}), + ); + + const spy = jest.spyOn(Client4, 'getPropertyFields'); + for (let i = 0; i < 6; i++) { + spy.mockResolvedValueOnce(makePage(i * 100)); + } + + const result = await fetchClassificationField(); + expect(result).toBeUndefined(); + + // Should have fetched 5 pages (500 items) then stopped + expect(Client4.getPropertyFields).toHaveBeenCalledTimes(5); + }); +}); + +describe('ClassificationMarkings component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should show loading screen initially', () => { + // Never resolve the fetch to keep loading state + jest.spyOn(Client4, 'getPropertyFields').mockReturnValue(new Promise(() => {})); + + const {container} = renderWithContext(); + + expect(screen.getByText('Classification Markings')).toBeInTheDocument(); + expect(container.querySelector('.loading-screen')).toBeInTheDocument(); + }); + + test('should show error when load fails', async () => { + const error = new Error('Network error'); + (error as unknown as Record).status_code = 500; + jest.spyOn(Client4, 'getPropertyFields').mockRejectedValueOnce(error); + + renderWithContext(); + + await screen.findByText(/Failed to load classification markings/); + expect(screen.getByText(/Network error/)).toBeInTheDocument(); + }); + + test('should show informational notice when loaded', async () => { + jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]); + + renderWithContext(); + + await screen.findByText('True'); + + expect(screen.getByRole('heading', {name: 'Classification markings are informational only'})).toBeInTheDocument(); + expect( + screen.getByText('Markings are not tied to access control decisions at this time and are for display purposes only.'), + ).toBeInTheDocument(); + }); + + test('should render disabled state when no existing field', async () => { + jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]); + + renderWithContext(); + + // Wait for loading to finish + await screen.findByText('True'); + + // Classification should default to disabled (False radio checked) + const falseRadio = screen.getByRole('radio', {name: /False/i}) as HTMLInputElement; + expect(falseRadio.checked).toBe(true); + + // Preset and levels sections should not be visible when disabled + expect(screen.queryByText('Classification preset')).not.toBeInTheDocument(); + }); + + test('should render enabled state with levels when field exists', async () => { + const usPreset = presets.find((p) => p.id === 'us')!; + const field = makePropertyField({ + attrs: { + options: usPreset.levels.map((l) => ({ + id: l.id, + name: l.name, + color: l.color, + rank: l.rank, + })), + }, + }); + jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([field]); + + renderWithContext(); + + // Wait for loading to finish and levels to render + await screen.findByText('Classification preset'); + + const trueRadio = screen.getByRole('radio', {name: /True/i}) as HTMLInputElement; + expect(trueRadio.checked).toBe(true); + + // Should show classification levels + expect(screen.getByText('Classification levels')).toBeInTheDocument(); + }); + + test('should show preset and levels sections when enabled', async () => { + jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]); + + renderWithContext(); + + await screen.findByText('True'); + + // Enable classification markings + const user = userEvent.setup(); + const trueRadio = screen.getByRole('radio', {name: /True/i}); + await act(async () => { + await user.click(trueRadio); + }); + + // Preset and levels sections should appear + expect(screen.getByText('Classification preset')).toBeInTheDocument(); + expect(screen.getByText('Classification levels')).toBeInTheDocument(); + }); + + test('should detect hasChanges when toggling enabled', async () => { + jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]); + + renderWithContext(); + + await screen.findByText('True'); + + // Initially no save button should be active + const user = userEvent.setup(); + + // Enable classification + const trueRadio = screen.getByRole('radio', {name: /True/i}); + await act(async () => { + await user.click(trueRadio); + }); + + // Save button should now appear since there are changes + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + + test('should validate empty levels when saving while enabled', async () => { + jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]); + + renderWithContext(); + + await screen.findByText('True'); + + const user = userEvent.setup(); + + // Enable classification + await act(async () => { + await user.click(screen.getByRole('radio', {name: /True/i})); + }); + + // Try to save with no levels + await act(async () => { + await user.click(screen.getByText('Save')); + }); + + // Should show validation error + await screen.findByText(/At least one classification level is required/); + }); + + test('should handle 404 error as no field found', async () => { + const error = new Error('Not found'); + (error as unknown as Record).status_code = 404; + jest.spyOn(Client4, 'getPropertyFields').mockRejectedValueOnce(error); + + renderWithContext(); + + // Should load successfully (not show error) since 404 means no field + await screen.findByText('True'); + expect(screen.queryByText(/Failed to load/)).not.toBeInTheDocument(); + }); + + test('should allow typing a full 6-char hex color without auto-fill at 3 chars', async () => { + const field = makePropertyField({ + attrs: { + options: [ + {id: 'lvl1', name: 'SECRET', color: '#C8102E', rank: 1}, + ], + }, + }); + jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([field]); + + renderWithContext(); + await screen.findByText('Classification levels'); + + const user = userEvent.setup(); + const colorInput = screen.getByTestId('color-inputColorValue'); + + await act(async () => { + await user.clear(colorInput); + await user.type(colorInput, '#1a2b3c'); + }); + + // Input should show exactly what was typed, not auto-expanded from 3-char hex + expect(colorInput).toHaveValue('#1a2b3c'); + }); + + test('should sync color to level on blur', async () => { + const field = makePropertyField({ + attrs: { + options: [ + {id: 'lvl1', name: 'SECRET', color: '#C8102E', rank: 1}, + ], + }, + }); + jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([field]); + jest.spyOn(Client4, 'patchPropertyField').mockResolvedValueOnce(makePropertyField({ + attrs: { + options: [ + {id: 'lvl1', name: 'SECRET', color: '#1a2b3c', rank: 1}, + ], + }, + })); + + renderWithContext(); + await screen.findByText('Classification levels'); + + const user = userEvent.setup(); + const colorInput = screen.getByTestId('color-inputColorValue'); + + // Type a new color then tab away to blur + await user.clear(colorInput); + await user.type(colorInput, '#1a2b3c'); + await user.tab(); + + // Save should be available (changes detected after blur) + const saveButton = await screen.findByText('Save'); + await user.click(saveButton); + + // The patch call should include the typed color + expect(Client4.patchPropertyField).toHaveBeenCalledWith( + GROUP_NAME, + OBJECT_TYPE, + 'field1', + expect.objectContaining({ + attrs: expect.objectContaining({ + options: expect.arrayContaining([ + expect.objectContaining({color: '#1a2b3c'}), + ]), + }), + }), + ); + }); + + test('should pass disabled prop to disable controls', async () => { + jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]); + + renderWithContext(); + + await screen.findByText('True'); + + // Radio buttons should be disabled + const trueRadio = screen.getByRole('radio', {name: /True/i}) as HTMLInputElement; + expect(trueRadio.disabled).toBe(true); + }); +}); diff --git a/webapp/channels/src/components/admin_console/classification_markings/classification_markings.tsx b/webapp/channels/src/components/admin_console/classification_markings/classification_markings.tsx new file mode 100644 index 00000000000..a794775e88b --- /dev/null +++ b/webapp/channels/src/components/admin_console/classification_markings/classification_markings.tsx @@ -0,0 +1,445 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {FormattedMessage, defineMessages, useIntl} from 'react-intl'; +import {useDispatch} from 'react-redux'; + +import type {ClientError} from '@mattermost/client'; +import {PlusIcon} from '@mattermost/compass-icons/components'; +import type {PropertyField} from '@mattermost/types/properties'; + +import {setNavigationBlocked} from 'actions/admin_actions'; + +import BooleanSetting from 'components/admin_console/boolean_setting'; +import Setting from 'components/admin_console/setting'; +import ConfirmModal from 'components/confirm_modal'; +import DropdownInput from 'components/dropdown_input'; +import type {ValueType} from 'components/dropdown_input'; +import LoadingScreen from 'components/loading_screen'; +import SectionNotice from 'components/section_notice'; +import AdminHeader from 'components/widgets/admin_console/admin_header'; + +import { + AddLevelButton, + AddLevelButtonRow, + ClassificationLevelsSectionContent, + InformationNoticeWrapper, + PresetDropdownWrapper, +} from './classification_markings_styled'; +import ClassificationLevelsTable from './components/classification_levels_table'; +import {fetchClassificationField, processClassificationField, saveCreateField, saveDeleteField, savePatchField} from './utils'; +import {classificationPresetDropdownStyles} from './utils/preset_dropdown_styles'; +import type {ClassificationLevel} from './utils/presets'; +import {PRESET_CUSTOM, presets} from './utils/presets'; + +import SaveChangesPanel from '../save_changes_panel'; +import {AdminSection, AdminWrapper, SectionHeader, SectionHeading} from '../system_properties/controls'; + +const msg = defineMessages({ + pageTitle: {id: 'admin.sidebar.classificationMarkings', defaultMessage: 'Classification Markings'}, + enableTitle: {id: 'admin.classification_markings.enable.title', defaultMessage: 'Enable classification markings'}, + enableDescription: {id: 'admin.classification_markings.enable.description', defaultMessage: 'Use this to enable classification markings as banners at the system and channel level. You can pre-select text and colors for your banner, as well as set a default option for consistency.'}, + presetTitle: {id: 'admin.classification_markings.preset.title', defaultMessage: 'Classification preset'}, + presetDescription: {id: 'admin.classification_markings.preset.description', defaultMessage: 'Select a classification preset from the dropdown menu based on your country affiliation. This will help tailor the options to your specific needs. You can also create custom classification levels.'}, + levelsTitle: {id: 'admin.classification_markings.levels.title', defaultMessage: 'Classification levels'}, + levelsDescription: {id: 'admin.classification_markings.levels.description', defaultMessage: 'Text and colors for different classification levels that will be used in the system'}, + informationalNoticeTitle: {id: 'admin.classification_markings.notice.title', defaultMessage: 'Classification markings are informational only'}, + informationalNoticeBody: {id: 'admin.classification_markings.notice.body', defaultMessage: 'Markings are not tied to access control decisions at this time and are for display purposes only.'}, + errorDeleteHasDependents: {id: 'admin.classification_markings.error.delete_has_dependents', defaultMessage: 'Cannot disable classification markings while channel classifications exist. Remove all channel classification markings first.'}, +}); + +export const searchableStrings = Object.values(msg); + +type Props = { + disabled?: boolean; +}; + +export default function ClassificationMarkings({disabled}: Props) { + const {formatMessage} = useIntl(); + const dispatch = useDispatch(); + + // Remote state + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(); + const [existingField, setExistingField] = useState(null); + + // Local editable state + const [enabled, setEnabled] = useState(false); + const [presetId, setPresetId] = useState(PRESET_CUSTOM); + const [levels, setLevels] = useState([]); + + // Track if there are unsaved changes + const [initialEnabled, setInitialEnabled] = useState(false); + const [initialLevels, setInitialLevels] = useState([]); + + // Confirm modal for preset switch + const [confirmPresetSwitch, setConfirmPresetSwitch] = useState(null); + + const hasChanges = useMemo(() => { + if (enabled !== initialEnabled) { + return true; + } + if (!enabled) { + return false; + } + if (levels.length !== initialLevels.length) { + return true; + } + return levels.some((level, i) => { + const initial = initialLevels[i]; + return level.name !== initial.name || level.color !== initial.color || level.id !== initial.id || level.rank !== initial.rank; + }); + }, [enabled, initialEnabled, levels, initialLevels]); + + useEffect(() => { + dispatch(setNavigationBlocked(hasChanges)); + }, [hasChanges, dispatch]); + + useEffect(() => { + (async () => { + try { + const field = await fetchClassificationField(); + if (field) { + const result = processClassificationField(field); + setExistingField(field); + setEnabled(true); + setInitialEnabled(true); + setLevels(result.levels); + setInitialLevels(result.levels); + setPresetId(result.presetId); + } + } catch (err: unknown) { + const isNotFound = (err as ClientError).status_code === 404; + if (!isNotFound) { + const message = err instanceof Error ? err.message : 'Failed to load classification markings'; + setLoadError(message); + } + } finally { + setLoading(false); + } + })(); + }, []); + + const handleClassificationEnabledChange = useCallback((_id: string, value: boolean) => { + setEnabled(value); + }, []); + + const applyPreset = useCallback((newPresetId: string) => { + const preset = presets.find((p) => p.id === newPresetId); + if (preset) { + setPresetId(newPresetId); + setLevels(preset.levels.map((l) => ({...l}))); + } + }, []); + + const presetDropdownOptions = useMemo((): ValueType[] => { + return [ + ...presets.map((p) => ({value: p.id, label: p.label})), + { + value: PRESET_CUSTOM, + label: formatMessage({ + id: 'admin.classification_markings.preset.custom', + defaultMessage: 'Custom classification levels', + }), + }, + ]; + }, [formatMessage]); + + const presetDropdownValue = useMemo(() => { + return presetDropdownOptions.find((o) => o.value === presetId) ?? presetDropdownOptions[presetDropdownOptions.length - 1]!; + }, [presetDropdownOptions, presetId]); + + const handlePresetDropdownChange = useCallback((selected: ValueType | null) => { + if (!selected) { + return; + } + const newPresetId = selected.value; + if (newPresetId === PRESET_CUSTOM) { + setPresetId(PRESET_CUSTOM); + return; + } + if (levels.length > 0) { + setConfirmPresetSwitch(newPresetId); + return; + } + applyPreset(newPresetId); + }, [levels.length, applyPreset]); + + const handleConfirmPresetSwitch = useCallback(() => { + if (confirmPresetSwitch) { + applyPreset(confirmPresetSwitch); + } + setConfirmPresetSwitch(null); + }, [confirmPresetSwitch, applyPreset]); + + const handleCancelPresetSwitch = useCallback(() => { + setConfirmPresetSwitch(null); + }, []); + + const switchToCustom = useCallback(() => { + if (presetId !== PRESET_CUSTOM) { + setPresetId(PRESET_CUSTOM); + } + }, [presetId]); + + const updateLevel = useCallback((id: string, updates: Partial) => { + setLevels((prev) => prev.map((level) => (level.id === id ? {...level, ...updates} : level))); + switchToCustom(); + }, [switchToCustom]); + + const deleteLevel = useCallback((id: string) => { + setLevels((prev) => prev.filter((level) => level.id !== id).map((level, i) => ({...level, rank: i + 1}))); + switchToCustom(); + }, [switchToCustom]); + + const addLevel = useCallback(() => { + setLevels((prev) => { + const maxRank = prev.reduce((max, l) => Math.max(max, l.rank), 0); + return [...prev, {id: `pending_${Date.now()}`, name: '', color: '#000000', rank: maxRank + 1}]; + }); + switchToCustom(); + }, [switchToCustom]); + + const handleReorder = useCallback((prevIndex: number, nextIndex: number) => { + setLevels((prev) => { + const next = [...prev]; + const [moved] = next.splice(prevIndex, 1); + next.splice(nextIndex, 0, moved); + return next.map((level, i) => ({...level, rank: i + 1})); + }); + switchToCustom(); + }, [switchToCustom]); + + const validate = useCallback((): string | null => { + if (!enabled) { + return null; + } + if (levels.length === 0) { + return formatMessage({id: 'admin.classification_markings.error.no_levels', defaultMessage: 'At least one classification level is required when classification markings are enabled.'}); + } + const emptyName = levels.find((l) => l.name.trim() === ''); + if (emptyName) { + return formatMessage({id: 'admin.classification_markings.error.empty_name', defaultMessage: 'All classification levels must have a name.'}); + } + const names = levels.map((l) => l.name.trim().toLowerCase()); + const duplicateName = names.find((name, i) => names.indexOf(name) !== i); + if (duplicateName) { + return formatMessage({id: 'admin.classification_markings.error.duplicate_name', defaultMessage: 'Classification level names must be unique. Duplicate: {name}'}, {name: duplicateName.toUpperCase()}); + } + return null; + }, [enabled, levels, formatMessage]); + + const handleSaveCreate = useCallback(async () => { + const created = await saveCreateField(levels); + const result = processClassificationField(created); + setExistingField(created); + setLevels(result.levels); + setInitialLevels(result.levels); + setInitialEnabled(true); + }, [levels]); + + const handleSaveDelete = useCallback(async () => { + await saveDeleteField(existingField!.id); + setExistingField(null); + setInitialEnabled(false); + setInitialLevels([]); + setLevels([]); + setPresetId(PRESET_CUSTOM); + }, [existingField]); + + const handleSavePatch = useCallback(async () => { + const patched = await savePatchField(existingField!.id, levels); + const result = processClassificationField(patched); + setExistingField(patched); + setLevels(result.levels); + setInitialLevels(result.levels); + }, [existingField, levels]); + + const handleSave = useCallback(async () => { + setSaveError(undefined); + + const validationError = validate(); + if (validationError) { + setSaveError(validationError); + return; + } + + setSaving(true); + + try { + if (enabled && !initialEnabled) { + await handleSaveCreate(); + } else if (!enabled && initialEnabled && existingField) { + await handleSaveDelete(); + } else if (enabled && initialEnabled && existingField) { + await handleSavePatch(); + } + } catch (err: unknown) { + const clientErr = err as ClientError; + if (clientErr.status_code === 409) { + setSaveError(formatMessage(msg.errorDeleteHasDependents)); + } else { + const message = err instanceof Error ? err.message : 'An error occurred while saving'; + setSaveError(message); + } + } finally { + setSaving(false); + } + }, [enabled, initialEnabled, existingField, validate, handleSaveCreate, handleSaveDelete, handleSavePatch]); + + if (loading) { + return ( +
+ + + + + + +
+ ); + } + + if (loadError) { + return ( +
+ + + + +
+ +
+
+
+ ); + } + + return ( +
+ + + + + + } + text={formatMessage(msg.informationalNoticeBody)} + /> + +
e.preventDefault()} + > + } + value={enabled} + onChange={handleClassificationEnabledChange} + disabled={disabled} + setByEnv={false} + helpText={} + trueText={( + + )} + falseText={( + + )} + /> + {enabled && ( + } + helpText={} + setByEnv={false} + > + + + + + )} + + + {enabled && ( + + +
+ + +
+
+ + + {!disabled && ( + + + + + + + )} + +
+ )} +
+ + + + +
+ ); +} diff --git a/webapp/channels/src/components/admin_console/classification_markings/classification_markings_styled.tsx b/webapp/channels/src/components/admin_console/classification_markings/classification_markings_styled.tsx new file mode 100644 index 00000000000..9deefe685c4 --- /dev/null +++ b/webapp/channels/src/components/admin_console/classification_markings/classification_markings_styled.tsx @@ -0,0 +1,199 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import styled from 'styled-components'; + +import {SectionContent} from '../system_properties/controls'; + +export const InformationNoticeWrapper = styled.div` + margin-bottom: 16px; + + h4 { + margin: 0; + } +`; + +export const PresetDropdownWrapper = styled.div` + max-width: 500px; + + > .DropdownInput.Input_container { + margin-top: 0; + } + + fieldset.Input_fieldset.classificationPresetDropdownFieldset { + padding: 0; + border: none; + box-shadow: none; + + &:hover, + &:focus-within { + border: none; + box-shadow: none; + } + } + + .Input_wrapper { + padding: 0; + margin: 0; + } + + .DropdownInput__indicatorsContainer { + margin-right: 0; + } + + .DropdownInput__indicatorsContainer .icon-chevron-down { + display: flex; + width: 16px; + height: 16px; + align-items: center; + justify-content: center; + font-size: 16px; + line-height: 16px; + + &::before { + font-size: 16px; + line-height: 16px; + } + } +`; + +export const ClassificationLevelsSectionContent = styled(SectionContent).attrs({ + $compact: true, +})` + &&& { + padding: 0; + padding-bottom: 16px; + } +`; + +export const AddLevelButtonRow = styled.div` + margin-top: 16px; + margin-left: 16px; +`; + +export const AddLevelButton = styled.button.attrs({ + type: 'button', + className: 'btn btn-tertiary', +})` + && { + padding-inline: 16px; + } +`; + +export const TableWrapper = styled.div` + table.adminConsoleListTable { + td, th { + &:after, &:before { + display: none; + } + } + + thead { + border-top: none; + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + tr { + th:first-child { + padding-inline-start: 36px; + } + + th.pinned { + background: rgba(var(--center-channel-color-rgb), 0.04); + padding-block-end: 8px; + padding-block-start: 8px; + } + } + } + + tbody { + tr { + border-top: none; + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + border-bottom-color: rgba(var(--center-channel-color-rgb), 0.08) !important; + + &:focus-within { + position: relative; + z-index: 30; + } + + td { + padding-block-end: 0; + padding-block-start: 0; + vertical-align: middle; + + &:first-child { + padding-inline-start: 36px; + + .form-control { + padding-inline-start: 0; + } + } + + &:last-child { + padding-inline-end: 12px; + } + &.pinned { + background: none; + } + + &.color { + overflow: visible; + } + } + } + } + + .dragHandle { + left: 12px; + } + + tfoot { + border-top: none; + } + } + + .adminConsoleListTableContainer { + overflow: visible; + } +`; + +export const ColHeaderLeft = styled.div` + display: inline-block; +`; + +export const ColorCellWrapper = styled.div` + .ClassificationColorInput { + max-width: 200px; + } +`; + +export const ReadOnlyColor = styled.div` + display: flex; + align-items: center; + gap: 8px; + padding: 8px 0; + color: rgba(var(--center-channel-color-rgb), 0.72); +`; + +export const ColorSwatch = styled.span` + display: inline-block; + width: 24px; + height: 24px; + border-radius: 4px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + flex-shrink: 0; +`; + +export const RankCell = styled.div` + padding: 8px; + color: rgba(var(--center-channel-color-rgb), 0.56); +`; + +export const ActionsCell = styled.div` + text-align: right; +`; + +export const DeleteButton = styled.button.attrs({className: 'btn btn-sm btn-transparent'})` + &:hover { + background: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.08); + } +`; diff --git a/webapp/channels/src/components/admin_console/classification_markings/components/classification_color_input.scss b/webapp/channels/src/components/admin_console/classification_markings/components/classification_color_input.scss new file mode 100644 index 00000000000..45083577ca1 --- /dev/null +++ b/webapp/channels/src/components/admin_console/classification_markings/components/classification_color_input.scss @@ -0,0 +1,77 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +.ClassificationColorInput { + position: relative; + display: inline-block; + width: 100%; + max-width: 200px; +} + +.ClassificationColorInput__control { + display: flex; + height: 34px; + box-sizing: border-box; + align-items: center; + padding: 0 10px 0 8px; + border: none; + border-radius: 0; + background: transparent; + gap: 8px; + + &:focus-within { + background: rgba(var(--button-bg-rgb), 0.06); + } +} + +.ClassificationColorInput__swatch { + display: block; + width: 20px; + height: 20px; + flex-shrink: 0; + padding: 0; + border: 1px solid transparent; + border-radius: 2px; + background: transparent; + cursor: pointer; + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + &:focus-visible { + outline: 2px solid var(--button-bg); + outline-offset: 1px; + } +} + +.ClassificationColorInput__hex { + min-width: 0; + height: 100%; + flex: 1 1 auto; + padding: 0; + border: none; + background: transparent; + color: var(--center-channel-color); + font-size: 14px; + line-height: 20px; + outline: none; + text-transform: uppercase; + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + &::placeholder { + color: rgba(var(--center-channel-color-rgb), 0.48); + } +} + +.ClassificationColorInput__popover { + position: absolute; + z-index: 10000; + top: calc(100% + 4px); + left: 0; +} diff --git a/webapp/channels/src/components/admin_console/classification_markings/components/classification_color_input.test.tsx b/webapp/channels/src/components/admin_console/classification_markings/components/classification_color_input.test.tsx new file mode 100644 index 00000000000..a32182431f5 --- /dev/null +++ b/webapp/channels/src/components/admin_console/classification_markings/components/classification_color_input.test.tsx @@ -0,0 +1,134 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {render, screen, fireEvent, act} from 'tests/react_testing_utils'; + +import ClassificationColorInput from './classification_color_input'; + +function makeProps(overrides = {}) { + return { + id: 'test-color', + value: '#FF0000', + onChange: jest.fn(), + swatchAriaLabel: 'Open color picker', + ...overrides, + }; +} + +describe('ClassificationColorInput', () => { + test('renders hex input with initial value', () => { + render(); + expect(screen.getByTestId('color-inputColorValue')).toHaveValue('#FF0000'); + }); + + test('renders color swatch button with aria-label', () => { + render(); + expect(screen.getByRole('button', {name: 'Open color picker'})).toBeInTheDocument(); + }); + + test('swatch button is not rendered when disabled', () => { + render(); + expect(screen.queryByRole('button', {name: 'Open color picker'})).not.toBeInTheDocument(); + }); + + test('hex input is disabled when isDisabled is true', () => { + render(); + expect(screen.getByTestId('color-inputColorValue')).toBeDisabled(); + }); + + test('calls onChange with normalized hex when valid color typed', () => { + const onChange = jest.fn(); + render(); + + fireEvent.change(screen.getByTestId('color-inputColorValue'), {target: {value: '#00FF00'}}); + + expect(onChange).toHaveBeenCalledWith('#00ff00'); + }); + + test('does not call onChange when invalid color typed', () => { + const onChange = jest.fn(); + render(); + + fireEvent.change(screen.getByTestId('color-inputColorValue'), {target: {value: '#GGGGGG'}}); + + expect(onChange).not.toHaveBeenCalled(); + }); + + test('local value reflects typing before blur', () => { + render(); + const input = screen.getByTestId('color-inputColorValue'); + + fireEvent.change(input, {target: {value: '#1a2b3c'}}); + + expect(input).toHaveValue('#1a2b3c'); + }); + + test('normalizes and calls onChange on blur with valid color', () => { + const onChange = jest.fn(); + render(); + const input = screen.getByTestId('color-inputColorValue'); + + fireEvent.focus(input); + fireEvent.change(input, {target: {value: 'red'}}); + fireEvent.blur(input); + + expect(onChange).toHaveBeenLastCalledWith('#ff0000'); + }); + + test('reverts to original value on blur with invalid color', () => { + render(); + const input = screen.getByTestId('color-inputColorValue'); + + fireEvent.focus(input); + fireEvent.change(input, {target: {value: 'not-a-color'}}); + fireEvent.blur(input); + + expect(input).toHaveValue('#FF0000'); + }); + + test('opens color picker popover when input is focused', () => { + render(); + const input = screen.getByTestId('color-inputColorValue'); + + act(() => { + fireEvent.focus(input); + }); + + expect(document.getElementById('test-color-ChromePickerModal')).toBeInTheDocument(); + }); + + test('closes color picker popover on blur', () => { + render(); + const input = screen.getByTestId('color-inputColorValue'); + + act(() => { + fireEvent.focus(input); + }); + expect(document.getElementById('test-color-ChromePickerModal')).toBeInTheDocument(); + + act(() => { + fireEvent.blur(input); + }); + expect(document.getElementById('test-color-ChromePickerModal')).not.toBeInTheDocument(); + }); + + test('syncs local value when external value changes while not focused', () => { + const {rerender} = render(); + + rerender(); + + expect(screen.getByTestId('color-inputColorValue')).toHaveValue('#0000FF'); + }); + + test('Enter key blurs the hex input', () => { + render(); + const input = screen.getByTestId('color-inputColorValue'); + const blurSpy = jest.spyOn(input, 'blur'); + + fireEvent.keyDown(input, {key: 'Enter'}); + + expect(blurSpy).toHaveBeenCalled(); + }); +}); diff --git a/webapp/channels/src/components/admin_console/classification_markings/components/classification_color_input.tsx b/webapp/channels/src/components/admin_console/classification_markings/components/classification_color_input.tsx new file mode 100644 index 00000000000..5c9a4036af0 --- /dev/null +++ b/webapp/channels/src/components/admin_console/classification_markings/components/classification_color_input.tsx @@ -0,0 +1,167 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {memo, useCallback, useEffect, useRef, useState} from 'react'; +import {ChromePicker} from 'react-color'; +import type {ColorResult} from 'react-color'; +import tinycolor from 'tinycolor2'; + +import './classification_color_input.scss'; + +export type ClassificationColorInputProps = { + id: string; + value: string; + onChange: (color: string) => void; + swatchAriaLabel: string; + isDisabled?: boolean; +}; + +function ClassificationColorInput({id, value, onChange, swatchAriaLabel, isDisabled}: ClassificationColorInputProps) { + const [focused, setFocused] = useState(false); + const [isOpened, setIsOpened] = useState(false); + const [localValue, setLocalValue] = useState(value); + const popoverRef = useRef(null); + const hexInputRef = useRef(null); + + useEffect(() => { + if (!focused) { + setLocalValue(value); + } + }, [value, focused]); + + useEffect(() => { + if (isDisabled) { + setIsOpened(false); + } + }, [isDisabled]); + + const handleChromeChange = useCallback( + (newColorData: ColorResult) => { + const hex = newColorData.hex; + setFocused(false); + setLocalValue(hex); + onChange(hex); + }, + [onChange], + ); + + const handleHexChange = useCallback( + (event: React.ChangeEvent) => { + const next = event.target.value; + const color = tinycolor(next); + if (color.isValid()) { + onChange('#' + color.toHex()); + } + setLocalValue(next); + }, + [onChange], + ); + + const handleHexFocus = useCallback( + (event: React.FocusEvent) => { + if (!isDisabled) { + setIsOpened(true); + } + setFocused(true); + const el = event.target; + if (el.value.length > 1) { + el.setSelectionRange(1, el.value.length); + } + }, + [isDisabled], + ); + + const handleHexBlur = useCallback( + (event: React.FocusEvent) => { + const related = event.relatedTarget as Node | null; + if (popoverRef.current && related && popoverRef.current.contains(related)) { + return; + } + setIsOpened(false); + const color = tinycolor(localValue); + if (color.isValid()) { + const normalized = '#' + color.toHex(); + onChange(normalized); + setLocalValue(normalized); + } else { + setLocalValue(value); + } + setFocused(false); + }, + [localValue, onChange, value], + ); + + const handleSwatchClick = useCallback(() => { + if (isDisabled) { + return; + } + const hexEl = hexInputRef.current; + if (isOpened && document.activeElement === hexEl) { + hexEl?.blur(); + return; + } + hexEl?.focus(); + }, [isDisabled, isOpened]); + + const handleHexKeyDown = useCallback((event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + event.currentTarget.blur(); + } + }, []); + + return ( +
+
+ {!isDisabled && ( +
+ {isOpened && !isDisabled && ( + <> + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- mousedown preventDefault matches ColorInput (picker before blur) */} +
e.preventDefault()} + > + +
+ + )} +
+ ); +} + +export default memo(ClassificationColorInput); diff --git a/webapp/channels/src/components/admin_console/classification_markings/components/classification_levels_table.test.tsx b/webapp/channels/src/components/admin_console/classification_markings/components/classification_levels_table.test.tsx new file mode 100644 index 00000000000..62d8e633332 --- /dev/null +++ b/webapp/channels/src/components/admin_console/classification_markings/components/classification_levels_table.test.tsx @@ -0,0 +1,111 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils'; + +import ClassificationLevelsTable from './classification_levels_table'; + +const LEVELS = [ + {id: 'lvl-1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}, + {id: 'lvl-2', name: 'SECRET', color: '#C8102E', rank: 2}, + {id: 'lvl-3', name: 'TOP SECRET', color: '#FF8C00', rank: 3}, +]; + +function makeProps(overrides = {}) { + return { + levels: LEVELS, + updateLevel: jest.fn(), + deleteLevel: jest.fn(), + onReorder: jest.fn(), + ...overrides, + }; +} + +describe('ClassificationLevelsTable', () => { + test('renders column headers', () => { + renderWithContext(); + + expect(screen.getByText('Text')).toBeInTheDocument(); + expect(screen.getByText('Color')).toBeInTheDocument(); + expect(screen.getByText('Rank')).toBeInTheDocument(); + }); + + test('renders a row for each level', () => { + renderWithContext(); + + expect(screen.getByDisplayValue('UNCLASSIFIED')).toBeInTheDocument(); + expect(screen.getByDisplayValue('SECRET')).toBeInTheDocument(); + expect(screen.getByDisplayValue('TOP SECRET')).toBeInTheDocument(); + }); + + test('renders rank values for each level', () => { + renderWithContext(); + + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + test('renders delete buttons when not disabled', () => { + renderWithContext(); + + const deleteButtons = screen.getAllByRole('button', {name: 'Delete level'}); + expect(deleteButtons).toHaveLength(LEVELS.length); + }); + + test('hides delete buttons when disabled', () => { + renderWithContext(); + + expect(screen.queryByRole('button', {name: 'Delete level'})).not.toBeInTheDocument(); + }); + + test('calls deleteLevel with the correct id when delete is clicked', () => { + const deleteLevel = jest.fn(); + renderWithContext(); + + const [firstDeleteButton] = screen.getAllByRole('button', {name: 'Delete level'}); + fireEvent.click(firstDeleteButton); + + expect(deleteLevel).toHaveBeenCalledTimes(1); + expect(deleteLevel).toHaveBeenCalledWith('lvl-1'); + }); + + test('renders color swatches in read-only mode when disabled', () => { + renderWithContext(); + + expect(screen.getByText('#007A33')).toBeInTheDocument(); + expect(screen.getByText('#C8102E')).toBeInTheDocument(); + expect(screen.getByText('#FF8C00')).toBeInTheDocument(); + }); + + test('renders color inputs (editable) when not disabled', () => { + renderWithContext(); + + const colorInputs = screen.getAllByTestId('color-inputColorValue'); + expect(colorInputs).toHaveLength(LEVELS.length); + expect(colorInputs[0]).toHaveValue('#007A33'); + }); + + test('displays rows sorted by rank regardless of input order', () => { + const unsortedLevels = [ + {id: 'lvl-3', name: 'TOP SECRET', color: '#FF8C00', rank: 3}, + {id: 'lvl-1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}, + {id: 'lvl-2', name: 'SECRET', color: '#C8102E', rank: 2}, + ]; + + renderWithContext(); + + const inputs = screen.getAllByRole('textbox', {name: 'Classification level name'}); + expect(inputs[0]).toHaveValue('UNCLASSIFIED'); + expect(inputs[1]).toHaveValue('SECRET'); + expect(inputs[2]).toHaveValue('TOP SECRET'); + }); + + test('renders empty state with no rows when levels array is empty', () => { + renderWithContext(); + + expect(screen.queryByRole('textbox', {name: 'Classification level name'})).not.toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/admin_console/classification_markings/components/classification_levels_table.tsx b/webapp/channels/src/components/admin_console/classification_markings/components/classification_levels_table.tsx new file mode 100644 index 00000000000..288a8288b2e --- /dev/null +++ b/webapp/channels/src/components/admin_console/classification_markings/components/classification_levels_table.tsx @@ -0,0 +1,157 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {createColumnHelper, getCoreRowModel, useReactTable} from '@tanstack/react-table'; +import type {ColumnDef} from '@tanstack/react-table'; +import React, {useMemo} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import {TrashCanOutlineIcon} from '@mattermost/compass-icons/components'; + +import WithTooltip from 'components/with_tooltip'; + +import LevelColorCell from './level_color_cell'; +import LevelNameCell from './level_name_cell'; + +import {AdminConsoleListTable} from '../../list_table'; +import { + ActionsCell, + ColHeaderLeft, + ColorCellWrapper, + ColorSwatch, + DeleteButton, + RankCell, + ReadOnlyColor, + TableWrapper, +} from '../classification_markings_styled'; +import type {ClassificationLevel} from '../utils/presets'; + +type ClassificationLevelsTableProps = { + levels: ClassificationLevel[]; + updateLevel: (id: string, updates: Partial) => void; + deleteLevel: (id: string) => void; + onReorder: (prev: number, next: number) => void; + disabled?: boolean; +}; + +export default function ClassificationLevelsTable({levels, updateLevel, deleteLevel, onReorder, disabled}: ClassificationLevelsTableProps) { + const {formatMessage} = useIntl(); + + const rows = useMemo(() => { + return [...levels].sort((a, b) => a.rank - b.rank); + }, [levels]); + + const col = createColumnHelper(); + + const columns = useMemo>>(() => { + return [ + col.accessor('name', { + size: 400, + header: () => ( + + + + ), + cell: ({row}) => ( + + ), + enableSorting: false, + }), + col.accessor('color', { + size: 180, + header: () => ( + + + + ), + cell: ({row}) => ( + + {disabled ? ( + + + {row.original.color} + + ) : ( + + )} + + ), + enableSorting: false, + }), + col.accessor('rank', { + size: 60, + header: () => ( + + + + ), + cell: ({row}) => ( + {row.original.rank} + ), + enableSorting: false, + }), + ...(disabled ? [] : [col.display({ + id: 'actions', + size: 40, + header: () => null, + cell: ({row}) => ( + + + deleteLevel(row.original.id)} + > + + + + + ), + enableSorting: false, + })]), + ]; + }, [col, updateLevel, deleteLevel, disabled, formatMessage]); + + const table = useReactTable({ + data: rows, + columns, + getCoreRowModel: getCoreRowModel(), + enableSortingRemoval: false, + enableMultiSort: false, + renderFallbackValue: '', + meta: { + tableId: 'classificationLevels', + disablePaginationControls: true, + onReorder, + }, + manualPagination: true, + }); + + return ( + + table={table}/> + + ); +} diff --git a/webapp/channels/src/components/admin_console/classification_markings/components/level_color_cell.test.tsx b/webapp/channels/src/components/admin_console/classification_markings/components/level_color_cell.test.tsx new file mode 100644 index 00000000000..ebd45990b70 --- /dev/null +++ b/webapp/channels/src/components/admin_console/classification_markings/components/level_color_cell.test.tsx @@ -0,0 +1,59 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {render, screen, fireEvent} from 'tests/react_testing_utils'; + +import LevelColorCell from './level_color_cell'; + +function makeProps(overrides = {}) { + return { + id: 'level-1', + value: '#FF0000', + swatchAriaLabel: 'Open color picker', + updateLevel: jest.fn(), + ...overrides, + }; +} + +describe('LevelColorCell', () => { + test('renders the color input with the initial value', () => { + render(); + expect(screen.getByTestId('color-inputColorValue')).toHaveValue('#FF0000'); + }); + + test('calls updateLevel on blur when color has changed', () => { + const updateLevel = jest.fn(); + render(); + + const input = screen.getByTestId('color-inputColorValue'); + fireEvent.focus(input); + fireEvent.change(input, {target: {value: '#0000FF'}}); + fireEvent.blur(input); + + expect(updateLevel).toHaveBeenCalledWith('level-1', {color: '#0000ff'}); + }); + + test('does not call updateLevel on blur when color is unchanged', () => { + const updateLevel = jest.fn(); + render(); + + fireEvent.blur(screen.getByTestId('color-inputColorValue')); + + expect(updateLevel).not.toHaveBeenCalled(); + }); + + test('syncs to updated external value prop', () => { + const {rerender} = render(); + expect(screen.getByTestId('color-inputColorValue')).toHaveValue('#FF0000'); + + rerender(); + expect(screen.getByTestId('color-inputColorValue')).toHaveValue('#00FF00'); + }); + + test('renders the color swatch button with the correct aria-label', () => { + render(); + expect(screen.getByRole('button', {name: 'Open color picker'})).toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/admin_console/classification_markings/components/level_color_cell.tsx b/webapp/channels/src/components/admin_console/classification_markings/components/level_color_cell.tsx new file mode 100644 index 00000000000..2f722e565ae --- /dev/null +++ b/webapp/channels/src/components/admin_console/classification_markings/components/level_color_cell.tsx @@ -0,0 +1,40 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useState} from 'react'; + +import ClassificationColorInput from './classification_color_input'; + +import type {ClassificationLevel} from '../utils/presets'; + +type LevelColorCellProps = { + value: string; + id: string; + updateLevel: (id: string, updates: Partial) => void; + swatchAriaLabel: string; +}; + +export default function LevelColorCell({value, id, updateLevel, swatchAriaLabel}: LevelColorCellProps) { + const [localColor, setLocalColor] = useState(value); + + useEffect(() => { + setLocalColor(value); + }, [value]); + + return ( +
{ + if (localColor !== value) { + updateLevel(id, {color: localColor}); + } + }} + > + +
+ ); +} diff --git a/webapp/channels/src/components/admin_console/classification_markings/components/level_name_cell.test.tsx b/webapp/channels/src/components/admin_console/classification_markings/components/level_name_cell.test.tsx new file mode 100644 index 00000000000..19108a477db --- /dev/null +++ b/webapp/channels/src/components/admin_console/classification_markings/components/level_name_cell.test.tsx @@ -0,0 +1,76 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {render, screen, fireEvent} from 'tests/react_testing_utils'; + +import LevelNameCell from './level_name_cell'; + +function makeProps(overrides = {}) { + return { + id: 'level-1', + value: 'SECRET', + label: 'Classification level name', + updateLevel: jest.fn(), + ...overrides, + }; +} + +describe('LevelNameCell', () => { + test('renders input with initial value', () => { + render(); + expect(screen.getByRole('textbox')).toHaveValue('SECRET'); + }); + + test('renders input with correct aria-label', () => { + render(); + expect(screen.getByRole('textbox', {name: 'Classification level name'})).toBeInTheDocument(); + }); + + test('input is readOnly when disabled', () => { + render(); + expect(screen.getByRole('textbox')).toHaveAttribute('readOnly'); + }); + + test('input is editable when not disabled', () => { + render(); + expect(screen.getByRole('textbox')).not.toHaveAttribute('readOnly'); + }); + + test('calls updateLevel with trimmed name on blur when value changed', () => { + const updateLevel = jest.fn(); + render(); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, {target: {value: ' TOP SECRET '}}); + fireEvent.blur(input); + + expect(updateLevel).toHaveBeenCalledTimes(1); + expect(updateLevel).toHaveBeenCalledWith('level-1', {name: 'TOP SECRET'}); + }); + + test('does not call updateLevel on blur when value is unchanged', () => { + const updateLevel = jest.fn(); + render(); + + fireEvent.blur(screen.getByRole('textbox')); + + expect(updateLevel).not.toHaveBeenCalled(); + }); + + test('reflects local typing before blur', () => { + render(); + const input = screen.getByRole('textbox'); + fireEvent.change(input, {target: {value: 'CONFIDENTIAL'}}); + expect(input).toHaveValue('CONFIDENTIAL'); + }); + + test('syncs to updated external value prop', () => { + const {rerender} = render(); + expect(screen.getByRole('textbox')).toHaveValue('SECRET'); + + rerender(); + expect(screen.getByRole('textbox')).toHaveValue('TOP SECRET'); + }); +}); diff --git a/webapp/channels/src/components/admin_console/classification_markings/components/level_name_cell.tsx b/webapp/channels/src/components/admin_console/classification_markings/components/level_name_cell.tsx new file mode 100644 index 00000000000..b5523d80572 --- /dev/null +++ b/webapp/channels/src/components/admin_console/classification_markings/components/level_name_cell.tsx @@ -0,0 +1,39 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useState} from 'react'; + +import {BorderlessInput} from '../../system_properties/controls'; +import type {ClassificationLevel} from '../utils/presets'; + +type LevelNameCellProps = { + value: string; + id: string; + updateLevel: (id: string, updates: Partial) => void; + label: string; + disabled?: boolean; +}; + +export default function LevelNameCell({value, id, updateLevel, label, disabled}: LevelNameCellProps) { + const [localValue, setLocalValue] = useState(value); + + useEffect(() => { + setLocalValue(value); + }, [value]); + + return ( + ) => setLocalValue(e.target.value)} + onBlur={() => { + if (localValue !== value) { + updateLevel(id, {name: localValue.trim()}); + } + }} + /> + ); +} diff --git a/webapp/channels/src/components/admin_console/classification_markings/index.ts b/webapp/channels/src/components/admin_console/classification_markings/index.ts new file mode 100644 index 00000000000..4dd172ea1f3 --- /dev/null +++ b/webapp/channels/src/components/admin_console/classification_markings/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export {default} from './classification_markings'; +export {searchableStrings} from './classification_markings'; +export {detectPreset, optionsToLevels, levelsToOptions, fetchClassificationField, processClassificationField} from './utils'; diff --git a/webapp/channels/src/components/admin_console/classification_markings/utils/index.ts b/webapp/channels/src/components/admin_console/classification_markings/utils/index.ts new file mode 100644 index 00000000000..718f93ee5be --- /dev/null +++ b/webapp/channels/src/components/admin_console/classification_markings/utils/index.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {PropertyField, PropertyFieldOption} from '@mattermost/types/properties'; + +import {Client4} from 'mattermost-redux/client'; + +import type {ClassificationLevel} from './presets'; +import {PRESET_CUSTOM, presets} from './presets'; + +export const GROUP_NAME = 'custom_profile_attributes'; + +// OBJECT_TYPE is 'template' so the classification field acts as the canonical schema +// (a Linked Properties template). Per-channel fields will link to it and inherit its options. +export const OBJECT_TYPE = 'template'; +export const TARGET_TYPE = 'system'; + +// TARGET_ID is intentionally empty for system-scoped fields — the target is the whole system, +// not a specific entity. The Client4 helper skips the target_id query param when it's empty. +export const TARGET_ID = ''; +export const FIELD_NAME = 'classification'; + +export function detectPreset(levels: ClassificationLevel[]): string { + for (const preset of presets) { + if (preset.levels.length !== levels.length) { + continue; + } + const matches = preset.levels.every((presetLevel, i) => { + const level = levels[i]; + return presetLevel.name === level.name && presetLevel.color.toUpperCase() === level.color.toUpperCase() && presetLevel.rank === level.rank; + }); + if (matches) { + return preset.id; + } + } + return PRESET_CUSTOM; +} + +export function optionsToLevels(options: PropertyFieldOption[]): ClassificationLevel[] { + return options.map((opt, i) => ({ + id: opt.id, + name: opt.name, + color: opt.color || '#000000', + rank: opt.rank ?? (i + 1), + })).sort((a, b) => a.rank - b.rank); +} + +export function levelsToOptions(levels: ClassificationLevel[]): Array<{id: string; name: string; color: string; rank: number}> { + return levels.map((level) => ({ + id: level.id.startsWith('pending_') ? '' : level.id, + name: level.name, + color: level.color, + rank: level.rank, + })); +} + +export async function fetchClassificationField(): Promise { + const maxItems = 500; + let fetched = 0; + let cursorId: string | undefined; + let cursorCreateAt: number | undefined; + + while (fetched < maxItems) { + const fields = await Client4.getPropertyFields(GROUP_NAME, OBJECT_TYPE, TARGET_TYPE, TARGET_ID, {cursorId, cursorCreateAt}); // eslint-disable-line no-await-in-loop + const found = fields.find((f: PropertyField) => f.name === FIELD_NAME && f.delete_at === 0); + if (found || fields.length === 0) { + return found; + } + + fetched += fields.length; + const last = fields[fields.length - 1]; + cursorId = last.id; + cursorCreateAt = last.create_at; + } + + return undefined; +} + +export function processClassificationField(field: PropertyField): {levels: ClassificationLevel[]; presetId: string} { + const options = (field.attrs?.options as PropertyFieldOption[]) || []; + const levels = optionsToLevels(options); + const presetId = detectPreset(levels); + return {levels, presetId}; +} + +export async function saveCreateField(levels: ClassificationLevel[]): Promise { + const options = levelsToOptions(levels); + return Client4.createPropertyField(GROUP_NAME, OBJECT_TYPE, { + name: FIELD_NAME, + type: 'select' as PropertyField['type'], + target_type: TARGET_TYPE, + target_id: TARGET_ID, + attrs: {options, managed: 'admin'}, + permission_field: 'sysadmin', + permission_values: 'sysadmin', + permission_options: 'sysadmin', + }); +} + +export async function saveDeleteField(fieldId: string): Promise { + await Client4.deletePropertyField(GROUP_NAME, OBJECT_TYPE, fieldId); +} + +export async function savePatchField(fieldId: string, levels: ClassificationLevel[]): Promise { + const options = levelsToOptions(levels); + return Client4.patchPropertyField(GROUP_NAME, OBJECT_TYPE, fieldId, { + attrs: {options}, + } as Partial); +} diff --git a/webapp/channels/src/components/admin_console/classification_markings/utils/preset_dropdown_styles.ts b/webapp/channels/src/components/admin_console/classification_markings/utils/preset_dropdown_styles.ts new file mode 100644 index 00000000000..18c9ccb4c52 --- /dev/null +++ b/webapp/channels/src/components/admin_console/classification_markings/utils/preset_dropdown_styles.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {StylesConfig} from 'react-select'; + +import type {ValueType} from 'components/dropdown_input'; + +/** react-select styles for classification preset: 34px height, 16px chevron inset (Figma). */ +export const classificationPresetDropdownStyles: StylesConfig = { + input: (provided) => ({ + ...provided, + color: 'var(--center-channel-color)', + margin: 0, + paddingTop: 0, + paddingBottom: 0, + }), + control: (provided, state) => ({ + ...provided, + alignItems: 'center', + minHeight: 34, + height: 34, + border: '1px solid rgba(var(--center-channel-color-rgb), 0.16)', + borderRadius: 'var(--radius-s)', + boxShadow: 'none', + cursor: state.isDisabled ? 'not-allowed' : 'pointer', + paddingLeft: 12, + paddingRight: 16, + backgroundColor: 'var(--center-channel-bg)', + opacity: state.isDisabled ? 0.64 : 1, + ...(!state.isDisabled && + state.isFocused && { + borderColor: 'var(--button-bg)', + boxShadow: 'inset 0 0 0 1px var(--button-bg)', + }), + }), + valueContainer: (provided) => ({ + ...provided, + height: 32, + paddingTop: 0, + paddingBottom: 0, + paddingLeft: 0, + paddingRight: 4, + }), + singleValue: (provided) => ({ + ...provided, + lineHeight: '20px', + marginLeft: 0, + marginRight: 0, + }), + placeholder: (provided) => ({ + ...provided, + lineHeight: '20px', + margin: 0, + }), + indicatorsContainer: (provided) => ({ + ...provided, + height: 32, + padding: 0, + }), + indicatorSeparator: () => ({ + display: 'none', + }), + menu: (provided) => ({ + ...provided, + zIndex: 100, + }), + menuPortal: (provided) => ({ + ...provided, + zIndex: 200, + }), +}; diff --git a/webapp/channels/src/components/admin_console/classification_markings/utils/presets.ts b/webapp/channels/src/components/admin_console/classification_markings/utils/presets.ts new file mode 100644 index 00000000000..974230ec6f4 --- /dev/null +++ b/webapp/channels/src/components/admin_console/classification_markings/utils/presets.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export type ClassificationLevel = { + id: string; + name: string; + color: string; + rank: number; +}; + +export type ClassificationPreset = { + id: string; + label: string; + levels: ClassificationLevel[]; +}; + +export const PRESET_CUSTOM = 'custom'; + +export const presets: ClassificationPreset[] = [ + { + id: 'us', + label: 'United States', + levels: [ + {id: 'uxaiwq46xffc3x5x1bwpxukw9w', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}, + {id: 'fixk5qd3kid65m7enh5myccpfw', name: 'CUI', color: '#502B85', rank: 2}, + {id: 'cgomt91mij8wt8oxk9ip9xmtih', name: 'CONFIDENTIAL', color: '#0033A0', rank: 3}, + {id: 'fxca5dm5tjg9ufihgfpinc47yh', name: 'SECRET', color: '#C8102E', rank: 4}, + {id: 'wandytq84tdc7k5rq49q6mywhy', name: 'TOP SECRET', color: '#FF8C00', rank: 5}, + {id: '7q7stt4p7my3ep2w6xkqfmbnwa', name: 'TOP SECRET//SCI', color: '#FCE83A', rank: 6}, + ], + }, + { + id: 'uk', + label: 'UK (GSCP)', + levels: [ + {id: '8q3zbyu9xfre7ktckznjcdjbhh', name: 'OFFICIAL', color: '#2B71C7', rank: 1}, + {id: '3idhkzx5updcdf943dtqk7di5e', name: 'OFFICIAL-SENSITIVE', color: '#2B71C7', rank: 2}, + {id: 'pf7u934zifd1zgqeagy5fnxnye', name: 'SECRET', color: '#F39C2C', rank: 3}, + {id: '7tf4m9qe5jgxzkkk7mwm5ibzoc', name: 'TOP SECRET', color: '#AA0000', rank: 4}, + ], + }, + { + id: 'canada', + label: 'Canada', + levels: [ + {id: '5jy6zdfjg7ry3g9sa4mjmy59po', name: 'PROTECTED A', color: '#227ABC', rank: 1}, + {id: 'bijonwg7ktrmfkqx9biawaekpa', name: 'PROTECTED B', color: '#900FB5', rank: 2}, + {id: '8zgn1scm6pg6ux4z3igk7pgy1w', name: 'PROTECTED C', color: '#460FB5', rank: 3}, + {id: 'iqmtfhsautyopd47rrn4bjgfgw', name: 'CONFIDENTIAL', color: '#0033A0', rank: 4}, + {id: '8gtgn9cn13ns3fmixtg53qdwrc', name: 'SECRET', color: '#C8102E', rank: 5}, + {id: 'gthfsjzmnprtpmkutg1wskuajy', name: 'TOP SECRET', color: '#FF671F', rank: 6}, + ], + }, + { + id: 'australia', + label: 'Australia (PSPF)', + levels: [ + {id: 'f9ep3h5pai883gpibzmh69414a', name: 'UNOFFICIAL', color: '#FFFFFF', rank: 1}, + {id: 'kzjso9tq1bgyxrzgwcfedco6xw', name: 'OFFICIAL', color: '#D5D7D8', rank: 2}, + {id: 'nk3gy1fryfd67bc4go81gyhejh', name: 'OFFICIAL:Sensitive', color: '#FFEA00', rank: 3}, + {id: '3niq6ez9b7r13remwkqarkq5qc', name: 'PROTECTED', color: '#4676B6', rank: 4}, + {id: 'pwign3di7f84pfsi9zoa8cw5ko', name: 'SECRET', color: '#E2AFAE', rank: 5}, + {id: 'wridu7pp9fdqzmy3dcqk6nzesr', name: 'TOP SECRET', color: '#E1211D', rank: 6}, + ], + }, + { + id: 'nato', + label: 'NATO', + levels: [ + {id: 'iafbimm1w3razndyr6d5zckd8w', name: 'NATO UNCLASSIFIED', color: '#007A33', rank: 1}, + {id: 'kc3t8egt5if19m6zdeidui9rfw', name: 'NATO RESTRICTED', color: '#FF671F', rank: 2}, + {id: '7sagzm4u1fgczp31ppju6xk3gy', name: 'NATO CONFIDENTIAL', color: '#0033A0', rank: 3}, + {id: 'pk45zxegtjyy3bgnqwy4uq5i4a', name: 'NATO SECRET', color: '#C8102E', rank: 4}, + {id: 'brqmooby6frpdfikkr8pgo19jc', name: 'COSMIC TOP SECRET', color: '#F7EA48', rank: 5}, + ], + }, +]; diff --git a/webapp/channels/src/components/section_notice/index.tsx b/webapp/channels/src/components/section_notice/index.tsx index cb04c3085a4..83807118b37 100644 --- a/webapp/channels/src/components/section_notice/index.tsx +++ b/webapp/channels/src/components/section_notice/index.tsx @@ -20,6 +20,7 @@ type Props = { tertiaryButton?: SectionNoticeButtonProp; linkButton?: SectionNoticeButtonProp; type?: 'info' | 'success' | 'danger' | 'welcome' | 'warning' | 'hint'; + iconOverride?: string; isDismissable?: boolean; onDismissClick?: () => void; }; @@ -41,11 +42,12 @@ const SectionNotice = ({ tertiaryButton, linkButton, type = 'info', + iconOverride, isDismissable, onDismissClick, }: Props) => { const intl = useIntl(); - const icon = iconByType[type]; + const icon = iconOverride || iconByType[type]; const showDismiss = Boolean(isDismissable && onDismissClick); const hasButtons = Boolean(primaryButton || secondaryButton || tertiaryButton || linkButton); return ( diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 1689678a57f..89a67df54d9 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -655,6 +655,33 @@ "admin.channelSettings.channelDetail.channel_organizations": "Organizations", "admin.channelSettings.channelDetail.channelName": "Name", "admin.channelSettings.channelDetail.channelTeam": "Team", + "admin.classification_markings.color.open_picker": "Open color picker", + "admin.classification_markings.enable.description": "Use this to enable classification markings as banners at the system and channel level. You can pre-select text and colors for your banner, as well as set a default option for consistency.", + "admin.classification_markings.enable.false": "False", + "admin.classification_markings.enable.title": "Enable classification markings", + "admin.classification_markings.enable.true": "True", + "admin.classification_markings.error.delete_has_dependents": "Cannot disable classification markings while channel classifications exist. Remove all channel classification markings first.", + "admin.classification_markings.error.duplicate_name": "Classification level names must be unique. Duplicate: {name}", + "admin.classification_markings.error.empty_name": "All classification levels must have a name.", + "admin.classification_markings.error.no_levels": "At least one classification level is required when classification markings are enabled.", + "admin.classification_markings.levels.add": "Add level", + "admin.classification_markings.levels.description": "Text and colors for different classification levels that will be used in the system", + "admin.classification_markings.levels.table.color": "Color", + "admin.classification_markings.levels.table.delete": "Delete level", + "admin.classification_markings.levels.table.rank": "Rank", + "admin.classification_markings.levels.table.text": "Text", + "admin.classification_markings.levels.table.text.input": "Classification level name", + "admin.classification_markings.levels.title": "Classification levels", + "admin.classification_markings.load_error": "Failed to load classification markings: {error}", + "admin.classification_markings.notice.body": "Markings are not tied to access control decisions at this time and are for display purposes only.", + "admin.classification_markings.notice.title": "Classification markings are informational only", + "admin.classification_markings.preset_switch.confirm": "Change preset", + "admin.classification_markings.preset_switch.message": "Changing the classification preset will affect all existing classifications across the system. Any channels, files, or other resources marked with the current classification levels may lose their markings.", + "admin.classification_markings.preset_switch.title": "Change classification preset?", + "admin.classification_markings.preset.custom": "Custom classification levels", + "admin.classification_markings.preset.description": "Select a classification preset from the dropdown menu based on your country affiliation. This will help tailor the options to your specific needs. You can also create custom classification levels.", + "admin.classification_markings.preset.title": "Classification preset", + "admin.classification_markings.saving": "Saving...", "admin.cluster.ClusterName": "Cluster Name:", "admin.cluster.ClusterNameDesc": "The cluster to join by name. Only nodes with the same cluster name will join together. This is to support Blue-Green deployments or staging pointing to the same database.", "admin.cluster.ClusterNameEx": "E.g.: \"Production\" or \"Staging\"", @@ -2886,6 +2913,7 @@ "admin.sidebar.billing": "Billing & Account", "admin.sidebar.billing_history": "Billing History", "admin.sidebar.channels": "Channels", + "admin.sidebar.classificationMarkings": "Classification Markings", "admin.sidebar.company_info": "Company Information", "admin.sidebar.compliance": "Compliance", "admin.sidebar.complianceExport": "Compliance Export", diff --git a/webapp/platform/client/src/client4.test.ts b/webapp/platform/client/src/client4.test.ts index af0377952e1..01797a1f57a 100644 --- a/webapp/platform/client/src/client4.test.ts +++ b/webapp/platform/client/src/client4.test.ts @@ -17,6 +17,100 @@ describe('Client4', () => { nock.restore(); }); + describe('property field routes', () => { + let client: Client4; + + beforeEach(() => { + client = new Client4(); + client.setUrl('http://mattermost.example.com'); + }); + + test('getPropertyFieldsRoute should build correct URL', () => { + expect(client.getPropertyFieldsRoute('my_group', 'user')).toBe( + 'http://mattermost.example.com/api/v4/properties/groups/my_group/user/fields', + ); + }); + + test('getPropertyFieldRoute should build correct URL', () => { + expect(client.getPropertyFieldRoute('my_group', 'user', 'field123')).toBe( + 'http://mattermost.example.com/api/v4/properties/groups/my_group/user/fields/field123', + ); + }); + + test('getPropertyFields should send GET with correct query params', async () => { + const fields = [{id: 'f1', name: 'test'}]; + nock(client.getBaseRoute()). + get('/properties/groups/grp/user/fields'). + query({target_type: 'system', per_page: '10', cursor_id: 'abc', cursor_create_at: '999'}). + reply(200, fields); + + const result = await client.getPropertyFields('grp', 'user', 'system', undefined, { + perPage: 10, + cursorId: 'abc', + cursorCreateAt: 999, + }); + + expect(result).toEqual(fields); + }); + + test('getPropertyFields should include target_id when provided', async () => { + nock(client.getBaseRoute()). + get('/properties/groups/grp/user/fields'). + query({target_type: 'channel', target_id: 'ch1'}). + reply(200, []); + + const result = await client.getPropertyFields('grp', 'user', 'channel', 'ch1'); + expect(result).toEqual([]); + }); + + test('getPropertyFields should send minimal params when no options', async () => { + nock(client.getBaseRoute()). + get('/properties/groups/grp/user/fields'). + query({target_type: 'system'}). + reply(200, []); + + const result = await client.getPropertyFields('grp', 'user', 'system'); + expect(result).toEqual([]); + }); + + test('createPropertyField should send POST with field body', async () => { + const field = {name: 'classification', type: 'select' as const, target_type: 'system'}; + const created = {id: 'new1', ...field}; + + nock(client.getBaseRoute()). + post('/properties/groups/grp/user/fields', (body) => { + return body.name === 'classification' && body.type === 'select'; + }). + reply(201, created); + + const result = await client.createPropertyField('grp', 'user', field); + expect(result).toEqual(created); + }); + + test('patchPropertyField should send PATCH to field URL', async () => { + const patch = {attrs: {options: []}}; + const patched = {id: 'f1', name: 'classification', attrs: {options: []}}; + + nock(client.getBaseRoute()). + patch('/properties/groups/grp/user/fields/f1', (body) => { + return body.attrs !== undefined; + }). + reply(200, patched); + + const result = await client.patchPropertyField('grp', 'user', 'f1', patch); + expect(result).toEqual(patched); + }); + + test('deletePropertyField should send DELETE to field URL', async () => { + nock(client.getBaseRoute()). + delete('/properties/groups/grp/user/fields/f1'). + reply(200, {status: 'OK'}); + + const result = await client.deletePropertyField('grp', 'user', 'f1'); + expect(result).toEqual({status: 'OK'}); + }); + }); + describe('doFetchWithResponse', () => { test('serverVersion should be set from response header', async () => { const client = new Client4(); diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index e70d7f52632..5fda93821b2 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -114,10 +114,10 @@ import type {PreferenceType} from '@mattermost/types/preferences'; import type {ProductNotices} from '@mattermost/types/product_notices'; import type { NameMappedPropertyFields, + PropertyField, UserPropertyField, UserPropertyFieldPatch, PropertyValue, - PropertyField, } from '@mattermost/types/properties'; import type {Reaction} from '@mattermost/types/reactions'; import type {Recap, CreateRecapRequest} from '@mattermost/types/recaps'; @@ -350,6 +350,14 @@ export default class Client4 { return `${this.getRemoteClustersRoute()}/${remoteId}`; } + getPropertyFieldsRoute(groupName: string, objectType: string) { + return `${this.getBaseRoute()}/properties/groups/${groupName}/${objectType}/fields`; + } + + getPropertyFieldRoute(groupName: string, objectType: string, fieldId: string) { + return `${this.getPropertyFieldsRoute(groupName, objectType)}/${fieldId}`; + } + getCustomProfileAttributeFieldsRoute() { return `${this.getBaseRoute()}/custom_profile_attributes/fields`; } @@ -2136,13 +2144,6 @@ export default class Client4 { ); }; - getPropertyFields = (groupName: string, objectType: string, targetType: string) => { - return this.doFetch( - `${this.getBaseRoute()}/properties/groups/${groupName}/${objectType}/fields?target_type=${targetType}`, - {method: 'get'}, - ); - }; - getPropertyValues = (groupName: string, objectType: string, targetId: string) => { return this.doFetch>>( `${this.getBaseRoute()}/properties/groups/${groupName}/${objectType}/values/${targetId}`, @@ -2306,6 +2307,49 @@ export default class Client4 { ); }; + // Generic Property Field Routes + + getPropertyFields = async (groupName: string, objectType: string, targetType: string, targetId?: string, options?: {perPage?: number; cursorId?: string; cursorCreateAt?: number}) => { + const params = new URLSearchParams({target_type: targetType}); + if (targetId) { + params.set('target_id', targetId); + } + if (options?.perPage) { + params.set('per_page', String(options.perPage)); + } + if (options?.cursorId) { + params.set('cursor_id', options.cursorId); + } + if (options?.cursorCreateAt !== undefined) { + params.set('cursor_create_at', String(options.cursorCreateAt)); + } + return this.doFetch( + `${this.getPropertyFieldsRoute(groupName, objectType)}?${params.toString()}`, + {method: 'GET'}, + ); + }; + + createPropertyField = async (groupName: string, objectType: string, field: Partial & Record) => { + return this.doFetch( + `${this.getPropertyFieldsRoute(groupName, objectType)}`, + {method: 'POST', body: JSON.stringify(field)}, + ); + }; + + patchPropertyField = async (groupName: string, objectType: string, fieldId: string, patch: Partial & Record) => { + return this.doFetch( + `${this.getPropertyFieldRoute(groupName, objectType, fieldId)}`, + {method: 'PATCH', body: JSON.stringify(patch)}, + ); + }; + + deletePropertyField = async (groupName: string, objectType: string, fieldId: string) => { + return this.doFetch( + `${this.getPropertyFieldRoute(groupName, objectType, fieldId)}`, + {method: 'DELETE'}, + ); + }; + getUserCustomProfileAttributesValues = async (userID: string) => { const data = await this.doFetch>( `${this.getUserRoute(userID)}/custom_profile_attributes`, diff --git a/webapp/platform/types/src/properties.ts b/webapp/platform/types/src/properties.ts index 93409583f17..ae5e6b55ca0 100644 --- a/webapp/platform/types/src/properties.ts +++ b/webapp/platform/types/src/properties.ts @@ -24,6 +24,7 @@ export type PropertyField = { target_id: string; target_type: string; object_type: string; + linked_field_id?: string; create_at: number; update_at: number; delete_at: number; @@ -67,6 +68,7 @@ export type PropertyFieldOption = { id: string; name: string; color?: string; + rank?: number; } export type UserPropertyField = PropertyField & {