MM-63907, MM-63906: Smooth navigation between User Properties and LDAP/SAML pages. (#31127)

Этот коммит содержится в:
Caleb Roseland
2025-05-23 16:26:31 -05:00
коммит произвёл GitHub
родитель 2358699d91
Коммит db27d1edec
7 изменённых файлов: 273 добавлений и 145 удалений

Просмотреть файл

@@ -1,7 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react'; import type {Location} from 'history';
import type {RefCallback} from 'react';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {Route, Switch, Redirect} from 'react-router-dom'; import {Route, Switch, Redirect} from 'react-router-dom';
import type {RouteComponentProps} from 'react-router-dom'; import type {RouteComponentProps} from 'react-router-dom';
@@ -31,10 +33,6 @@ import type {PropsFromRedux} from './index';
export type Props = PropsFromRedux & RouteComponentProps; export type Props = PropsFromRedux & RouteComponentProps;
type State = {
search: string;
}
// not every page in the system console will need the license and config, but the vast majority will // not every page in the system console will need the license and config, but the vast majority will
type ExtraProps = { type ExtraProps = {
enterpriseReady: boolean; enterpriseReady: boolean;
@@ -49,39 +47,72 @@ type ExtraProps = {
isCurrentUserSystemAdmin: boolean; isCurrentUserSystemAdmin: boolean;
} }
class AdminConsole extends React.PureComponent<Props, State> { /**
public constructor(props: Props) { * Focus or scroll to a provided hash for the given {@link Location}.
super(props); * @returns a ref callback that should to be attached to an ancestor of the target hash element.
this.state = { * @remarks emulates standard browser URL hash scroll-to behavior, but also works in custom or nested scroll containers.
search: '', */
}; const useFocusScroller = (location: Location): RefCallback<HTMLElement> => {
} const lastFocusedLocation = useRef<Location>();
public componentDidMount(): void { return useCallback((node) => {
this.props.actions.getConfig(); if (!node || !location.hash || lastFocusedLocation.current === location) {
this.props.actions.getEnvironmentConfig(); // if there is no node or hash, or if we've already focused the hash for this location
this.props.actions.loadRolesIfNeeded(['channel_user', 'team_user', 'system_user', 'channel_admin', 'team_admin', 'system_admin', 'system_user_manager', 'system_custom_group_admin', 'system_read_only_admin', 'system_manager']); return;
this.props.actions.selectLhsItem(LhsItemType.None); }
this.props.actions.selectTeam('');
const id = decodeURIComponent(location.hash.substring(1));
if (!id) {
return;
}
const element = document.getElementById(id);
if (!element) {
return;
}
// focus the element, or scroll it into view if it couldn't be focused as a fallback
element.focus();
if (document.activeElement !== element) {
element.scrollIntoView({behavior: 'auto'});
}
// only focus a hash for a given location once
lastFocusedLocation.current = location;
}, [location]);
};
const AdminConsole = (props: Props) => {
const [search, setSearch] = useState('');
const handleFocusScroller = useFocusScroller(props.location);
useEffect(() => {
props.actions.getConfig();
props.actions.getEnvironmentConfig();
props.actions.loadRolesIfNeeded(['channel_user', 'team_user', 'system_user', 'channel_admin', 'team_admin', 'system_admin', 'system_user_manager', 'system_custom_group_admin', 'system_read_only_admin', 'system_manager']);
props.actions.selectLhsItem(LhsItemType.None);
props.actions.selectTeam('');
document.body.classList.add('console__body'); document.body.classList.add('console__body');
document.getElementById('root')?.classList.add('console__root'); document.getElementById('root')?.classList.add('console__root');
resetTheme(); resetTheme();
}
public componentWillUnmount(): void { return () => {
document.body.classList.remove('console__body'); document.body.classList.remove('console__body');
document.getElementById('root')?.classList.remove('console__root'); document.getElementById('root')?.classList.remove('console__root');
applyTheme(this.props.currentTheme); applyTheme(props.currentTheme);
// Reset the admin console users management table properties // Reset the admin console users management table properties
this.props.actions.setAdminConsoleUsersManagementTableProperties(); props.actions.setAdminConsoleUsersManagementTableProperties();
} };
}, []);
private handleSearchChange = (search: string) => { const handleSearchChange = (searchTerm: string) => {
this.setState({search}); setSearch(searchTerm);
}; };
private mainRolesLoaded(roles: Record<string, Role>) { const mainRolesLoaded = (roles: Record<string, Role>) => {
return ( return (
roles && roles &&
roles.channel_admin && roles.channel_admin &&
@@ -95,15 +126,15 @@ class AdminConsole extends React.PureComponent<Props, State> {
roles.system_custom_group_admin && roles.system_custom_group_admin &&
roles.system_manager roles.system_manager
); );
} };
private renderRoutes = (extraProps: ExtraProps) => { const renderRoutes = (extraProps: ExtraProps) => {
const {adminDefinition, config, license, buildEnterpriseReady, consoleAccess, cloud, isCurrentUserSystemAdmin} = this.props; const {adminDefinition, config, license, buildEnterpriseReady, consoleAccess, cloud, isCurrentUserSystemAdmin} = props;
const schemas: AdminDefinitionSubSection[] = Object.values(adminDefinition).flatMap((section: AdminDefinitionSection) => { const schemas: AdminDefinitionSubSection[] = Object.values(adminDefinition).flatMap((section: AdminDefinitionSection) => {
let isSectionHidden = false; let isSectionHidden = false;
if (typeof section.isHidden === 'function') { if (typeof section.isHidden === 'function') {
isSectionHidden = section.isHidden(config, this.state, license, buildEnterpriseReady, consoleAccess, cloud, isCurrentUserSystemAdmin); isSectionHidden = section.isHidden(config, {search}, license, buildEnterpriseReady, consoleAccess, cloud, isCurrentUserSystemAdmin);
} else { } else {
isSectionHidden = Boolean(section.isHidden); isSectionHidden = Boolean(section.isHidden);
} }
@@ -117,7 +148,7 @@ class AdminConsole extends React.PureComponent<Props, State> {
const schemaRoutes = schemas.map((item: AdminDefinitionSubSection, index: number) => { const schemaRoutes = schemas.map((item: AdminDefinitionSubSection, index: number) => {
if (typeof item.isHidden !== 'undefined') { if (typeof item.isHidden !== 'undefined') {
const isHidden = (typeof item.isHidden === 'function') ? item.isHidden(config, this.state, license, buildEnterpriseReady, consoleAccess, cloud, isCurrentUserSystemAdmin) : Boolean(item.isHidden); const isHidden = (typeof item.isHidden === 'function') ? item.isHidden(config, {search}, license, buildEnterpriseReady, consoleAccess, cloud, isCurrentUserSystemAdmin) : Boolean(item.isHidden);
if (isHidden) { if (isHidden) {
return false; return false;
} }
@@ -126,7 +157,7 @@ class AdminConsole extends React.PureComponent<Props, State> {
let isItemDisabled: boolean; let isItemDisabled: boolean;
if (typeof item.isDisabled === 'function') { if (typeof item.isDisabled === 'function') {
isItemDisabled = item.isDisabled(config, this.state, license, buildEnterpriseReady, consoleAccess, cloud, isCurrentUserSystemAdmin); isItemDisabled = item.isDisabled(config, {search}, license, buildEnterpriseReady, consoleAccess, cloud, isCurrentUserSystemAdmin);
} else { } else {
isItemDisabled = Boolean(item.isDisabled); isItemDisabled = Boolean(item.isDisabled);
} }
@@ -144,12 +175,12 @@ class AdminConsole extends React.PureComponent<Props, State> {
return ( return (
<Route <Route
key={item.url} key={item.url}
path={`${this.props.match.url}/${item.url}`} path={`${props.match.url}/${item.url}`}
render={(props) => ( render={(routeProps) => (
<SchemaAdminSettings <SchemaAdminSettings
{...extraProps} {...extraProps}
{...props} {...routeProps}
consoleAccess={this.props.consoleAccess} consoleAccess={props.consoleAccess}
schema={item.schema} schema={item.schema}
isDisabled={isItemDisabled} isDisabled={isItemDisabled}
/> />
@@ -161,80 +192,79 @@ class AdminConsole extends React.PureComponent<Props, State> {
return ( return (
<Switch> <Switch>
{schemaRoutes} {schemaRoutes}
{<Redirect to={`${this.props.match.url}/${defaultUrl}`}/>} {<Redirect to={`${props.match.url}/${defaultUrl}`}/>}
</Switch> </Switch>
); );
}; };
public render(): JSX.Element | null { const {
const { license,
license, config,
config, environmentConfig,
environmentConfig, showNavigationPrompt,
showNavigationPrompt, roles,
roles, } = props;
} = this.props; const {setNavigationBlocked, cancelNavigation, confirmNavigation, editRole, patchConfig} = props.actions;
const {setNavigationBlocked, cancelNavigation, confirmNavigation, editRole, patchConfig} = this.props.actions;
if (!this.props.currentUserHasAnAdminRole) {
return (
<Redirect to={this.props.unauthorizedRoute}/>
);
}
if (!this.mainRolesLoaded(this.props.roles)) {
return null;
}
if (Object.keys(config).length === 0) {
return <div/>;
}
if (config && Object.keys(config).length === 0 && config.constructor === Object) {
return (
<div className='admin-console__wrapper admin-console'/>
);
}
const extraProps: ExtraProps = {
enterpriseReady: this.props.buildEnterpriseReady,
license,
config,
environmentConfig,
setNavigationBlocked,
roles,
editRole,
patchConfig,
cloud: this.props.cloud,
isCurrentUserSystemAdmin: this.props.isCurrentUserSystemAdmin,
};
if (!props.currentUserHasAnAdminRole) {
return ( return (
<> <Redirect to={props.unauthorizedRoute}/>
<AnnouncementBarController/>
<SystemNotice/>
<BackstageNavbar team={this.props.team}/>
<AdminSidebar onSearchChange={this.handleSearchChange}/>
<div
className='admin-console__wrapper admin-console'
id='adminConsoleWrapper'
>
<SearchKeywordMarking
keyword={this.state.search}
pathname={this.props.location.pathname}
>
{this.renderRoutes(extraProps)}
</SearchKeywordMarking>
</div>
<DiscardChangesModal
show={showNavigationPrompt}
onConfirm={confirmNavigation}
onCancel={cancelNavigation}
/>
<ModalController/>
</>
); );
} }
}
if (!mainRolesLoaded(props.roles)) {
return null;
}
if (Object.keys(config).length === 0) {
return <div/>;
}
if (config && Object.keys(config).length === 0 && config.constructor === Object) {
return (
<div className='admin-console__wrapper admin-console'/>
);
}
const extraProps: ExtraProps = {
enterpriseReady: props.buildEnterpriseReady,
license,
config,
environmentConfig,
setNavigationBlocked,
roles,
editRole,
patchConfig,
cloud: props.cloud,
isCurrentUserSystemAdmin: props.isCurrentUserSystemAdmin,
};
return (
<>
<AnnouncementBarController/>
<SystemNotice/>
<BackstageNavbar team={props.team}/>
<AdminSidebar onSearchChange={handleSearchChange}/>
<div
className='admin-console__wrapper admin-console'
id='adminConsoleWrapper'
ref={handleFocusScroller}
>
<SearchKeywordMarking
keyword={search}
pathname={props.location.pathname}
>
{renderRoutes(extraProps)}
</SearchKeywordMarking>
</div>
<DiscardChangesModal
show={showNavigationPrompt}
onConfirm={confirmNavigation}
onCancel={cancelNavigation}
/>
<ModalController/>
</>
);
};
export default AdminConsole; export default AdminConsole;

Просмотреть файл

@@ -79,7 +79,8 @@ class AdminSidebar extends React.PureComponent<Props, State> {
this.props.actions.getPlugins(); this.props.actions.getPlugins();
} }
if (this.searchRef.current) { if (this.searchRef.current && !getHistory().location.hash) {
// default focus if no other target/hash is specified for auto-focus
this.searchRef.current.focus(); this.searchRef.current.focus();
} }
@@ -208,13 +209,11 @@ class AdminSidebar extends React.PureComponent<Props, State> {
definitionKey={subDefinitionKey} definitionKey={subDefinitionKey}
name={item.url} name={item.url}
restrictedIndicator={item.restrictedIndicator?.shouldDisplay(license, subscriptionProduct) ? item.restrictedIndicator.value(cloud) : undefined} restrictedIndicator={item.restrictedIndicator?.shouldDisplay(license, subscriptionProduct) ? item.restrictedIndicator.value(cloud) : undefined}
title={ title={typeof item.title === 'string' ? item.title : (
typeof item.title === 'string' ? <FormattedMessage
item.title : {...item.title}
<FormattedMessage />
{...item.title} )}
/>
}
/> />
)); ));
}); });
@@ -237,13 +236,11 @@ class AdminSidebar extends React.PureComponent<Props, State> {
parentLink='/admin_console' parentLink='/admin_console'
icon={section.icon} icon={section.icon}
sectionClass='' sectionClass=''
title={ title={typeof section.sectionTitle === 'string' ? section.sectionTitle : (
typeof section.sectionTitle === 'string' ? <FormattedMessage
section.sectionTitle : {...section.sectionTitle}
<FormattedMessage />
{...section.sectionTitle} )}
/>
}
> >
{sidebarItems} {sidebarItems}
</AdminSidebarCategory> </AdminSidebarCategory>

Просмотреть файл

@@ -30,3 +30,13 @@
font-weight: 600; font-weight: 600;
} }
} }
.user-property-field-dotmenu__chip {
padding: 4px 12px;
border-radius: 12px;
background: rgba(var(--center-channel-color-rgb), 0.08);
color: var(--center-channel-color);
font-size: 12px;
font-style: normal;
font-weight: 600;
}

Просмотреть файл

@@ -110,7 +110,7 @@ describe('UserPropertyDotMenu', () => {
}); });
}); });
it('displays LDAP and SAML link menu options', async () => { it('displays LDAP and SAML link menu options for existing fields', async () => {
renderComponent(); renderComponent();
// Open the menu // Open the menu
@@ -120,8 +120,63 @@ describe('UserPropertyDotMenu', () => {
// Verify both link options are shown // Verify both link options are shown
expect(screen.getByText('Link property to AD/LDAP')).toBeInTheDocument(); expect(screen.getByText('Link property to AD/LDAP')).toBeInTheDocument();
expect(screen.getByText('Link property to SAML')).toBeInTheDocument(); expect(screen.getByText('Link property to SAML')).toBeInTheDocument();
});
// TODO mock history and verify the link actions it('hides LDAP and SAML link menu options for pending fields', async () => {
const pendingField = {
...baseField,
create_at: 0, // Mark as pending creation
};
renderComponent(pendingField);
// Open the menu
const menuButton = screen.getByTestId(`user-property-field_dotmenu-${pendingField.id}`);
fireEvent.click(menuButton);
// Verify both link options are not shown
expect(screen.queryByText('Link property to AD/LDAP')).not.toBeInTheDocument();
expect(screen.queryByText('Link property to SAML')).not.toBeInTheDocument();
});
it('shows "Edit link with" text when LDAP attribute is linked', async () => {
const linkedField = {
...baseField,
attrs: {
...baseField.attrs,
ldap: 'employeeID',
},
};
renderComponent(linkedField);
// Open the menu
const menuButton = screen.getByTestId(`user-property-field_dotmenu-${linkedField.id}`);
fireEvent.click(menuButton);
// Verify the LDAP link text shows the linked property
expect(screen.getByText('Edit link with:')).toBeInTheDocument();
expect(screen.getByText('AD/LDAP: employeeID')).toBeInTheDocument();
});
it('shows "Edit link with" text when SAML attribute is linked', async () => {
const linkedField = {
...baseField,
attrs: {
...baseField.attrs,
saml: 'position',
},
};
renderComponent(linkedField);
// Open the menu
const menuButton = screen.getByTestId(`user-property-field_dotmenu-${linkedField.id}`);
fireEvent.click(menuButton);
// Verify the SAML link text shows the linked property
expect(screen.getByText('Edit link with:')).toBeInTheDocument();
expect(screen.getByText('SAML: position')).toBeInTheDocument();
}); });
it('handles field duplication', async () => { it('handles field duplication', async () => {

Просмотреть файл

@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import type {ComponentProps} from 'react';
import React from 'react'; import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl'; import {FormattedMessage, useIntl} from 'react-intl';
@@ -12,6 +13,7 @@ import * as Menu from 'components/menu';
import './user_properties_dot_menu.scss'; import './user_properties_dot_menu.scss';
import {useUserPropertyFieldDelete} from './user_properties_delete_modal'; import {useUserPropertyFieldDelete} from './user_properties_delete_modal';
import {isCreatePending} from './user_properties_utils'; import {isCreatePending} from './user_properties_utils';
type Props = { type Props = {
field: UserPropertyField; field: UserPropertyField;
canCreate: boolean; canCreate: boolean;
@@ -174,28 +176,50 @@ const DotMenu = ({
)} )}
/> />
</Menu.SubMenu> </Menu.SubMenu>
<Menu.LinkItem {field.create_at !== 0 && ([
id={`${menuId}_link_ad-ldap`} <Menu.LinkItem
to={`/admin_console/authentication/ldap#custom_profile_attribute-${field.name}`} key={`${menuId}_link_ad-ldap`}
leadingElement={<SyncIcon size={18}/>} id={`${menuId}_link_ad-ldap`}
labels={( to={`/admin_console/authentication/ldap#custom_profile_attribute-${field.name}`}
<FormattedMessage leadingElement={<SyncIcon size={18}/>}
id='admin.system_properties.user_properties.dotmenu.ad_ldap.link_property.label' labels={field.attrs.ldap ? (
defaultMessage={'Link property to AD/LDAP'} <FormattedMessage
/> id='admin.system_properties.user_properties.dotmenu.ad_ldap.edit_link.label'
)} defaultMessage={'Edit link with: <Chip>AD/LDAP: {propertyName}</Chip>'}
/> values={{
<Menu.LinkItem Chip: (chunks: React.ReactNode) => <Chip>{chunks}</Chip>,
id={`${menuId}_link_ad-ldap`} propertyName: field.attrs.ldap,
to={`/admin_console/authentication/saml#custom_profile_attribute-${field.name}`} }}
leadingElement={<SyncIcon size={18}/>} />
labels={( ) : (
<FormattedMessage <FormattedMessage
id='admin.system_properties.user_properties.dotmenu.saml.link_property.label' id='admin.system_properties.user_properties.dotmenu.ad_ldap.link_property.label'
defaultMessage={'Link property to SAML'} defaultMessage={'Link property to AD/LDAP'}
/> />
)} )}
/> />,
<Menu.LinkItem
key={`${menuId}_link_saml`}
id={`${menuId}_link_saml`}
to={`/admin_console/authentication/saml#custom_profile_attribute-${field.name}`}
leadingElement={<SyncIcon size={18}/>}
labels={field.attrs.saml ? (
<FormattedMessage
id='admin.system_properties.user_properties.dotmenu.saml.edit_link.label'
defaultMessage={'Edit link with: <Chip>SAML: {propertyName}</Chip>'}
values={{
Chip: (chunks: React.ReactNode) => <Chip>{chunks}</Chip>,
propertyName: field.attrs.saml,
}}
/>
) : (
<FormattedMessage
id='admin.system_properties.user_properties.dotmenu.saml.link_property.label'
defaultMessage={'Link property to SAML'}
/>
)}
/>,
])}
<Menu.Separator/> <Menu.Separator/>
{canCreate && ( {canCreate && (
<Menu.Item <Menu.Item
@@ -226,4 +250,13 @@ const DotMenu = ({
); );
}; };
const Chip = ({children, ...rest}: ComponentProps<'span'>) => (
<span
className='user-property-field-dotmenu__chip'
{...rest}
>
{children}
</span>
);
export default DotMenu; export default DotMenu;

Просмотреть файл

@@ -2729,10 +2729,12 @@
"admin.system_properties.details.saving_changes": "Saving configuration…", "admin.system_properties.details.saving_changes": "Saving configuration…",
"admin.system_properties.details.saving_changes_error": "There was an error while saving the configuration", "admin.system_properties.details.saving_changes_error": "There was an error while saving the configuration",
"admin.system_properties.user_properties.add_property": "Add property", "admin.system_properties.user_properties.add_property": "Add property",
"admin.system_properties.user_properties.dotmenu.ad_ldap.edit_link.label": "Edit link with: <Chip>AD/LDAP: {propertyName}</Chip>",
"admin.system_properties.user_properties.dotmenu.ad_ldap.link_property.label": "Link property to AD/LDAP", "admin.system_properties.user_properties.dotmenu.ad_ldap.link_property.label": "Link property to AD/LDAP",
"admin.system_properties.user_properties.dotmenu.delete.label": "Delete property", "admin.system_properties.user_properties.dotmenu.delete.label": "Delete property",
"admin.system_properties.user_properties.dotmenu.duplicate.label": "Duplicate property", "admin.system_properties.user_properties.dotmenu.duplicate.label": "Duplicate property",
"admin.system_properties.user_properties.dotmenu.duplicate.name_copy": "{fieldName} (copy)", "admin.system_properties.user_properties.dotmenu.duplicate.name_copy": "{fieldName} (copy)",
"admin.system_properties.user_properties.dotmenu.saml.edit_link.label": "Edit link with: <Chip>SAML: {propertyName}</Chip>",
"admin.system_properties.user_properties.dotmenu.saml.link_property.label": "Link property to SAML", "admin.system_properties.user_properties.dotmenu.saml.link_property.label": "Link property to SAML",
"admin.system_properties.user_properties.dotmenu.visibility.always.label": "Always show", "admin.system_properties.user_properties.dotmenu.visibility.always.label": "Always show",
"admin.system_properties.user_properties.dotmenu.visibility.hidden.label": "Always hide", "admin.system_properties.user_properties.dotmenu.visibility.hidden.label": "Always hide",

Просмотреть файл

@@ -13,6 +13,7 @@
height: 100%; height: 100%;
color: functions.v(center-channel-color); color: functions.v(center-channel-color);
grid-area: center; grid-area: center;
scroll-behavior: smooth;
> div { > div {
width: 100%; width: 100%;