[MM-61756] Attribute Based Access Control - Phase 1 (#30785)

Attribute Based Access Control - Base
* MM-63662

* MM-63919

* MM-63954

* MM-63955 

* MM-63425

* MM-63426

* MM-63458

* MM-63459

* MM-63603

* MM-63845

* MM-64146

* MM-64199

* MM-64201

* MM-64233

* MM-64247

* MM-64268

---------

Co-authored-by: Harshil Sharma <harshilsharma63@gmail.com>
Co-authored-by: Pablo Andrés Vélez Vidal <pablovv2012@gmail.com>
Co-authored-by: abhijit-singh <abhijitsingh0702@gmail.com>
Co-authored-by: Harrison Healey <harrisonmhealey@gmail.com>
Этот коммит содержится в:
Ibrahim Serdar Acikgoz
2025-05-15 11:33:08 +02:00
коммит произвёл GitHub
родитель 4b445cbf16
Коммит a344b3225b
156 изменённых файлов: 14382 добавлений и 621 удалений

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

@@ -54,6 +54,8 @@
"marked": "github:mattermost/marked#3b13ba8ddf725327ddf0298361d6d304a021f2d1",
"memoize-one": "6.0.0",
"moment-timezone": "0.5.38",
"monaco-editor": "0.52.2",
"monaco-editor-webpack-plugin": "7.1.0",
"p-queue": "7.3.0",
"pdfjs-dist": "4.4.168",
"process": "0.11.10",

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

@@ -0,0 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {jest} from '@jest/globals';
const monacoMock = {
editor: {
create: jest.fn(),
defineTheme: jest.fn(),
setTheme: jest.fn(),
},
languages: {
registerCompletionItemProvider: jest.fn(),
},
};
export default monacoMock;

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

@@ -0,0 +1,274 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/admin_console/access_control/PolicyList should match snapshot with no policies 1`] = `
<div
className="PolicyTable"
>
<div
className="policy-header"
>
<div
className="policy-header-text"
>
<h1>
<MemoizedFormattedMessage
defaultMessage="Access Control Policies"
id="admin.access_control.policies.title"
/>
</h1>
<p>
<MemoizedFormattedMessage
defaultMessage="Create policies containing attribute based access rules and the resources they apply to."
id="admin.access_control.policies.description"
/>
</p>
</div>
<button
className="btn btn-primary"
onClick={[Function]}
>
<i
className="icon icon-plus"
/>
<span>
<MemoizedFormattedMessage
defaultMessage="Add policy"
id="admin.access_control.policies.add_policy"
/>
</span>
</button>
</div>
<DataGrid
columns={
Array [
Object {
"field": "name",
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Name"
id="admin.access_control.policies.name"
/>,
"width": 5,
},
Object {
"field": "resources",
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Applies to"
id="admin.access_control.policies.applies_to"
/>,
"textAlign": "center",
"width": 4,
},
Object {
"className": "actions-column",
"field": "actions",
"name": <span />,
"width": 1,
},
]
}
endCount={0}
loading={false}
nextPage={[Function]}
onSearch={[Function]}
page={0}
placeholderEmpty={
<Memo(MemoizedFormattedMessage)
defaultMessage="No policies found"
id="admin.user_settings.policy_list.no_policies_found"
/>
}
previousPage={[Function]}
rows={Array []}
rowsContainerStyles={
Object {
"minHeight": "0px",
}
}
startCount={1}
term=""
total={0}
/>
</div>
`;
exports[`components/admin_console/access_control/PolicyList should match snapshot with policies 1`] = `
<div
className="PolicyTable"
>
<div
className="policy-header"
>
<div
className="policy-header-text"
>
<h1>
<MemoizedFormattedMessage
defaultMessage="Access Control Policies"
id="admin.access_control.policies.title"
/>
</h1>
<p>
<MemoizedFormattedMessage
defaultMessage="Create policies containing attribute based access rules and the resources they apply to."
id="admin.access_control.policies.description"
/>
</p>
</div>
<button
className="btn btn-primary"
onClick={[Function]}
>
<i
className="icon icon-plus"
/>
<span>
<MemoizedFormattedMessage
defaultMessage="Add policy"
id="admin.access_control.policies.add_policy"
/>
</span>
</button>
</div>
<DataGrid
columns={
Array [
Object {
"field": "name",
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Name"
id="admin.access_control.policies.name"
/>,
"width": 5,
},
Object {
"field": "resources",
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Applies to"
id="admin.access_control.policies.applies_to"
/>,
"textAlign": "center",
"width": 4,
},
Object {
"className": "actions-column",
"field": "actions",
"name": <span />,
"width": 1,
},
]
}
endCount={0}
loading={false}
nextPage={[Function]}
onSearch={[Function]}
page={0}
placeholderEmpty={
<Memo(MemoizedFormattedMessage)
defaultMessage="No policies found"
id="admin.user_settings.policy_list.no_policies_found"
/>
}
previousPage={[Function]}
rows={Array []}
rowsContainerStyles={
Object {
"minHeight": "0px",
}
}
startCount={1}
term=""
total={0}
/>
</div>
`;
exports[`components/admin_console/access_control/PolicyList should match snapshot with search error 1`] = `
<div
className="PolicyTable"
>
<div
className="policy-header"
>
<div
className="policy-header-text"
>
<h1>
<MemoizedFormattedMessage
defaultMessage="Access Control Policies"
id="admin.access_control.policies.title"
/>
</h1>
<p>
<MemoizedFormattedMessage
defaultMessage="Create policies containing attribute based access rules and the resources they apply to."
id="admin.access_control.policies.description"
/>
</p>
</div>
<button
className="btn btn-primary"
onClick={[Function]}
>
<i
className="icon icon-plus"
/>
<span>
<MemoizedFormattedMessage
defaultMessage="Add policy"
id="admin.access_control.policies.add_policy"
/>
</span>
</button>
</div>
<DataGrid
columns={
Array [
Object {
"field": "name",
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Name"
id="admin.access_control.policies.name"
/>,
"width": 5,
},
Object {
"field": "resources",
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Applies to"
id="admin.access_control.policies.applies_to"
/>,
"textAlign": "center",
"width": 4,
},
Object {
"className": "actions-column",
"field": "actions",
"name": <span />,
"width": 1,
},
]
}
endCount={0}
loading={false}
nextPage={[Function]}
onSearch={[Function]}
page={0}
placeholderEmpty={
<Memo(MemoizedFormattedMessage)
defaultMessage="No policies found"
id="admin.user_settings.policy_list.no_policies_found"
/>
}
previousPage={[Function]}
rows={Array []}
rowsContainerStyles={
Object {
"minHeight": "0px",
}
}
startCount={1}
term=""
total={0}
/>
</div>
`;

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

@@ -0,0 +1,257 @@
.cel-editor {
margin-bottom: 24px;
&__container {
position: relative;
display: flex;
overflow: auto;
height: auto;
flex-direction: column;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
margin-bottom: 8px;
background: var(--center-channel-bg);
overflow-y: auto;
.policy-editor-placeholder {
position: absolute;
z-index: 10;
top: 13px;
left: 30px;
color: rgba(0,0,0, 0.40);
font-family: monospace;
font-size: 12px;
pointer-events: none;
user-select: none;
white-space: pre-wrap;
}
}
&__cursor-position {
position: absolute;
right: 8px;
border-radius: 4px;
color: var(--button-color);
font-family: monospace;
font-size: 12px;
pointer-events: none;
}
&__input {
overflow: auto;
width: 100%;
height: auto;
min-height: 120px;
max-height: 600px;
//overflow-y: auto;
flex-grow: 0;
padding: 12px;
border: 1px solid var(--button-bg);
border-radius: 4px 4px 0 0;
font-family: monospace;
resize: vertical;
transition: border-color 0.15s ease;
&:focus {
outline: none;
}
&::placeholder {
color: rgba(var(--center-channel-color-rgb), 0.56);
}
.policyEditor {
outline: none;
}
.cel-editor__container[data-status-color='var(--error-text)'] & {
border-color: var(--error-text);
}
.cel-editor__container[data-status-color='var(--online-indicator)'] & {
border-color: var(--online-indicator);
}
.cel-editor__container[data-status-color='var(--button-bg)'] & {
border-color: var(--button-bg);
}
}
&__footer {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-top: 8px;
.help-text-container {
flex: 1;
padding-right: 16px;
color: var(--center-channel-color-72);
font-size: 12px;
}
}
&__actions {
display: flex;
align-items: center;
}
&__loading {
display: flex;
align-items: center;
i {
margin-right: 8px;
}
}
&__status-bar {
display: flex;
min-height: 24px;
align-items: center;
justify-content: space-between;
padding: 0 8px;
border-radius: 0 0 4px 4px;
color: var(--button-color);
font-family: monospace;
font-size: 12px;
transition: background-color 0.15s ease;
&:not([data-validation-state="validated"]):not([data-validation-state="error"]):not([data-validation-state="validating"]) {
cursor: pointer;
&:hover {
background: linear-gradient(0deg, rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.08)), var(--button-bg);
}
&:active {
background: linear-gradient(0deg, rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.16)), var(--button-bg);
}
}
}
&__error-message {
display: flex;
align-items: center;
.icon {
margin-right: 4px;
font-size: 14px;
}
}
&__error {
display: flex;
align-items: center;
color: var(--button-color);
}
&__status-message {
display: flex;
align-items: center;
.icon {
margin-right: 4px;
font-size: 14px;
&.icon-refresh {
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
}
}
&__inline-validate-btn {
padding: 0;
border: none;
background: transparent;
color: var(--button-color);
cursor: pointer;
font-size: 12px;
font-weight: 600;
&:hover {
text-decoration: underline;
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.cel-editor__loading {
display: flex;
align-items: center;
i {
margin-right: 4px;
font-size: 12px;
}
}
}
&__validate-btn {
display: none;
}
}
.cel-test-results-modal {
.modal-body {
max-height: 70vh;
overflow-y: auto;
}
.cel-test-attributes {
padding: 10px;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
margin-bottom: 20px;
.cel-attribute-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
margin: 0 4px 4px 0;
background: rgba(var(--center-channel-color-rgb), 0.08);
font-size: 12px;
}
}
.cel-subjects-list {
.cel-subject-item {
padding: 12px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
border-radius: 4px;
margin-bottom: 12px;
.cel-subject-header {
margin-bottom: 8px;
font-weight: 600;
}
.cel-subject-attributes {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
.cel-subject-attribute {
.cel-attribute-key {
margin-right: 4px;
font-weight: 500;
}
.cel-attribute-value {
color: rgba(var(--center-channel-color-rgb), 0.72);
}
}
}
}
}
}
.Card__body.expanded:has(.cel-editor) {
height: max-content !important;
}

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

@@ -0,0 +1,363 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as monaco from 'monaco-editor';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import type {AccessControlTestResult} from '@mattermost/types/access_control';
import {searchUsersForExpression} from 'mattermost-redux/actions/access_control';
import {Client4} from 'mattermost-redux/client';
import {MonacoLanguageProvider} from './language_provider';
import CELHelpModal from '../../modals/cel_help/cel_help_modal';
import TestResultsModal from '../../modals/policy_test/test_modal';
import {TestButton, HelpText} from '../shared';
import './editor.scss';
export const POLICY_LANGUAGE = 'expressionLanguage';
const VALIDATE_POLICY_SYNTAX_COMMAND_ID = 'policyEditorValidateSyntaxCommand';
const MONACO_EDITOR_OPTIONS: monaco.editor.IStandaloneEditorConstructionOptions = {
extraEditorClassName: 'policyEditor',
language: POLICY_LANGUAGE,
automaticLayout: true,
minimap: {enabled: false},
lineNumbers: 'off',
scrollBeyondLastLine: false,
wordWrap: 'on',
renderLineHighlight: 'none',
lineNumbersMinChars: 1,
occurrencesHighlight: 'off',
stickyScroll: {enabled: false},
autoClosingBrackets: 'never',
autoClosingQuotes: 'never',
autoIndent: 'keep',
autoSurround: 'never',
codeLens: false,
folding: false,
fontFamily: 'monospace',
hideCursorInOverviewRuler: true,
fontSize: 12,
guides: {indentation: false},
links: true,
matchBrackets: 'never',
multiCursorLimit: 1,
overviewRulerBorder: false,
quickSuggestions: false,
renderControlCharacters: false,
scrollbar: {
horizontal: 'hidden',
useShadows: false,
},
selectionHighlight: false,
showFoldingControls: 'never',
suggestOnTriggerCharacters: true,
unicodeHighlight: {
ambiguousCharacters: false,
invisibleCharacters: false,
},
unusualLineTerminators: 'auto',
wordWrapColumn: 400,
wrappingIndent: 'none',
wrappingStrategy: 'advanced',
contextmenu: false,
};
interface CELEditorProps {
value: string;
onChange: (value: string) => void;
onValidate?: (isValid: boolean) => void;
placeholder?: string;
className?: string;
userAttributes: Array<{
attribute: string;
values: string[];
}>;
}
// TODO: this is just a sample schema for the editor, we need to get the actual schema from the server
function CELEditor({
value,
onChange,
onValidate,
placeholder = 'user.attributes.<attribute> == <value>',
className = '',
userAttributes,
}: CELEditorProps): JSX.Element {
const [editorState, setEditorState] = useState({
expression: value,
isValidating: false,
isValid: true,
cursorPosition: {line: 1, column: 1},
validationErrors: [] as string[],
statusBarColor: 'var(--button-bg)',
showTestResults: false,
testResults: null as AccessControlTestResult | null,
});
const schemas = {
user: ['attributes'],
'user.attributes': userAttributes.map((attr) => attr.attribute),
};
const editorRef = useRef(null);
const monacoRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const [showHelpModal, setShowHelpModal] = useState(false);
useEffect(() => {
setEditorState((prev) => ({...prev, expression: value}));
}, [value]);
useEffect(() => {
if (monacoRef.current && monacoRef.current.getValue() !== editorState.expression) {
monacoRef.current.setValue(editorState.expression);
}
}, [editorState.expression]);
const handleChange = useCallback((newValue: string) => {
setEditorState((prev) => ({
...prev,
expression: newValue,
statusBarColor: 'var(--button-bg)',
validationErrors: [],
}));
onChange(newValue);
}, [onChange]);
const validateSyntax = useCallback(async () => {
setEditorState((prev) => ({...prev, isValidating: true}));
try {
const errors = await Client4.checkAccessControlExpression(editorState.expression);
const isValid = errors.length === 0;
setEditorState((prev) => ({
...prev,
isValid,
validationErrors: errors.map((error) => `${error.message} @L${error.line}:${error.column + 1}`),
statusBarColor: isValid ? 'var(--online-indicator)' : 'var(--error-text)',
isValidating: false,
}));
onValidate?.(isValid);
} catch (error) {
setEditorState((prev) => ({
...prev,
isValid: false,
validationErrors: [error.detailed_error || 'Unknown error'],
statusBarColor: 'var(--error-text)',
isValidating: false,
}));
onValidate?.(false);
}
}, [editorState.expression, onValidate]);
// initialize monaco editor
useEffect(() => {
if (!editorRef.current || monacoRef.current) {
return () => {};
}
monacoRef.current = monaco.editor.create(editorRef.current, MONACO_EDITOR_OPTIONS);
// Set the initial value from the expression state
monacoRef.current.setValue(editorState.expression);
monacoRef.current.getModel()?.onDidChangeContent(() => {
const newValue = monacoRef.current?.getValue() || '';
handleChange(newValue);
});
monacoRef.current.onDidChangeCursorPosition((e) => {
setEditorState((prev) => ({
...prev,
cursorPosition: {line: e.position.lineNumber, column: e.position.column},
}));
});
// To disable monaco's default behavior of opening the find and replace widget
monaco.editor.addKeybindingRule({
keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF,
command: null,
});
monaco.editor.addCommand({
id: VALIDATE_POLICY_SYNTAX_COMMAND_ID,
run: validateSyntax,
});
monaco.editor.addKeybindingRule({
keybinding: monaco.KeyMod.Alt | monaco.KeyCode.Enter,
command: VALIDATE_POLICY_SYNTAX_COMMAND_ID,
});
return () => {
if (monacoRef.current) {
monacoRef.current.dispose();
monacoRef.current = null;
}
};
}, []);
return (
<div className={`cel-editor ${className}`}>
<MonacoLanguageProvider schemas={schemas}/>
<div
className='cel-editor__container'
data-status-color={editorState.statusBarColor}
>
{!editorState.expression && (
<div
className='policy-editor-placeholder'
aria-label='CEL Expression Editor'
>
{placeholder}
</div>
)}
<div
ref={editorRef}
className='cel-editor__input'
/>
<div
className='cel-editor__status-bar'
style={{backgroundColor: editorState.statusBarColor}}
onClick={() => {
if (!editorState.isValidating && editorState.validationErrors.length === 0 &&
!(editorState.isValid && editorState.statusBarColor === 'var(--online-indicator)')) {
validateSyntax();
}
}}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (!editorState.isValidating && editorState.validationErrors.length === 0 &&
!(editorState.isValid && editorState.statusBarColor === 'var(--online-indicator)')) {
validateSyntax();
}
}
}}
data-validation-state={
(() => {
if (editorState.isValidating) {
return 'validating';
}
if (editorState.validationErrors.length > 0) {
return 'error';
}
if (editorState.isValid && editorState.statusBarColor === 'var(--online-indicator)') {
return 'validated';
}
return 'unvalidated';
})()
}
>
<div className='cel-editor__status-message'>
{(() => {
if (editorState.validationErrors.length > 0) {
return (
<span className='cel-editor__error'>
<i
className='icon icon-refresh'
onClick={validateSyntax}
role='button'
aria-label='Retry validation'
/>
{editorState.validationErrors[0]}
</span>
);
}
if (editorState.isValid && editorState.statusBarColor === 'var(--online-indicator)') {
return (
<span className='cel-editor__valid'>
<i className='icon icon-check'/>
{'Valid'}
</span>
);
}
return (
<button
className='cel-editor__inline-validate-btn'
onClick={validateSyntax}
disabled={editorState.isValidating}
>
<span className='cel-editor__loading'>
{editorState.isValidating ? (
<>
<i className='fa fa-spinner fa-spin'/>
<FormattedMessage
id='admin.access_control.cel.validating'
defaultMessage='Validating...'
/>
</>
) : (
<>
<i className='icon icon-magnify'/>
<FormattedMessage
id='admin.access_control.cel.validateSyntax'
defaultMessage='Validate syntax'
/>
</>
)}
</span>
</button>
);
})()}
</div>
<div className='cel-editor__cursor-position'>
<FormattedMessage
id='admin.access_control.cel.line_and_column_number'
defaultMessage='L{lineNumber}:{columnNumber}'
values={{
lineNumber: editorState.cursorPosition.line,
columnNumber: editorState.cursorPosition.column,
}}
/>
</div>
</div>
</div>
<div className='cel-editor__footer'>
<div className='help-text-container'>
<div>
<HelpText
message={'Write rules like `user.<attribute> == <value>`. Use `&&` / `||` (and/or) for multiple conditions. Group conditions with `()`.'}
onLearnMoreClick={() => setShowHelpModal(true)}
/>
</div>
</div>
<TestButton
onClick={() => setEditorState((prev) => ({...prev, showTestResults: true}))}
disabled={!editorState.isValid || editorState.isValidating}
/>
</div>
{editorState.showTestResults && (
<TestResultsModal
onExited={() => setEditorState((prev) => ({...prev, showTestResults: false}))}
actions={{
openModal: () => {},
searchUsers: (term: string, after: string, limit: number) => {
return searchUsersForExpression(editorState.expression, term, after, limit);
},
}}
/>
)}
{showHelpModal && (
<CELHelpModal
onExited={() => setShowHelpModal(false)}
/>
)}
</div>
);
}
export default CELEditor;

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

@@ -0,0 +1,275 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as monaco from 'monaco-editor';
import {useEffect} from 'react';
const POLICY_LANGUAGE_NAME = 'expressionLanguage';
// Enhanced schema interface to support different types of values
interface SchemaValue {
[key: string]: string[] | boolean | SchemaValue;
}
interface SchemaMap {
[schemaName: string]: string[] | SchemaValue | boolean;
}
interface MonacoLanguageProviderProps {
schemas: SchemaMap;
}
export function MonacoLanguageProvider({schemas}: MonacoLanguageProviderProps) {
useEffect(() => {
// Register our custom expression language
if (
!monaco.languages.
getLanguages().
some((lang) => lang.id === POLICY_LANGUAGE_NAME)
) {
monaco.languages.register({id: POLICY_LANGUAGE_NAME});
// Define language tokenizer
monaco.languages.setMonarchTokensProvider(POLICY_LANGUAGE_NAME, {
tokenizer: {
root: [
// Comments
[/\/\/.*$/, 'comment'],
// Object and property paths
[/[a-zA-Z][\w$]*(?=\.)/, 'variable'],
[/\./, 'delimiter'],
[/[a-zA-Z][\w$]*/, 'property'],
// Operators
[/&&|\|\||==|!=/, 'operator'],
// Whitespace
[/[ \t\r\n]+/, 'white'],
// Parentheses
[/[()]/, '@brackets'],
// String literals
[/"([^"\\]|\\.)*$/, 'string.invalid'],
[/"/, {token: 'string.quote', bracket: '@open', next: '@string'}],
[/'([^'\\]|\\.)*$/, 'string.invalid'],
[
/'/,
{token: 'string.quote', bracket: '@open', next: '@string2'},
],
// Numbers
[/\d+/, 'number'],
],
string: [
[/[^\\"]+/, 'string'],
[/"/, {token: 'string.quote', bracket: '@close', next: '@pop'}],
],
string2: [
[/[^'\\]+/, 'string'],
[/'/, {token: 'string.quote', bracket: '@close', next: '@pop'}],
],
},
});
}
// Get properties from a schema path
const getPropertiesFromPath = (path: string): string[] => {
const schemaItem = schemas[path];
if (!schemaItem) {
return [];
}
if (Array.isArray(schemaItem)) {
return schemaItem;
} else if (typeof schemaItem === 'object') {
return Object.keys(schemaItem);
}
return [];
};
// Get allowed values for a property or path
const getValuesForPath = (fullPath: string): string[] | null => {
// Check if the path exists directly in schemas
const directValue = schemas[fullPath];
if (Array.isArray(directValue)) {
return directValue;
}
// Otherwise, try to parse it as parent.property
const pathParts = fullPath.split('.');
if (pathParts.length >= 2) {
const property = pathParts.pop();
if (!property) {
return null;
}
const parentPath = pathParts.join('.');
const schemaItem = schemas[parentPath];
if (!schemaItem || Array.isArray(schemaItem) || typeof schemaItem === 'boolean') {
return null;
}
const propValue = schemaItem[property];
if (Array.isArray(propValue)) {
return propValue;
} else if (propValue === true) {
return null; // Property exists but no predefined values
}
}
return null;
};
// Create a completion item provider for our language
const disposable = monaco.languages.registerCompletionItemProvider(
'expressionLanguage',
{
triggerCharacters: ['.', ' ', '"', "'", '='],
provideCompletionItems: (model, position) => {
const lineNumber = position.lineNumber;
const column = position.column;
const lineContent = model.getLineContent(lineNumber);
const textBeforePosition = lineContent.substring(0, column - 1);
// Check if we're after an operator that expects a value
// Pattern: path followed by an operator that expects a value
const valueOperatorPattern =
/(\w+(?:\.\w+)*)\s+(==|!=|>|<|>=|<=)\s+["']?(\w*)$/;
const valueMatch = textBeforePosition.match(valueOperatorPattern);
if (valueMatch) {
const [, fullPath, , currentValue] = valueMatch;
// Get values for this full path
const allowedValues = getValuesForPath(fullPath);
if (allowedValues && allowedValues.length > 0) {
// Create range that includes the characters already typed
const wordStartColumn = column - currentValue.length;
return {
suggestions: allowedValues.
filter((val) =>
val.
toString().
toLowerCase().
startsWith(currentValue.toLowerCase()),
).
map((val) => ({
label: val.toString(),
kind: monaco.languages.CompletionItemKind.Value,
insertText: `"${val}"`,
range: {
startLineNumber: lineNumber,
startColumn: wordStartColumn,
endLineNumber: lineNumber,
endColumn: column,
},
})),
};
}
}
// Check if we should suggest operators
// Pattern: an entity (word possibly with dots) followed by space
const operatorPattern = /(\w+(?:\.\w+)*)\s+$/;
const operatorMatch = textBeforePosition.match(operatorPattern);
if (operatorMatch) {
// We have an entity followed by space - suggest operators
const operators = ['&&', '||', '==', '!=', 'in'];
return {
suggestions: operators.map((op) => ({
label: op,
kind: monaco.languages.CompletionItemKind.Operator,
insertText: op + ' ',
range: {
startLineNumber: lineNumber,
startColumn: column,
endLineNumber: lineNumber,
endColumn: column,
},
})),
};
}
// Check for dot completion (property access)
const dotMatch = textBeforePosition.match(/(\w+)(?:\.(\w+))*\.$/);
if (dotMatch) {
const fullPath = dotMatch[0].slice(0, -1); // Remove trailing dot
// Get properties for this path
const properties = getPropertiesFromPath(fullPath);
if (properties.length > 0) {
return {
suggestions: properties.map((field) => ({
label: field,
kind: monaco.languages.CompletionItemKind.Field,
insertText: field,
range: {
startLineNumber: lineNumber,
startColumn: column,
endLineNumber: lineNumber,
endColumn: column,
},
})),
};
}
}
// When not after a dot or space, suggest root objects
const wordMatch = textBeforePosition.match(
/(?:^|\s+|[&|=!<>()]|\()(\w*)$/,
);
if (wordMatch) {
const word = wordMatch[1] || '';
const wordStartColumn = column - word.length;
// Filter schemas that are root objects (don't contain dots)
const rootSchemas = Object.keys(schemas).filter(
(key) => !key.includes('.'),
);
const suggestions = rootSchemas.
filter((schema) =>
schema.toLowerCase().startsWith(word.toLowerCase()),
).
map((schema) => ({
label: schema,
kind: monaco.languages.CompletionItemKind.Class,
insertText: schema,
range: {
startLineNumber: lineNumber,
startColumn: wordStartColumn,
endLineNumber: lineNumber,
endColumn: column,
},
}));
return {suggestions};
}
return {suggestions: []};
},
},
);
// Cleanup function
return () => {
disposable.dispose();
};
}, [schemas]);
return null; // This component doesn't render anything
}

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

@@ -0,0 +1,55 @@
.editor__test-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 16px;
border: 1px solid var(--button-bg);
border-radius: 4px;
background: transparent;
color: var(--button-bg);
cursor: pointer;
font-weight: 600;
i {
margin-right: 8px;
}
&:hover {
background-color: rgba(var(--button-bg-rgb), 0.08);
}
&:disabled {
border: 1px solid rgba(var(--center-channel-color-rgb), 0.32);
color: rgba(var(--center-channel-color-rgb), 0.56);
cursor: not-allowed;
opacity: 0.4;
}
}
.editor__add-row-button {
display: flex;
align-items: center;
padding: 10px 16px;
border: 1px solid var(--button-bg);
border-radius: 4px;
background-color: transparent;
color: var(--button-bg);
cursor: pointer;
font-weight: 600;
i {
margin-right: 8px;
font-size: 14px;
}
&:hover {
background-color: rgba(var(--button-bg-rgb), 0.08);
}
&:disabled {
border: 1px solid rgba(var(--center-channel-color-rgb), 0.32);
color: rgba(var(--center-channel-color-rgb), 0.56);
cursor: not-allowed;
opacity: 0.4;
}
}

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

@@ -0,0 +1,78 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import './shared.scss';
import Markdown from 'components/markdown';
interface TestButtonProps {
onClick: () => void;
disabled: boolean;
}
interface AddAttributeButtonProps {
onClick: () => void;
disabled: boolean;
}
interface HelpTextProps {
message: string;
onLearnMoreClick?: () => void;
}
export function TestButton({onClick, disabled}: TestButtonProps): JSX.Element {
return (
<button
className='btn btn-sm btn-tertiary'
onClick={onClick}
disabled={disabled}
>
<i className='icon icon-lock-outline'/>
<FormattedMessage
id='admin.access_control.table_editor.test_access_rule'
defaultMessage='Test access rule'
/>
</button>
);
}
export function AddAttributeButton({onClick, disabled}: AddAttributeButtonProps): JSX.Element {
return (
<button
className='btn btn-sm btn-tertiary'
onClick={onClick}
disabled={disabled}
>
<i className='icon icon-plus'/>
<FormattedMessage
id='admin.access_control.table_editor.add_attribute'
defaultMessage='Add attribute'
/>
</button>
);
}
export function HelpText({message, onLearnMoreClick}: HelpTextProps): JSX.Element {
return (
<div className='editor__help-text'>
<Markdown
message={message}
options={{mentionHighlight: false}}
/>
{onLearnMoreClick && (
<a
href='#'
className='editor__learn-more'
onClick={onLearnMoreClick}
>
<FormattedMessage
id='admin.access_control.table_editor.learnMore'
defaultMessage='Learn more about creating access expressions with examples.'
/>
</a>
)}
</div>
);
}

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

@@ -0,0 +1,104 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {CheckIcon, MenuVariantIcon} from '@mattermost/compass-icons/components';
import type IconProps from '@mattermost/compass-icons/components/props';
import * as Menu from 'components/menu';
import './selector_menus.scss';
interface AttributeOption {
attribute: string;
values: string[];
}
interface AttributeSelectorProps {
currentAttribute: string;
availableAttributes: AttributeOption[];
disabled: boolean;
onChange: (attribute: string) => void;
}
const AttributeSelectorMenu = ({currentAttribute, availableAttributes, disabled, onChange}: AttributeSelectorProps) => {
const {formatMessage} = useIntl();
const [filter, setFilter] = useState('');
const onFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFilter(e.target.value);
};
const options = useMemo(() => {
return availableAttributes.filter((attr) => {
return attr.attribute.toLowerCase().includes(filter.toLowerCase());
});
}, [availableAttributes, filter]);
const handleAttributeChange = (attribute: string) => {
onChange(attribute);
setFilter('');
};
// TODO: We can use different icons for different attributes types
const AttributeIcon = (props: IconProps) => <MenuVariantIcon {...props}/>;
return (
<Menu.Container
menuButton={{
id: 'attribute-selector-button',
class: classNames('btn btn-transparent field-selector-menu-button', {
disabled,
}),
children: (
<>
<AttributeIcon/>
{currentAttribute || formatMessage({id: 'admin.access_control.table_editor.selector.select_attribute', defaultMessage: 'Select attribute'})}
</>
),
dataTestId: 'attributeSelectorMenuButton',
disabled,
}}
menu={{
id: 'attribute-selector-menu',
'aria-label': 'Select attribute',
className: 'select-attribute-mui-menu',
}}
>
{[
<Menu.InputItem
key='filter_attributes'
id='filter_attributes'
type='text'
placeholder={formatMessage({id: 'admin.access_control.table_editor.selector.filter_attributes', defaultMessage: 'Search attributes...'})}
className='attribute-selector-search'
value={filter}
onChange={onFilterChange}
/>,
]}
{options.map((option) => {
const {attribute} = option;
return (
<Menu.Item
id={`attribute-${attribute}`}
key={attribute}
role='menuitemradio'
forceCloseOnSelect={true}
aria-checked={attribute === currentAttribute}
onClick={() => handleAttributeChange(attribute)}
labels={<span>{attribute}</span>}
leadingElement={<AttributeIcon size={18}/>}
trailingElements={attribute === currentAttribute && (
<CheckIcon/>
)}
/>
);
})}
</Menu.Container>
);
};
export default AttributeSelectorMenu;

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

@@ -0,0 +1,239 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import type {ComponentType} from 'react';
import React, {useMemo, useState} from 'react';
import type {MessageDescriptor} from 'react-intl';
import {defineMessage, FormattedMessage, useIntl} from 'react-intl';
import {CheckIcon} from '@mattermost/compass-icons/components';
import type IconProps from '@mattermost/compass-icons/components/props';
import type {IDMappedObjects} from '@mattermost/types/utilities';
import * as Menu from 'components/menu';
import './selector_menus.scss';
const AlphaEIcon: React.FC<IconProps> = ({size, color, ...rest}: IconProps): JSX.Element => (
<svg
xmlns='http://www.w3.org/2000/svg'
version='1.1'
width={size || '1em'}
height={size || '1em'}
fill={color || 'currentColor'}
viewBox='0 0 24 24'
transform='rotate(90)'
{...rest}
>
<path d='M9,17A2,2 0 0,1 7,15V7H9V15H11V8H13V15H15V7H17V15A2,2 0 0,1 15,17H9Z'/>
</svg>
);
const EqualIcon: React.FC<IconProps> = ({size, color, ...rest}: IconProps): JSX.Element => (
<svg
xmlns='http://www.w3.org/2000/svg'
version='1.1'
width={size || '1em'}
height={size || '1em'}
fill={color || 'currentColor'}
viewBox='0 0 24 24'
{...rest}
>
<path d='M19,10H5V8H19V10M19,16H5V14H19V16Z'/>
</svg>
);
const FunctionIcon: React.FC<IconProps> = ({size, color, ...rest}: IconProps): JSX.Element => (
<svg
xmlns='http://www.w3.org/2000/svg'
version='1.1'
width={size || '1em'}
height={size || '1em'}
fill={color || 'currentColor'}
viewBox='0 0 24 24'
{...rest}
>
<path d='M15.6,5.29C14.5,5.19 13.53,6 13.43,7.11L13.18,10H16V12H13L12.56,17.07C12.37,19.27 10.43,20.9 8.23,20.7C6.92,20.59 5.82,19.86 5.17,18.83L6.67,17.33C6.91,18.07 7.57,18.64 8.4,18.71C9.5,18.81 10.47,18 10.57,16.89L11,12H8V10H11.17L11.44,6.93C11.63,4.73 13.57,3.1 15.77,3.3C17.08,3.41 18.18,4.14 18.83,5.17L17.33,6.67C17.09,5.93 16.43,5.36 15.6,5.29Z'/>
</svg>
);
const NotEqualIcon: React.FC<IconProps> = ({size, color, ...rest}: IconProps): JSX.Element => (
<svg
xmlns='http://www.w3.org/2000/svg'
version='1.1'
width={size || '1em'}
height={size || '1em'}
fill={color || 'currentColor'}
viewBox='0 0 24 24'
{...rest}
>
<path d='M21,10H9V8H21V10M21,16H9V14H21V16M4,5H6V16H4V5M6,18V20H4V18H6Z'/>
</svg>
);
interface OperatorSelectorProps {
currentOperator: string;
disabled: boolean;
onChange: (operator: string) => void;
}
const OperatorSelectorMenu = ({currentOperator, disabled, onChange}: OperatorSelectorProps) => {
const handleOperatorChange = (descriptor: OperatorDescriptor) => {
onChange(descriptor.operatorValue);
setFilter('');
};
const currentOperatorDescriptor = useMemo(() => {
return getOperatorDescriptor(currentOperator);
}, [currentOperator]);
const CurrentOperatorIcon = currentOperatorDescriptor.icon;
const {formatMessage} = useIntl();
const [filter, setFilter] = useState('');
const onFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFilter(e.target.value);
};
const filteredOperators = useMemo(() => {
return Object.values(OPERATOR_DESCRIPTORS).filter((desc) => {
const label = formatMessage(desc.label);
return label.toLowerCase().includes(filter.toLowerCase());
});
}, [filter, formatMessage]);
return (
<Menu.Container
menuButton={{
id: 'operator-selector-button',
class: classNames('btn btn-transparent field-selector-menu-button', {
disabled,
}),
children: (
<>
<CurrentOperatorIcon
size={18}
color='rgba(var(--center-channel-color-rgb), 0.64)'
/>
<FormattedMessage {...currentOperatorDescriptor.label}/>
</>
),
dataTestId: 'operatorSelectorMenuButton',
disabled,
}}
menu={{
id: 'operator-selector-menu',
'aria-label': 'Select operator',
className: 'select-operator-mui-menu',
}}
>
<Menu.InputItem
key='filter_operators'
id='filter_operators'
type='text'
placeholder={formatMessage({id: 'admin.access_control.table_editor.selector.filter_operators', defaultMessage: 'Search operators...'})}
className='attribute-selector-search'
value={filter}
onChange={onFilterChange}
/>
{filteredOperators.map((descriptor) => {
const {id, icon: Icon, label} = descriptor;
return (
<Menu.Item
id={id}
key={id}
role='menuitemradio'
forceCloseOnSelect={true}
aria-checked={id === currentOperatorDescriptor.id}
onClick={() => handleOperatorChange(descriptor)}
labels={<FormattedMessage {...label}/>}
leadingElement={<Icon size={18}/>}
trailingElements={id === currentOperatorDescriptor.id && (
<CheckIcon/>
)}
/>
);
})}
</Menu.Container>
);
};
export default OperatorSelectorMenu;
const getOperatorDescriptor = (operatorValue: string): OperatorDescriptor => {
for (const descriptor of Object.values(OPERATOR_DESCRIPTORS)) {
if (descriptor.operatorValue === operatorValue) {
return descriptor;
}
}
return OPERATOR_DESCRIPTORS.is;
};
type OperatorID = 'is' | 'is_not' | 'in' | 'starts_with' | 'ends_with' | 'contains';
type OperatorDescriptor = {
id: OperatorID;
operatorValue: string;
icon: ComponentType<IconProps>;
label: MessageDescriptor;
};
const OPERATOR_DESCRIPTORS: IDMappedObjects<OperatorDescriptor> = {
is: {
id: 'is',
operatorValue: 'is',
icon: EqualIcon,
label: defineMessage({
id: 'admin.access_control.table_editor.operator.is',
defaultMessage: 'is',
}),
},
is_not: {
id: 'is_not',
operatorValue: 'is not',
icon: NotEqualIcon,
label: defineMessage({
id: 'admin.access_control.table_editor.operator.is_not',
defaultMessage: 'is not',
}),
},
in: {
id: 'in',
operatorValue: 'in',
icon: AlphaEIcon,
label: defineMessage({
id: 'admin.access_control.table_editor.operator.in',
defaultMessage: 'in',
}),
},
starts_with: {
id: 'starts_with',
operatorValue: 'starts with',
icon: FunctionIcon,
label: defineMessage({
id: 'admin.access_control.table_editor.operator.starts_with',
defaultMessage: 'starts with',
}),
},
ends_with: {
id: 'ends_with',
operatorValue: 'ends with',
icon: FunctionIcon,
label: defineMessage({
id: 'admin.access_control.table_editor.operator.ends_with',
defaultMessage: 'ends with',
}),
},
contains: {
id: 'contains',
operatorValue: 'contains',
icon: FunctionIcon,
label: defineMessage({
id: 'admin.access_control.table_editor.operator.contains',
defaultMessage: 'contains',
}),
},
};

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

@@ -0,0 +1,46 @@
.field-selector-menu-button {
width: 100%;
height: 40px;
justify-content: start;
border-color: transparent;
border-radius: 0;
box-shadow: none;
font-weight: normal;
&:hover,
&:focus {
border-color: transparent;
box-shadow: none;
}
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.04)
}
&:focus,
&[aria-expanded="true"] {
background: rgba(var(--button-bg-rgb), 0.08);
}
&.disabled {
cursor: not-allowed;
opacity: 0.6;
}
svg {
margin-right: 8px;
}
}
.select-attribute-mui-menu,
.select-operator-mui-menu {
margin-top: 0;
.MenuItem {
height: 40px;
svg {
margin-right: 8px;
}
}
}

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

@@ -0,0 +1,174 @@
.table-editor {
position: relative;
margin-bottom: 24px;
&__table {
overflow: hidden;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
}
&__header {
display: flex;
padding: 12px 16px;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
background: rgba(var(--center-channel-color-rgb), 0.04);
}
&__column-header {
flex: 1;
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600;
padding-inline: 10px;
&:nth-child(1) {
flex: 1;
}
&:nth-child(2) {
flex: 0.8;
}
&:nth-child(3) {
flex: 2.3;
}
}
&__column-header-actions {
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600;
}
&__row {
display: flex;
align-items: center;
padding: 0;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
}
&__cell {
flex: 1;
&:nth-child(1) {
flex: 1;
}
&:nth-child(2) {
flex: 0.8;
}
&:nth-child(3) {
flex: 2;
}
}
&__cell-actions {
width: 40px;
text-align: right;
}
&__attribute-select,
&__operator-select {
width: 100%;
}
&__select {
width: 100%;
padding: 10px 16px;
border: none;
border-radius: 4px;
appearance: none;
background-color: transparent;
color: var(--center-channel-color);
font-size: 14px;
&:hover {
background-color: rgba(var(--button-bg-rgb), 0.08);
cursor: pointer;
}
&:focus {
background-color: rgba(var(--button-bg-rgb), 0.08);
outline: none;
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
&__row-remove {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
background: none;
color: rgba(var(--center-channel-color-rgb), 0.56);
cursor: pointer;
&:hover {
color: var(--error-text);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
&__actions-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
.editor__help-text {
margin-right: 32px;
p {
margin-bottom: 0;
}
}
}
&__blank-state {
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
span {
color: var(--center-channel-color-64);
}
}
&__add-button-container {
display: flex;
align-items: center;
padding: 8px;
}
}
.editor__help-text {
color: var(--center-channel-color-72);
font-size: 12px;
p {
margin-bottom: 0;
}
a {
display: inline-block;
margin-top: 8px;
color: var(--link-color);
&:hover {
text-decoration: underline;
}
}
}

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

@@ -0,0 +1,317 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useEffect} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {searchUsersForExpression} from 'mattermost-redux/actions/access_control';
import {Client4} from 'mattermost-redux/client';
import AttributeSelectorMenu from './attribute_selector_menu';
import OperatorSelectorMenu from './operator_selector_menu';
import type {TableRow} from './table_row';
import ValuesEditor from './values_editor';
import CELHelpModal from '../../modals/cel_help/cel_help_modal';
import TestResultsModal from '../../modals/policy_test/test_modal';
import {AddAttributeButton, TestButton, HelpText} from '../shared';
import './table_editor.scss';
interface TableEditorProps {
value: string;
onChange: (value: string) => void;
onValidate?: (isValid: boolean) => void;
disabled?: boolean;
userAttributes: Array<{
attribute: string;
values: string[];
}>;
}
// Parse CEL expression into table rows
const parseExpression = async (expr: string): Promise<TableRow[]> => {
const tableRows: TableRow[] = [];
if (!expr) {
return tableRows;
}
const rawVisualAST = await Client4.expressionToVisualFormat(expr);
for (const node of rawVisualAST.conditions) {
let attr;
if (node.attribute.startsWith('user.attributes.')) {
attr = node.attribute.slice(16); // wow, there is no trim-prefix
} else {
throw new Error(`Unknown attribute: ${node.attribute}`);
}
let op;
switch (node.operator) {
case '==':
op = 'is';
break;
case 'in':
op = 'in';
break;
case '!=':
op = 'is not';
break;
case 'startsWith':
op = 'starts with';
break;
case 'endsWith':
op = 'ends with';
break;
case 'contains':
op = 'contains';
break;
default:
throw new Error(`Unknown operator: ${node.operator}`);
}
let values;
if (Array.isArray(node.value)) {
values = node.value;
} else {
values = [node.value];
}
tableRows.push({
attribute: attr,
operator: op,
values,
});
}
return tableRows;
};
function TableEditor({
value,
onChange,
onValidate,
disabled = false,
userAttributes,
}: TableEditorProps): JSX.Element {
const {formatMessage} = useIntl();
const [rows, setRows] = useState<TableRow[]>([]);
const [showTestResults, setShowTestResults] = useState(false);
const [showHelpModal, setShowHelpModal] = useState(false);
// Update rows when value changes externally
useEffect(() => {
parseExpression(value).then((rows) => {
setRows(rows);
});
}, [value]);
// Update the CEL expression when table changes
const updateExpression = (newRows: TableRow[]) => {
const validRows = newRows.filter((row) => row.attribute && row.values.length > 0);
const expr = validRows.map((row) => {
if (row.operator === 'is') {
return `user.attributes.${row.attribute} == "${row.values[0]}"`;
}
if (row.operator === 'is not') {
return `user.attributes.${row.attribute} != "${row.values[0]}"`;
}
if (row.operator === 'starts with') {
return `user.attributes.${row.attribute}.startsWith("${row.values[0]}")`;
}
if (row.operator === 'ends with') {
return `user.attributes.${row.attribute}.endsWith("${row.values[0]}")`;
}
if (row.operator === 'contains') {
return `user.attributes.${row.attribute}.contains("${row.values[0]}")`;
}
const valuesStr = row.values.map((val) => `"${val}"`).join(', ');
return `user.attributes.${row.attribute} in [${valuesStr}]`;
}).join(' && ');
onChange(expr);
if (onValidate) {
onValidate(true);
}
};
const addRow = () => {
// Find first available attribute
const availableAttrs = getAvailableAttributes();
if (availableAttrs.length === 0) {
return;
}
const newRows = [...rows, {
attribute: availableAttrs[0].attribute,
operator: 'is',
values: [],
}];
setRows(newRows);
updateExpression(newRows);
};
const removeRow = (index: number) => {
const newRows = rows.filter((_, i) => i !== index);
setRows(newRows);
updateExpression(newRows);
};
const updateRowAttribute = (index: number, attribute: string) => {
const newRows = [...rows];
newRows[index].attribute = attribute;
setRows(newRows);
updateExpression(newRows);
};
const updateRowOperator = (index: number, operator: string) => {
const newRows = [...rows];
newRows[index].operator = operator;
if ((operator !== 'in') && newRows[index].values.length > 1) {
newRows[index].values = newRows[index].values.length > 0 ? [newRows[index].values[0]] : [];
}
setRows(newRows);
updateExpression(newRows);
};
const updateRowValues = (index: number, values: string[]) => {
const newRows = [...rows];
newRows[index].values = values;
setRows(newRows);
updateExpression(newRows);
};
// Get available attributes (excluding ones already used)
const getAvailableAttributes = () => {
const usedAttributes = new Set(rows.map((row) => row.attribute));
return userAttributes.filter((attr) => !usedAttributes.has(attr.attribute));
};
return (
<div className='table-editor'>
<div className='table-editor__table'>
<div className='table-editor__header'>
<div className='table-editor__column-header'>
<FormattedMessage
id='admin.access_control.table_editor.attribute'
defaultMessage='Attribute'
/>
</div>
<div className='table-editor__column-header'>
<FormattedMessage
id='admin.access_control.table_editor.operator'
defaultMessage='Operator'
/>
</div>
<div className='table-editor__column-header'>
<FormattedMessage
id='admin.access_control.table_editor.values'
defaultMessage='Values'
/>
</div>
<div className='table-editor__column-header-actions'/>
</div>
<div className='table-editor__rows'>
{rows.length === 0 ? (
<div className='table-editor__blank-state'>
<span>
{formatMessage({
id: 'admin.access_control.table_editor.blank_state',
defaultMessage: 'Select a user attribute and values to create a rule',
})}
</span>
</div>
) : (
rows.map((row, index) => (
<div
key={index}
className='table-editor__row'
>
<div className='table-editor__cell'>
<AttributeSelectorMenu
currentAttribute={row.attribute}
availableAttributes={getAvailableAttributes().concat(
row.attribute ? [{attribute: row.attribute, values: []}] : [],
)}
disabled={disabled}
onChange={(attribute) => updateRowAttribute(index, attribute)}
/>
</div>
<div className='table-editor__cell'>
<OperatorSelectorMenu
currentOperator={row.operator}
disabled={disabled}
onChange={(operator) => updateRowOperator(index, operator)}
/>
</div>
<div className='table-editor__cell'>
<ValuesEditor
row={row}
disabled={disabled}
updateValues={(values) => updateRowValues(index, values)}
/>
</div>
<div className='table-editor__cell-actions'>
<button
className='table-editor__row-remove'
onClick={() => removeRow(index)}
disabled={disabled}
aria-label={formatMessage({id: 'admin.access_control.table_editor.remove_row', defaultMessage: 'Remove row'})}
>
<i className='icon icon-trash-can-outline'/>
</button>
</div>
</div>
))
)}
</div>
<div className='table-editor__add-button-container'>
<AddAttributeButton
onClick={addRow}
disabled={disabled || getAvailableAttributes().length === 0}
/>
</div>
</div>
<div className='table-editor__actions-row'>
<HelpText
message={'Each row is a single condition that must be met for a user to comply with the policy. All rules are combined with logical AND operator (`&&`).'}
/>
<TestButton
onClick={() => setShowTestResults(true)}
disabled={disabled || !value}
/>
</div>
{showTestResults && (
<TestResultsModal
onExited={() => setShowTestResults(false)}
actions={{
openModal: () => {},
searchUsers: (term: string, after: string, limit: number) => {
return searchUsersForExpression(value, term, after, limit);
},
}}
/>
)}
{showHelpModal && (
<CELHelpModal
onExited={() => setShowHelpModal(false)}
/>
)}
</div>
);
}
export default TableEditor;

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

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export interface TableRow {
attribute: string;
operator: string;
values: string[];
}

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

@@ -0,0 +1,78 @@
.values-editor {
position: relative;
width: 100%;
.select__multi-value {
display: flex;
height: 24px;
align-items: center;
padding: 0;
border: none;
border-radius: 4px;
margin: 2px;
background-color: rgba(var(--center-channel-color-rgb), 0.08);
}
.select__multi-value__label {
padding: 0 8px;
color: var(--center-channel-color);
font-size: 12px;
line-height: 16px;
}
.select__multi-value__remove {
display: flex;
height: 100%;
align-items: center;
padding: 0 4px;
border-radius: 0 4px 4px 0;
color: rgba(var(--center-channel-color-rgb), 0.56);
cursor: pointer;
&:hover {
background-color: rgba(var(--center-channel-color-rgb), 0.16);
color: var(--center-channel-color);
}
}
.select__control {
min-height: 40px;
border: none;
border-radius: 0;
overflow-y: auto;
&--is-focused {
border: none;
box-shadow: none;
}
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.06);
cursor: text;
}
}
&__simple-input {
width: 100%;
height: 40px;
padding: 0 16px;
border: none;
background: transparent;
color: var(--center-channel-color);
font-size: 14px;
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.06);
}
&:focus {
background: rgba(var(--center-channel-color-rgb), 0.06);
outline: none;
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
}

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

@@ -0,0 +1,122 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useMemo} from 'react';
import {useIntl} from 'react-intl';
import CreatableSelect from 'react-select/creatable';
import Constants from 'utils/constants';
import './values_editor.scss';
import type {TableRow} from './table_row';
export type ValuesEditorProps = {
row: TableRow;
disabled: boolean;
updateValues: (values: string[]) => void;
}
function ValuesEditor({row, disabled, updateValues}: ValuesEditorProps) {
const {formatMessage} = useIntl();
const isMulti = row.operator === 'in';
const [inputValue, setInputValue] = useState('');
const [isEditing, setIsEditing] = useState(false);
// Format options for react-select
const value = useMemo(() => {
return row.values.map((val) => ({
label: val,
value: val,
}));
}, [row.values]);
// Handle input submission for single value
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
// Only update if there's actual text - don't set empty values
if (inputValue.trim()) {
updateValues([inputValue.trim()]);
}
setInputValue('');
setIsEditing(false);
}
};
// For single value mode, use a simple input field
if (!isMulti) {
const displayValue = row.values.length > 0 ? row.values[0] : '';
return (
<div className='values-editor'>
<input
type='text'
className='values-editor__simple-input'
value={isEditing ? inputValue : displayValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => {
setIsEditing(true);
if (displayValue) {
setInputValue(displayValue);
}
}}
onBlur={() => {
// Only update if there's actual text - don't set empty values
if (inputValue.trim()) {
updateValues([inputValue.trim()]);
}
setInputValue('');
setIsEditing(false);
}}
placeholder={formatMessage({id: 'admin.access_control.table_editor.value.placeholder', defaultMessage: 'Add value...'})}
disabled={disabled}
maxLength={Constants.MAX_CUSTOM_ATTRIBUTE_LENGTH}
/>
</div>
);
}
// For multi-value mode, continue using CreatableSelect
const customComponents = {
DropdownIndicator: () => null,
IndicatorsContainer: () => null,
};
const handleChange = (newValue: any) => {
if (!newValue) {
updateValues([]);
} else if (Array.isArray(newValue)) {
updateValues(newValue.map((option) => option.value));
}
};
return (
<div className='values-editor'>
<CreatableSelect
isMulti={true}
isClearable={true}
isDisabled={disabled}
components={customComponents}
value={value}
onChange={handleChange}
onCreateOption={(inputValue) => {
const val = inputValue.trim();
if (!val) {
return;
}
if (!row.values.includes(val)) {
updateValues([...row.values, val]);
}
}}
placeholder={formatMessage({id: 'admin.access_control.table_editor.values.placeholder', defaultMessage: 'Add values...'})}
classNamePrefix='select'
menuPortalTarget={document.body}
/>
</div>
);
}
export default ValuesEditor;

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

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {searchAccessControlPolicies, deleteAccessControlPolicy} from 'mattermost-redux/actions/access_control';
import PolicyList from './policies';
const mapDispatchToProps = (dispatch: Dispatch) => ({
actions: bindActionCreators({
searchPolicies: searchAccessControlPolicies,
deletePolicy: deleteAccessControlPolicy,
}, dispatch),
});
export default connect(null, mapDispatchToProps)(PolicyList);

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

@@ -0,0 +1,77 @@
.AccessControlSyncJobTable {
overflow: auto;
.policy-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
&-text {
h1 {
margin-bottom: 8px;
font-size: 20px;
font-weight: 600;
}
p {
margin: 0;
color: rgba(63, 67, 80, 0.72);
}
}
.btn-primary {
display: flex;
align-items: center;
padding: 10px 16px;
gap: 8px;
.icon {
font-size: 18px;
}
}
}
.job-table__access-control {
.job-table__table {
max-height: 400px;
padding: 0px;
border: none;
}
.table > thead > tr > th,
.table > tbody > tr > th,
.table > tfoot > tr > th,
.table > thead > tr > td,
.table > tbody > tr > td,
.table > tfoot > tr > td {
border-top: none;
padding-block: 10px;
}
.table > thead > tr > th {
border-bottom: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.16);
font-size: 1em;
font-weight: 600;
}
.cancel-button-field {
width: 30px;
}
.table-row {
min-height: 40px;
cursor: pointer;
}
}
.DataGrid_footer {
border-bottom: 0;
color: rgba(var(--sys-center-channel-color-rgb), 0.56);
}
.actions-column {
justify-content: flex-end !important;
text-align: right;
}
}

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

@@ -0,0 +1,115 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useEffect} from 'react';
import type {JobType, JobTypeBase, Job} from '@mattermost/types/jobs';
import type {ActionResult} from 'mattermost-redux/types/actions';
import JobsTable from 'components/admin_console/jobs';
import {JobTypes} from 'utils/constants';
import JobDetailsModal from '../modals/job_details/job_details_modal';
import './access_control_sync_job_table.scss';
type Props = {
actions: {
createJob: (job: JobTypeBase) => Promise<ActionResult>;
getJobsByType: (jobType: JobType) => void;
};
};
export default function AccessControlSyncJobTable(props: Props): JSX.Element {
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
const [showModal, setShowModal] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
// Load jobs when component mounts
props.actions.getJobsByType(JobTypes.ACCESS_CONTROL_SYNC);
// Set up polling interval
const interval = setInterval(() => {
props.actions.getJobsByType(JobTypes.ACCESS_CONTROL_SYNC);
}, 15000);
return () => {
clearInterval(interval);
};
}, [props.actions]);
const handleCreateJob = async (e?: React.SyntheticEvent) => {
e?.preventDefault();
if (isSubmitting) {
return;
}
setIsSubmitting(true);
const job = {
type: JobTypes.ACCESS_CONTROL_SYNC,
};
try {
await props.actions.createJob(job);
// Immediately fetch updated job list
props.actions.getJobsByType(JobTypes.ACCESS_CONTROL_SYNC);
} finally {
// Reset submitting state after a short delay to prevent rapid re-clicks
setTimeout(() => {
setIsSubmitting(false);
}, 1000);
}
};
const handleRowClick = (job: Job) => {
setSelectedJob(job);
setShowModal(true);
};
const handleModalClose = () => {
setShowModal(false);
setSelectedJob(null);
};
return (
<div className='AccessControlSyncJobTable'>
<div className='policy-header'>
<div className='policy-header-text'>
<h1>{'Access Control Sync Jobs'}</h1>
<p>{'Synchronize access control policies with system resources and permissions.'}</p>
</div>
<button
className='btn btn-primary'
onClick={handleCreateJob}
disabled={isSubmitting}
>
<i className='icon icon-plus'/>
<span>{isSubmitting ? 'Running Job...' : 'Run Sync Job'}</span>
</button>
</div>
<JobsTable
perPage={5}
jobType={JobTypes.ACCESS_CONTROL_SYNC}
hideJobCreateButton={true}
className={'job-table__access-control'}
createJobButtonText={'Create Job'}
disabled={false}
createJobHelpText={<></>}
onRowClick={handleRowClick}
/>
{showModal && selectedJob && (
<JobDetailsModal
job={selectedJob}
onExited={handleModalClose}
/>
)}
</div>
);
}

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

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {createJob, getJobsByType} from 'mattermost-redux/actions/jobs';
import AccessControlSyncJobTable from './access_control_sync_job_table';
const mapDispatchToProps = (dispatch: Dispatch) => ({
actions: bindActionCreators({
createJob,
getJobsByType,
}, dispatch),
});
export default connect(null, mapDispatchToProps)(AccessControlSyncJobTable);

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

@@ -0,0 +1,39 @@
// Content layout and styling
.cel-help-modal__content-container {
display: flex;
flex-direction: column;
.cel-help-modal__content {
padding: 32px;
padding-top: 0;
}
// Important notes section
.cel-help-additional-info-modal__content {
display: flex;
flex-direction: column;
padding: 20px;
background-color: rgba(var(--button-bg-rgb), 0.08);
// Header with icon
.cel-help-additional-info-modal__header {
display: flex;
align-items: center;
margin-bottom: 16px;
i {
margin-right: 8px;
color: var(--button-bg);
font-size: 24px;
}
.cel-help-additional-info-modal__title {
font-size: 16px;
font-weight: 600;
}
}
}
}

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

@@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {GenericModal} from '@mattermost/components';
import ExternalLink from 'components/external_link';
import Markdown from 'components/markdown';
import './cel_help_modal.scss';
type Props = {
onExited: () => void;
onHide?: () => void;
};
const CELHelpModal: React.FC<Props> = ({onExited, onHide}: Props) => {
return (
<GenericModal
id='CELHelpModal'
className='cel-help-modal--centered'
aria-labelledby='CELHelpModalLabel'
onExited={onExited}
onHide={onHide}
modalHeaderText={(
<FormattedMessage
id='admin.access_control.cel_help_modal.title'
defaultMessage='Common Expression Language (CEL)'
/>
)}
modalSubheaderText={(
<FormattedMessage
id='admin.access_control.cel_help_modal.subheader'
defaultMessage='With CEL you can define conditions to filter user attributes and control resource access.'
/>
)}
compassDesign={true}
bodyPadding={false}
modalLocation='top'
>
<div className='cel-help-modal__content-container'>
<div className='cel-help-modal__content'>
<Markdown
message={'### Basic Syntax\nCEL expressions evaluate to boolean values (`true`/`false`) to determine if access should be granted.\n### Common Examples\n- To match a specific program:\n&ensp;`user.attributes.Program == "Delta"`\n- To match any of multiple teams:\n&ensp;`user.attributes.Team in ["Sales", "Engineering"]`\n- To match an email domain:\n&ensp;`user.attributes.Email.endsWith("example.com")`\n- To combine conditions (for this example with `OR` operator, altertanitvely use `&&` for `AND` operation):\n&ensp;`user.attrs.Program == "Alpha" || user.attrs.Team == "Operations"`\n### Supported Operators and functions\n- `==`, `!=`, `&&`, `||`, `in`, `contains()`, `startsWith()`, `endsWith()`'}
/>
</div>
<div className='cel-help-additional-info-modal__content'>
<div className='cel-help-additional-info-modal__header'>
<i className='icon icon-information-outline'/>
<span className='cel-help-additional-info-modal__title'>
<FormattedMessage
id='admin.access_control.cel_help_modal.important_notes_title'
defaultMessage='Important Notes'
/>
</span>
</div>
<div className='cel-help-additional-info-modal__text'>
<Markdown
message={'- Operators like `<` or `>` are forbidden due to incorrect string comparison.\n- Only `user.attributes` are supported; any other variables are not supported yet.'}
/>
<FormattedMessage
id='admin.access_control.cel_help_modal.external_link'
defaultMessage='For more information, visit <link>CEL Documentation</link>.'
values={{
link: (msg: React.ReactNode) => (
<ExternalLink
href='https://cel.dev/'
location='cel_help_modal'
>
{msg}
</ExternalLink>
),
}}
/>
</div>
</div>
</div>
</GenericModal>
);
};
export default CELHelpModal;

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

@@ -0,0 +1,70 @@
.PolicyConfirmationModal {
.modal-body {
.enforce-toggle {
margin-top: 20px;
.enforce-checkbox-label {
display: flex;
align-items: center;
color: #3d3c40;
cursor: pointer;
input[type="checkbox"] {
margin-right: 8px;
cursor: pointer;
}
}
}
.confirmation {
margin-top: 20px;
color: #3d3c40;
font-weight: 600;
}
}
.modal-footer {
.btn-cancel {
padding: 10px 16px;
border: none;
border-radius: 4px;
margin-right: 10px;
background: #f2f4f8;
color: #3d3c40;
font-size: 14px;
font-weight: 600;
&:hover {
background: #e8eaed;
}
}
.btn-apply {
padding: 10px 16px;
border: none;
border-radius: 4px;
background: #C74A4A;
color: white;
font-size: 14px;
font-weight: 600;
&:hover {
background: #b73535;
}
}
.btn-save {
padding: 10px 16px;
border: none;
border-radius: 4px;
background: #166de0;
color: white;
font-size: 14px;
font-weight: 600;
&:hover {
background: #0f5fc0;
}
}
}
}

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

@@ -0,0 +1,106 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import './confirmation_modal.scss';
import GenericModal from '@mattermost/components/src/generic_modal/generic_modal';
type Props = {
active: boolean;
onExited: () => void;
onConfirm: (apply: boolean) => void;
channelsAffected: number;
}
export default function PolicyConfirmationModal({active, onExited, onConfirm, channelsAffected}: Props) {
const {formatMessage} = useIntl();
const [enforceImmediately, setEnforceImmediately] = useState(true);
return (
<GenericModal
className={'PolicyConfirmationModal'}
show={true}
onExited={onExited}
onHide={onExited}
compassDesign={true}
modalHeaderText={
<FormattedMessage
id='admin.access_control.policy.save_policy_confirmation_title'
defaultMessage='Save access control policy '
/>
}
modalSubheaderText={
<FormattedMessage
id='admin.access_control.policy.save_policy_confirmation_subheader'
defaultMessage='{count} channels will be affected.'
values={{count: channelsAffected}}
/>
}
footerContent={
<div>
<button
type='button'
className='btn-cancel'
onClick={onExited}
>
{formatMessage({id: 'admin.access_control.edit_policy.cancel', defaultMessage: 'Cancel'})}
</button>
<button
type='button'
className={enforceImmediately ? 'btn-apply' : 'btn-save'}
onClick={() => onConfirm(enforceImmediately)}
>
{enforceImmediately ?
formatMessage({id: 'admin.access_control.edit_policy.apply_policy', defaultMessage: 'Apply policy'}) :
formatMessage({id: 'admin.access_control.edit_policy.save_policy', defaultMessage: 'Save policy'})
}
</button>
</div>
}
>
<div className='body'>
{active ? (
formatMessage({
id: 'admin.access_control.policy.save_policy_confirmation_body',
defaultMessage: 'Applying this policy will allow users with the appropriate attribute values to be added to the selected channels. Existing channel members will be removed from these channels if they are not assigned the values defined in this access policy.',
})
) : (
formatMessage({
id: 'admin.access_control.policy.save_policy_confirmation_body.inactive',
defaultMessage: 'Only users who match the attribute values configured below can be added to the selected channels. Existing channel members will be removed from these channels if they are not assigned the values defined in this access policy.',
})
)}
</div>
<div className='enforce-toggle'>
<label className='enforce-checkbox-label'>
<input
type='checkbox'
checked={enforceImmediately}
onChange={(e) => setEnforceImmediately(e.target.checked)}
/>
<span>{formatMessage({
id: 'admin.access_control.policy.enforce_immediately',
defaultMessage: 'Enforce policy immediately',
})}</span>
</label>
</div>
<div className='confirmation'>
{enforceImmediately ?
formatMessage({
id: 'admin.access_control.policy.channels_affected',
defaultMessage: 'Are you sure you want to save and apply the access control policy?',
}) :
formatMessage({
id: 'admin.access_control.policy.save_only',
defaultMessage: 'Are you sure you want to save this access control policy?',
})
}
</div>
</GenericModal>
);
}

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

@@ -0,0 +1,103 @@
#job-details-modal {
.modal-header{
padding: 16px 64px 4px 32px;
}
.filtered-user-list {
height: 440px;
cursor: pointer;
}
.modal-header-with-status {
display: flex;
align-items: center;
font-size: 26px;
font-weight: 600;
gap: 10px;
}
// Status indicator
.status-indicator {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
&.status-success { background-color: var(--online-indicator); }
&.status-error { background-color: var(--error-text); }
&.status-in-progress { background-color: var(--away-indicator); }
&.status-pending { background-color: var(--offline-indicator); }
}
.filter-row--full {
position: relative;
padding: 0 32px;
.input-clear {
top: 16px;
right: 14px;
}
#searchIcon {
position: absolute;
z-index: 2;
top: 16px;
left: 42px;
color: rgba(var(--center-channel-color-rgb), 0.64);
pointer-events: none;
}
#searchChannelsTextbox {
height: 48px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
box-shadow: none;
font-size: 16px;
padding-inline: 40px;
&::placeholder {
color: var(--center-channel-color);
}
&:focus {
border: 2px solid var(--button-bg);
}
}
}
.sync-job-channel-count-label {
display: flex;
margin: 10px 16px;
color: var(--center-channel-color-64);
font-size: 12px;
}
.changes-cell {
text-align: center;
.changes-summary {
.added {
color: var(--online-indicator);
font-weight: 600;
}
.removed {
color: var(--error-text);
font-weight: 600;
}
}
}
.more-modal__list .more-modal__details{
padding-left: 0;
}
.error-status-content {
display: flex;
height: 100%;
flex-direction: column;
padding: 0px 32px 32px;
&__title {
color: var(--error-text);
}
}
}

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

@@ -0,0 +1,238 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useEffect} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {GenericModal} from '@mattermost/components';
import type {Channel} from '@mattermost/types/channels';
import type {Job} from '@mattermost/types/jobs';
import type {Team} from '@mattermost/types/teams';
import type {IDMappedObjects} from '@mattermost/types/utilities';
import * as ChannelActions from 'mattermost-redux/actions/channels';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {getTeam} from 'mattermost-redux/selectors/entities/teams';
import CodeBlock from 'components/code_block/code_block';
import type {GlobalState} from 'types/store';
import SearchableSyncJobChannelList from './searchable_sync_job_channel_list';
import type {SyncResults} from './searchable_sync_job_channel_list';
import UserListModal, {type ChannelMembersSyncResults} from '../user_sync/user_sync_modal';
import './job_details_modal.scss';
// Component to display job status
type StatusIndicatorProps = {
status: string;
};
const StatusIndicator = ({status}: StatusIndicatorProps): JSX.Element => {
let statusClass = 'status-indicator';
if (status === 'success') {
statusClass += ' status-success';
} else if (status === 'error' || status === 'canceled') {
statusClass += ' status-error';
} else if (status === 'in_progress') {
statusClass += ' status-in-progress';
} else {
statusClass += ' status-pending';
}
return (
<div className='status-wrapper'>
<div
className={statusClass}
title={status}
/>
</div>
);
};
type Props = {
job: Job ;
onExited: () => void;
};
export default function JobDetailsModal({job, onExited}: Props): JSX.Element {
const dispatch = useDispatch();
const [selectedChannel, setSelectedChannel] = useState<string | null>(null);
const [selectedChannelName, setSelectedChannelName] = useState<string>('');
const [selectedChannelResults, setSelectedChannelResults] = useState<ChannelMembersSyncResults | null>(null);
const [channelLookup, setChannelLookup] = useState<IDMappedObjects<Channel>>({});
const [teamLookup, setTeamLookup] = useState<IDMappedObjects<Team>>({});
const [syncResults, setSyncResults] = useState<SyncResults | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [allChannelsForList, setAllChannelsForList] = useState<Channel[]>([]);
const pageSize = 10;
// Get state for lookups
const state = useSelector((state: GlobalState) => state);
// Parse sync results initially
useEffect(() => {
if (job?.data?.sync_results) {
const parsedResults = JSON.parse(job.data.sync_results);
setSyncResults(parsedResults);
// Collect all channel IDs and user IDs for lookup
const channelIds: string[] = [];
// Use a safer type cast for Object.entries
Object.entries(parsedResults).forEach((entry) => {
const channelId = entry[0];
channelIds.push(channelId);
});
// Fetch channel and user data if we have IDs
if (channelIds.length > 0) {
// Fetch each channel individually
channelIds.forEach((id) => {
dispatch(ChannelActions.getChannel(id));
});
}
}
}, [job?.data?.sync_results, dispatch]);
// Build channel lookup from state and prepare allChannelsForList
useEffect(() => {
if (syncResults) {
const channels: IDMappedObjects<Channel> = {};
const teams: IDMappedObjects<Team> = {};
const channelsForList: Channel[] = [];
Object.keys(syncResults).forEach((channelId) => {
const channel = getChannel(state, channelId);
if (channel) {
channels[channelId] = channel;
channelsForList.push(channel);
if (!teams[channel.team_id]) {
const team = getTeam(state, channel.team_id);
if (team) {
teams[team.id] = team;
}
}
}
});
setTeamLookup(teams);
setChannelLookup(channels);
setAllChannelsForList(channelsForList);
}
}, [syncResults, state]);
const handleViewDetails = (channelId: string, channelName: string, results: ChannelMembersSyncResults) => {
setSelectedChannel(channelId);
setSelectedChannelName(channelName);
setSelectedChannelResults(results);
};
const handleCloseUserListModal = () => {
setSelectedChannel(null);
setSelectedChannelName('');
setSelectedChannelResults(null);
};
// Filter and search channels for SearchableSyncJobChannelList
const getFilteredChannels = () => {
let channels = allChannelsForList;
if (searchTerm) {
channels = channels.filter((channel) =>
channel.display_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
channel.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(channelLookup[channel.id] && teamLookup[channelLookup[channel.id].team_id]?.name.toLowerCase().includes(searchTerm.toLowerCase())),
);
}
// Add filtering by type (Public, Private, Archived) if needed based on currentFilter
// For now, it shows all channels from syncResults
return channels;
};
const filteredChannels = getFilteredChannels();
const noResultsText = (
<span className='no-results-message'>
<FormattedMessage
id='admin.jobTable.syncResults.noResultsSearchable'
defaultMessage='No channels match your search or filter.'
/>
</span>
);
return (
<GenericModal
id='job-details-modal'
onExited={onExited}
compassDesign={true}
modalHeaderText={
<div className='modal-header-with-status'>
<FormattedMessage
id='admin.jobTable.details.title'
defaultMessage='Job Details'
/>
<StatusIndicator status={job.status}/>
</div>
}
modalSubheaderText={
<div className='modal-subheader-text'>
<FormattedMessage
id='admin.access_control.jobTable.details.subheader'
defaultMessage='Finished at {finishedAt}'
values={{
finishedAt: new Date(job.last_activity_at).toLocaleString(),
}}
/>
</div>
}
show={true}
bodyPadding={false}
>
{job.status === 'error' ? (
<div className='error-status-content'>
<div className='error-status-content__title'>
<FormattedMessage
id='admin.jobTable.syncResults.error'
defaultMessage='An error occurred while syncing the channels.'
/>
</div>
<CodeBlock
code={JSON.stringify(job.data, null, 2)}
language='json'
/>
</div>
) : (
job.type.includes('access_control_sync') && syncResults && (
<SearchableSyncJobChannelList
channels={filteredChannels}
teams={teamLookup}
channelsPerPage={pageSize}
nextPage={() => {}}
isSearch={Boolean(searchTerm)}
search={setSearchTerm}
onViewDetails={handleViewDetails}
noResultsText={noResultsText}
syncResults={syncResults}
/>
)
)}
{selectedChannel && selectedChannelResults && (
<UserListModal
channelId={selectedChannel}
channelName={selectedChannelName}
syncResults={selectedChannelResults}
onClose={handleCloseUserListModal}
/>
)}
</GenericModal>
);
}

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

@@ -0,0 +1,313 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useRef, useEffect} from 'react';
import {FormattedMessage, defineMessages, injectIntl, type WrappedComponentProps} from 'react-intl';
import {ArchiveOutlineIcon, GlobeIcon, LockOutlineIcon} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import type {Team} from '@mattermost/types/teams';
import type {IDMappedObjects} from '@mattermost/types/utilities';
import {isPrivateChannel} from 'mattermost-redux/utils/channel_utils';
import MagnifyingGlassSVG from 'components/common/svg_images_components/magnifying_glass_svg';
import LoadingScreen from 'components/loading_screen';
import QuickInput from 'components/quick_input';
import {isArchivedChannel} from 'utils/channel_utils';
import Constants from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
import type {ChannelMembersSyncResults} from '../user_sync/user_sync_modal';
export type SyncResults = {
[channelId: string]: ChannelMembersSyncResults;
};
interface Props extends WrappedComponentProps {
channels: Channel[];
teams: IDMappedObjects<Team>;
channelsPerPage: number;
nextPage: (page: number) => void;
isSearch: boolean;
search: (term: string) => void;
onViewDetails?: (channelId: string, channelName: string, results: ChannelMembersSyncResults) => void;
noResultsText: JSX.Element;
loading?: boolean;
syncResults: SyncResults;
}
const SearchableSyncJobChannelList = (props: Props) => {
const [page, setPage] = useState(0);
const [nextDisabled, setNextDisabled] = useState(false);
const [channelSearchValue, setChannelSearchValue] = useState('');
const [isSearch, setIsSearch] = useState(props.isSearch);
const channelListScroll = useRef<HTMLDivElement>(null);
// Handle getDerivedStateFromProps
useEffect(() => {
setIsSearch(props.isSearch);
if (props.isSearch && !isSearch) {
setPage(0);
}
}, [props.isSearch, isSearch]);
// Handle componentDidMount and componentWillUnmount
useEffect(() => {
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, []);
const onKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const isEnterKeyPressed = isKeyPressed(e, Constants.KeyCodes.ENTER);
if (isEnterKeyPressed && (e.shiftKey || e.ctrlKey || e.altKey)) {
return;
}
if (isEnterKeyPressed && target?.classList.contains('more-modal__row')) {
target.click();
}
};
const handleRowClick = (channel: Channel) => {
if (props.onViewDetails && props.syncResults[channel.id]) {
props.onViewDetails(channel.id, channel.display_name, props.syncResults[channel.id]);
}
};
const createChannelRow = (channel: Channel) => {
const ariaLabel = `${channel.display_name}, ${channel.purpose}`.toLowerCase();
let channelTypeIcon;
if (isArchivedChannel(channel)) {
channelTypeIcon = <ArchiveOutlineIcon size={18}/>;
} else if (isPrivateChannel(channel)) {
channelTypeIcon = <LockOutlineIcon size={18}/>;
} else {
channelTypeIcon = <GlobeIcon size={18}/>;
}
const team = props.teams[channel.team_id];
const channelMoreInfoContainer = (
<div
id='channelMoreInfoContainer'
aria-label={`${team.display_name}`}
>
<span className='more-modal__description'>{`${team.display_name}`}</span>
</div>
);
const channelSyncData = props.syncResults[channel.id];
const syncChangesDisplay = channelSyncData ? (
<div className='changes-cell'>
<span className='changes-summary'>
<span className='added'>
{'+' + (channelSyncData.MembersAdded?.length || 0)}
</span>
{' / '}
<span className='removed'>
{'-' + (channelSyncData.MembersRemoved?.length || 0)}
</span>
</span>
</div>
) : null;
return (
<div
className='more-modal__row job-sync-row'
key={channel.id}
id={`ChannelRow-${channel.name}`}
data-testid={`ChannelRow-${channel.name}`}
aria-label={ariaLabel}
onClick={() => handleRowClick(channel)}
tabIndex={0}
>
<div className='more-modal__details'>
<div className='style--none more-modal__name'>
{channelTypeIcon}
<span id='channelName'>{channel.display_name}</span>
</div>
{team && channelMoreInfoContainer}
</div>
<div className='more-modal__actions'>
{syncChangesDisplay}
</div>
</div>
);
};
const nextPage = (e: React.MouseEvent) => {
e.preventDefault();
setPage(page + 1);
setNextDisabled(true);
props.nextPage(page + 1);
channelListScroll.current?.scrollTo({top: 0});
};
const previousPage = (e: React.MouseEvent) => {
e.preventDefault();
setPage(page - 1);
channelListScroll.current?.scrollTo({top: 0});
};
const handleChange = (e?: React.FormEvent<HTMLInputElement>) => {
if (e?.currentTarget) {
setChannelSearchValue(e.currentTarget.value);
props.search(e.currentTarget.value);
}
};
const handleClear = () => {
setChannelSearchValue('');
props.search('');
};
const getEmptyStateMessage = () => {
return (
<FormattedMessage
id='more_channels.noMore'
tagName='strong'
defaultMessage='No results for {text}'
values={{text: channelSearchValue}}
/>
);
};
const channels = props.channels;
let listContent;
let nextButton;
let previousButton;
if (props.loading && channels.length === 0) {
listContent = <LoadingScreen/>;
} else if (channels.length === 0) {
listContent = (
<div
className='no-channel-message channel-switcher__suggestion-box'
aria-label={channelSearchValue.length > 0 ? props.intl.formatMessage(messages.noMore, {text: channelSearchValue}) : props.intl.formatMessage({id: 'widgets.channels_input.empty', defaultMessage: 'No channels found'})
}
>
<MagnifyingGlassSVG/>
<h3 className='primary-message'>
{getEmptyStateMessage()}
</h3>
{props.noResultsText}
</div>
);
} else {
const pageStart = page * props.channelsPerPage;
const pageEnd = pageStart + props.channelsPerPage;
const channelsToDisplay = props.channels.slice(pageStart, pageEnd);
listContent = channelsToDisplay.map(createChannelRow);
if (channelsToDisplay.length >= props.channelsPerPage && pageEnd < props.channels.length) {
nextButton = (
<button
className='btn btn-sm btn-tertiary filter-control filter-control__next'
onClick={nextPage}
disabled={nextDisabled}
aria-label={props.intl.formatMessage({id: 'more_channels.next', defaultMessage: 'Next'})}
>
<FormattedMessage
id='more_channels.next'
defaultMessage='Next'
/>
</button>
);
}
if (page > 0) {
previousButton = (
<button
className='btn btn-sm btn-tertiary filter-control filter-control__prev'
onClick={previousPage}
aria-label={props.intl.formatMessage({id: 'more_channels.prev', defaultMessage: 'Previous'})}
>
<FormattedMessage
id='more_channels.prev'
defaultMessage='Previous'
/>
</button>
);
}
}
const input = (
<div className='filter-row filter-row--full'>
<span
id='searchIcon'
aria-hidden='true'
>
<i className='icon icon-magnify'/>
</span>
<QuickInput
id='searchChannelsTextbox'
className='form-control filter-textbox'
placeholder={props.intl.formatMessage({id: 'filtered_channels_list.search', defaultMessage: 'Search channels'})}
onInput={handleChange}
clearable={true}
onClear={handleClear}
value={channelSearchValue}
aria-label={props.intl.formatMessage({id: 'filtered_channels_list.search', defaultMessage: 'Search Channels'})}
/>
</div>
);
let channelCountLabel;
if (channels.length === 0) {
channelCountLabel = props.intl.formatMessage({id: 'more_channels.count_zero', defaultMessage: '0 Results'});
} else if (channels.length === 1) {
channelCountLabel = props.intl.formatMessage({id: 'more_channels.count_one', defaultMessage: '1 Result'});
} else if (channels.length > 1) {
channelCountLabel = props.intl.formatMessage(messages.channelCount, {count: channels.length});
} else {
channelCountLabel = props.intl.formatMessage({id: 'more_channels.count_zero', defaultMessage: '0 Results'});
}
return (
<div className='filtered-user-list'>
{input}
<div className='more-modal__dropdown'>
<span className='sync-job-channel-count-label'>
{channelCountLabel}
</span>
</div>
<div
role='search'
className='more-modal__list'
tabIndex={-1}
>
<div
id='moreChannelsList'
tabIndex={-1}
ref={channelListScroll}
>
{listContent}
</div>
</div>
<div className='filter-controls'>
{previousButton}
{nextButton}
</div>
</div>
);
};
const messages = defineMessages({
channelCount: {
id: 'more_channels.count',
defaultMessage: '{count} Results',
},
noMore: {
id: 'more_channels.noMore',
defaultMessage: 'No results for {text}',
},
});
export default injectIntl(SearchableSyncJobChannelList);

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

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {GenericModal} from '@mattermost/components';
import type {AccessControlPolicy} from '@mattermost/types/access_control';
import type {ActionResult} from 'mattermost-redux/types/actions';
import PolicyList from 'components/admin_console/access_control/policies';
type Props = {
show: boolean;
onHide: () => void;
onPolicySelected: (policy: AccessControlPolicy) => void;
actions: {
searchPolicies: (term: string, type: string, after: string, limit: number) => Promise<ActionResult>;
};
};
export default function PolicySelectionModal(props: Props): JSX.Element {
const {show, onHide, onPolicySelected, actions} = props;
return (
<GenericModal
id='PolicySelectionModal'
compassDesign={true}
show={show}
onHide={onHide}
backdrop='static'
modalHeaderText={(
<FormattedMessage
id='admin.channel_settings.channel_detail.select_policy_title'
defaultMessage='Select an Access Control Policy'
/>
)}
modalSubheaderText={(
<FormattedMessage
id='admin.channel_settings.channel_detail.select_policy_description'
defaultMessage='An access control policy will restrict channel membership based on user attributes.'
/>
)}
>
<PolicyList
simpleMode={true}
onPolicySelected={onPolicySelected}
actions={{
searchPolicies: actions.searchPolicies,
deletePolicy: () => Promise.resolve({data: {}}),
}}
/>
</GenericModal>
);
}

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

@@ -0,0 +1,4 @@
#testResultsModalLabel {
padding: 32px 0;
text-align: center;
}

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

@@ -0,0 +1,120 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState, useCallback} from 'react';
import {Modal} from 'react-bootstrap';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import type {AccessControlTestResult} from '@mattermost/types/access_control';
import type {UserProfile} from '@mattermost/types/users';
import type {ActionResult} from 'mattermost-redux/types/actions';
import SearchableUserList from 'components/searchable_user_list/searchable_user_list_container';
import type {ModalData} from 'types/actions';
import type {ActionFuncAsync} from 'types/store';
import './test_modal.scss';
const USERS_TO_FETCH = 50;
const USERS_PER_PAGE = 10;
type Props = {
onExited: () => void;
actions: {
searchUsers: (term: string, after: string, limit: number) => ActionFuncAsync<AccessControlTestResult>;
openModal?: <P>(modalData: ModalData<P>) => void;
};
}
function TestResultsModal({
onExited,
actions,
}: Props): JSX.Element {
const dispatch = useDispatch<any>();
const [term, setTerm] = useState<string>('');
const [users, setUsers] = useState<UserProfile[]>([]);
const [total, setTotal] = useState<number>(0);
const [loading, setLoading] = useState<boolean>(true);
const [cursorHistory, setCursorHistory] = useState<string[]>([]); // Stores the 'after' cursor for page 1, page 2, etc.
const fetchUsers = useCallback(async (searchTerm: string, cursor: string, reset: boolean = false) => {
setLoading(true);
const result: ActionResult<AccessControlTestResult> = await dispatch(actions.searchUsers(searchTerm, cursor, USERS_TO_FETCH));
if (result?.data) {
const newUsers = result.data.users;
if (reset) {
setUsers(newUsers);
} else {
setUsers((prevUsers) => [...prevUsers, ...newUsers]);
}
setTotal(result.data.total);
} else {
setUsers([]);
setTotal(0);
}
setLoading(false);
}, [dispatch, actions]);
useEffect(() => {
fetchUsers(term, '');
}, []);
const handleSearch = (newTerm: string) => {
setCursorHistory([]);
setTerm(newTerm);
fetchUsers(newTerm, '', true);
};
const handleNextPage = (page: number) => {
if (loading || !users.length) {
return;
}
if (page * USERS_PER_PAGE < USERS_TO_FETCH) {
return;
}
const cursorForNextPage = users[users.length - 1].id;
setCursorHistory([...cursorHistory, cursorForNextPage]);
fetchUsers(term, cursorForNextPage);
};
return (
<Modal
dialogClassName='a11y__modal more-modal'
show={true}
onHide={onExited}
role='none'
aria-labelledby='testResultsModalLabel'
id='testResultsModal'
>
<Modal.Header
closeButton={true}
style={{display: 'flex', alignItems: 'center'}}
>
<Modal.Title
componentClass='h1'
id='testResultsModalLabel'
>
<FormattedMessage
id='admin.access_control.testResults'
defaultMessage='Access Rule Test Results'
/>
</Modal.Title>
</Modal.Header>
<Modal.Body>
<SearchableUserList
users={users}
usersPerPage={USERS_PER_PAGE}
total={total}
nextPage={handleNextPage}
search={handleSearch}
actionUserProps={{}}
/>
</Modal.Body>
</Modal>
);
}
export default TestResultsModal;

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

@@ -0,0 +1,107 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useEffect, useCallback} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import type {UserProfile} from '@mattermost/types/users';
import type {ActionResult} from 'mattermost-redux/types/actions';
import {UserGroupsSVG} from 'components/common/svg_images_components/user_groups_svg';
import SearchableUserList from 'components/searchable_user_list/searchable_user_list_container';
import type {ActionFuncAsync} from 'types/store';
type SyncedUserListProps = {
userIds: string[];
noResultsMessageId: string;
noResultsDefaultMessage: string;
actions: {
getProfilesByIds: (userIds: string[]) => ActionFuncAsync<UserProfile[]>;
};
};
const USERS_PER_PAGE = 10;
// TODO: this component should be improved:
// - make pagination work
// - improve search
export const SyncedUserList = ({userIds, noResultsMessageId, noResultsDefaultMessage, actions}: SyncedUserListProps): JSX.Element => {
const dispatch = useDispatch<any>();
const [users, setUsers] = useState<UserProfile[]>([]);
const [currentPage, setCurrentPage] = useState(0);
const totalUsers = userIds.length;
const fetchUsers = useCallback(async (page: number) => {
const startIndex = page * USERS_PER_PAGE;
const endIndex = startIndex + USERS_PER_PAGE;
const idsToFetch = userIds.slice(startIndex, endIndex);
await dispatch(actions.getProfilesByIds(idsToFetch)).then((result: ActionResult<UserProfile[]>) => {
if (result?.data) {
setUsers([...result.data]);
} else {
setUsers([]);
}
});
}, [userIds]);
useEffect(() => {
fetchUsers(currentPage);
}, [currentPage]);
const handleSearch = (searchTerm: string) => {
if (searchTerm === '') {
fetchUsers(0);
} else {
setUsers(users.filter((user) => {
return user.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.first_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.last_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.nickname.toLowerCase().includes(searchTerm.toLowerCase());
}));
}
};
if (userIds.length === 0) {
return (
<div
className='no-user-message'
aria-label='No users found'
>
<UserGroupsSVG className='empty-state-svg'/>
<h3 className='primary-message'>
<FormattedMessage
id={noResultsMessageId}
tagName='strong'
defaultMessage={noResultsDefaultMessage}
/>
</h3>
</div>
);
}
return (
<SearchableUserList
users={users}
usersPerPage={USERS_PER_PAGE}
total={totalUsers}
nextPage={() => {
setCurrentPage(currentPage + 1);
}}
previousPage={() => {
setCurrentPage(currentPage - 1);
}}
search={handleSearch}
actionUserProps={{}}
/>
);
};
export default SyncedUserList;

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

@@ -0,0 +1,56 @@
#user-list-modal-dialog {
.modal-header {
padding-bottom: 0px;
}
.tabs {
display: flex;
padding: 0 32px;
border-bottom: 1px solid var(--center-channel-color-16);
margin-bottom: 12px;
}
.tab-button {
padding: 10px 15px;
border: none;
margin-bottom: -1px;
background: none;
color: rgba(var(--center-channel-color-rgb), 0.64);
cursor: pointer;
font-weight: 600;
&.active {
border-bottom: 2px solid var(--button-bg);
color: var(--button-bg);
}
&:hover:not(.active) {
color: var(--center-channel-color);
}
}
.tab-content {
.filtered-user-list {
height: 440px;
overflow-y: auto;
}
.no-user-message {
display: flex;
height: 440px;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(var(--center-channel-color-rgb), 0.64);
text-align: center;
.empty-state-svg {
margin-bottom: 20px;
}
.primary-message {
padding-bottom: 24px;
}
}
}
}

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

@@ -0,0 +1,107 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import {FormattedMessage} from 'react-intl';
import {GenericModal} from '@mattermost/components';
import {getProfilesByIds} from 'mattermost-redux/actions/users';
import {SyncedUserList} from './synced_user_list';
import './user_sync_modal.scss';
// Types for sync results
export type ChannelMembersSyncResults = {
MembersAdded: string[];
MembersRemoved: string[];
};
// Modal for showing detailed user lists
type UserListModalProps = {
channelId: string;
channelName: string;
syncResults: ChannelMembersSyncResults;
onClose: () => void;
};
export const UserListModal = ({channelId, channelName, syncResults, onClose}: UserListModalProps): JSX.Element => {
const [activeTab, setActiveTab] = useState<'added' | 'removed'>('added');
const handleTabChange = (tab: 'added' | 'removed') => {
setActiveTab(tab);
};
const displayName = channelName || channelId;
return (
<GenericModal
className='a11y__modal more-modal'
id='user-list-modal-dialog'
onExited={onClose}
show={true}
onHide={onClose}
compassDesign={true}
bodyPadding={false}
modalHeaderText={
<FormattedMessage
id='admin.jobTable.syncResults.userListTitle'
defaultMessage='Channel Membership Changes'
/>
}
modalSubheaderText={`${displayName} - (${channelId})`}
>
<div className='tabs'>
<button
className={`tab-button ${activeTab === 'added' ? 'active' : ''}`}
onClick={() => handleTabChange('added')}
>
<FormattedMessage
id='admin.jobTable.syncResults.added'
defaultMessage='Added ({count, number})'
values={{
count: syncResults.MembersAdded.length,
}}
/>
</button>
<button
className={`tab-button ${activeTab === 'removed' ? 'active' : ''}`}
onClick={() => handleTabChange('removed')}
>
<FormattedMessage
id='admin.jobTable.syncResults.removed'
defaultMessage='Removed ({count, number})'
values={{
count: syncResults.MembersRemoved.length,
}}
/>
</button>
</div>
<div className='tab-content'>
{activeTab === 'added' && (
<SyncedUserList
userIds={syncResults.MembersAdded}
noResultsMessageId='admin.jobTable.syncResults.noUsersAdded'
noResultsDefaultMessage='No users were added'
actions={{
getProfilesByIds,
}}
/>
)}
{activeTab === 'removed' && (
<SyncedUserList
userIds={syncResults.MembersRemoved}
noResultsMessageId='admin.jobTable.syncResults.noUsersRemoved'
noResultsDefaultMessage='No users were removed'
actions={{
getProfilesByIds,
}}
/>
)}
</div>
</GenericModal>
);
};
export default UserListModal;

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

@@ -0,0 +1,166 @@
.PolicyTable {
overflow: auto;
.DataGrid {
padding: 0;
background-color: rgba(var(--sys-white-rgb), 0.04);
&_search {
height: 4em;
border-bottom: none;
}
&_searchBar {
margin-left: 0 !important;
}
&_header {
padding-left: 12px;
font-size: 1em;
font-weight: 600;
.DataGrid_cell {
display: flex;
&[data-field="name"] {
justify-content: flex-start;
}
&[data-field="resources"] {
display: flex;
align-items: left;
justify-content: flex-start;
}
}
}
.DataGrid_rows {
.DataGrid_row {
display: flex;
min-height: 40px;
padding-left: 12px !important;
border-left: none;
background-color: rgba(var(--sys-white-rgb), 0.04);
cursor: pointer;
&:hover {
border-left: none;
background-color: rgba(0, 0, 0, 0.05);
}
.policy-name {
width: 100%;
padding: 10px 0;
}
.policy-resources {
width: 100%;
padding: 10px 0;
text-align: left;
}
.policy-actions {
display: flex;
width: 100%;
justify-content: flex-end;
padding-right: 8px;
}
.policy-menu-button {
display: flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: rgba(var(--center-channel-color-rgb), 0.56);
cursor: pointer;
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.08);
color: rgba(var(--center-channel-color-rgb), 0.72);
}
.icon {
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
line-height: 18px;
}
}
}
}
&_footer {
border-bottom: 0;
}
}
.policy-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
&-text {
h1 {
margin-bottom: 8px;
font-size: 20px;
font-weight: 600;
}
p {
margin: 0;
color: rgba(63, 67, 80, 0.72);
}
}
.btn-primary {
display: flex;
align-items: center;
padding: 10px 16px;
gap: 8px;
.icon {
font-size: 18px;
}
}
}
.action-wrapper {
display: flex;
justify-content: flex-end;
padding: 0 16px;
.icon-button {
display: flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
border: none;
border-radius: 4px;
background: transparent;
color: rgba(63, 67, 80, 0.72);
cursor: pointer;
&:hover {
background-color: rgba(63, 67, 80, 0.08);
color: rgba(63, 67, 80, 0.8);
}
.icon {
font-size: 18px;
}
}
}
}
.actions-column {
justify-content: flex-end !important;
text-align: right;
}

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

@@ -0,0 +1,106 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import {act} from 'react-dom/test-utils';
import type {AccessControlPolicy} from '@mattermost/types/access_control';
import type {ActionResult} from 'mattermost-redux/types/actions';
import type {Column} from 'components/admin_console/data_grid/data_grid';
import PolicyList from './policies';
const mockHistoryPushInternal = jest.fn();
jest.mock('utils/browser_history', () => ({
getHistory: () => ({
push: mockHistoryPushInternal,
}),
}));
describe('components/admin_console/access_control/PolicyList', () => {
const mockSearchPolicies = jest.fn();
const mockDeletePolicy = jest.fn();
const defaultProps = {
actions: {
searchPolicies: mockSearchPolicies,
deletePolicy: mockDeletePolicy,
},
};
beforeEach(() => {
mockSearchPolicies.mockReset();
mockDeletePolicy.mockReset();
mockHistoryPushInternal.mockReset();
});
test('should match snapshot with no policies', async () => {
mockSearchPolicies.mockResolvedValue({data: {policies: [], total: 0}} as ActionResult);
const wrapper = shallow(<PolicyList {...defaultProps}/>);
await act(async () => {
await Promise.resolve();
});
wrapper.update();
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with policies', async () => {
mockSearchPolicies.mockResolvedValue({
data: {
policies: [
{id: 'policy1', name: 'Policy 1'} as AccessControlPolicy,
{id: 'policy2', name: 'Policy 2'} as AccessControlPolicy,
],
total: 2,
},
} as ActionResult);
const wrapper = shallow(<PolicyList {...defaultProps}/>);
await act(async () => {
await Promise.resolve();
});
wrapper.update();
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with search error', async () => {
mockSearchPolicies.mockRejectedValue(new Error('Search failed'));
const wrapper = shallow(<PolicyList {...defaultProps}/>);
await act(async () => {
await Promise.resolve();
});
wrapper.update();
expect(wrapper).toMatchSnapshot();
});
test('should not call previousPage if no history', async () => {
mockSearchPolicies.mockResolvedValueOnce({data: {policies: [], total: 0}} as ActionResult);
const wrapper = shallow(<PolicyList {...defaultProps}/>);
await act(async () => {
await Promise.resolve();
});
wrapper.update();
mockSearchPolicies.mockClear(); // Clear calls from mount
await act(async () => {
(wrapper.find('DataGrid').props() as any).previousPage();
});
wrapper.update();
expect(mockSearchPolicies).not.toHaveBeenCalled();
expect(wrapper.find('DataGrid').prop('page')).toBe(0);
});
test('should get columns correctly', () => {
const wrapper = shallow(<PolicyList {...defaultProps}/>);
// Columns are determined synchronously
const columns = wrapper.find('DataGrid').prop('columns') as Column[];
expect(columns).toHaveLength(3);
expect(columns[0].field).toBe('name');
expect(columns[1].field).toBe('resources');
expect(columns[2].field).toBe('actions');
});
});

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

@@ -0,0 +1,359 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useEffect, useMemo} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import type {AccessControlPolicy} from '@mattermost/types/access_control';
import type {ActionResult} from 'mattermost-redux/types/actions';
import type {Row, Column} from 'components/admin_console/data_grid/data_grid';
import DataGrid from 'components/admin_console/data_grid/data_grid';
import * as Menu from 'components/menu';
import {getHistory} from 'utils/browser_history';
import './policies.scss';
type Props = {
onPolicySelected?: (policy: AccessControlPolicy) => void;
simpleMode?: boolean;
actions: {
searchPolicies: (term: string, type: string, after: string, limit: number) => Promise<ActionResult>;
deletePolicy: (id: string) => Promise<ActionResult>;
};
};
const PAGE_SIZE = 10;
export default function PolicyList(props: Props): JSX.Element {
const [policies, setPolicies] = useState<AccessControlPolicy[]>([]);
const [page, setPage] = useState(0);
const [after, setAfter] = useState('');
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [searchErrored, setSearchErrored] = useState(false);
const [cursorHistory, setCursorHistory] = useState<string[]>([]);
const [total, setTotal] = useState(0);
const intl = useIntl();
const history = useMemo(() => getHistory(), []);
useEffect(() => {
fetchPolicies();
}, []);
const fetchPolicies = async (term = '', afterParam = '', resetPage = false) => {
setLoading(true);
try {
const action = await props.actions.searchPolicies(term, 'parent', afterParam, PAGE_SIZE + 1);
const data = action.data.policies || [];
const newTotal = action.data.total || 0;
// Check if we have more data than the page size, indicating there's a next page
const hasNextPage = data.length > PAGE_SIZE;
// If we have more data than needed, remove the extra item (which is used to check for next page)
const newPolicies = hasNextPage ? data.slice(0, PAGE_SIZE) : data;
// Get the ID of the last policy for the next cursor
const lastPolicyId = newPolicies.length > 0 ? newPolicies[newPolicies.length - 1].id : '';
if (resetPage) {
setPolicies(newPolicies);
setLoading(false);
setAfter(lastPolicyId);
setTotal(newTotal);
setPage(0);
setCursorHistory([]);
} else {
setPolicies(newPolicies);
setLoading(false);
setAfter(lastPolicyId);
setTotal(newTotal);
}
} catch (error) {
setLoading(false);
setSearchErrored(true);
}
};
const onSearch = async (term: string) => {
if (term.length === 0) {
setPage(0);
setAfter('');
setLoading(false);
setSearchErrored(false);
setSearch('');
fetchPolicies();
return;
}
setLoading(true);
setSearch(term);
await fetchPolicies(term, '', true);
};
const nextPage = async () => {
// Save current cursor to history for "previous" navigation
const newCursorHistory = [...cursorHistory, after];
setLoading(true);
setPage(page + 1);
setCursorHistory(newCursorHistory);
await fetchPolicies(search, after);
};
const previousPage = async () => {
if (cursorHistory.length === 0) {
return;
}
// Remove the current cursor from history
const newCursorHistory = [...cursorHistory];
newCursorHistory.pop();
// Get the previous cursor
const previousCursor = newCursorHistory.length > 0 ? newCursorHistory[newCursorHistory.length - 1] : '';
setLoading(true);
setPage(page - 1);
setCursorHistory(newCursorHistory);
await fetchPolicies(search, previousCursor);
};
const getResources = (policy: AccessControlPolicy) => {
const childIds = policy.props?.child_ids as string[];
if (!childIds || childIds.length === 0) {
return (
<FormattedMessage
id='admin.access_control.policies.resources.none'
defaultMessage='None'
/>
);
}
return (
<FormattedMessage
id='admin.access_control.policies.resources.channels'
defaultMessage='{count, number} {count, plural, one {channel} other {channels}}'
values={{
count: childIds.length,
}}
/>
);
};
const handleDelete = async (policyId: string) => {
await props.actions.deletePolicy(policyId);
fetchPolicies(search);
};
const getRows = (): Row[] => {
return policies.map((policy: AccessControlPolicy) => {
const descriptionId = `customDescription-${policy.id}`;
const appliedToId = `customAppliedTo-${policy.id}`;
return {
cells: {
name: (
<div
id={descriptionId}
className='policy-name'
>
{policy.name}
</div>
),
resources: (
<div
id={appliedToId}
className='policy-resources'
>
{getResources(policy)}
</div>
),
actions: (
<div className='policy-actions'>
{!props.simpleMode && (
<Menu.Container
menuButton={{
id: `policy-menu-${policy.id}`,
class: 'policy-menu-button',
children: (
<i className='icon icon-dots-vertical'/>
),
}}
menu={{
id: `policy-menu-dropdown-${policy.id}`,
'aria-label': intl.formatMessage({
id: 'admin.access_control.policies.menu.aria_label',
defaultMessage: 'Policy actions menu',
}),
}}
>
<Menu.Item
id={`policy-menu-edit-${policy.id}`}
onClick={() => {
history.push(`/admin_console/user_management/attribute_based_access_control/edit_policy/${policy.id}`);
}}
leadingElement={<i className='icon icon-pencil-outline'/>}
labels={
<FormattedMessage
id='admin.access_control.edit'
defaultMessage='Edit'
/>
}
/>
<Menu.Item
id={`policy-menu-delete-${policy.id}`}
onClick={() => handleDelete(policy.id)}
leadingElement={<i className='icon icon-trash-can-outline'/>}
labels={
<FormattedMessage
id='admin.access_control.delete'
defaultMessage='Delete'
/>
}
isDestructive={true}
disabled={Boolean(policy.props?.child_ids?.length)}
/>
</Menu.Container>
)}
</div>
),
},
onClick: () => {
if (props.onPolicySelected) {
props.onPolicySelected(policy);
} else {
history.push(`/admin_console/user_management/attribute_based_access_control/edit_policy/${policy.id}`);
}
},
};
});
};
const getColumns = (): Column[] => {
return [
{
name: (
<FormattedMessage
id='admin.access_control.policies.name'
defaultMessage='Name'
/>
),
field: 'name',
width: 5,
},
{
name: (
<FormattedMessage
id='admin.access_control.policies.applies_to'
defaultMessage='Applies to'
/>
),
field: 'resources',
textAlign: 'center',
width: 4,
},
{
name: (
<span/>
),
field: 'actions',
className: 'actions-column',
width: 1,
},
];
};
const getPaginationProps = () => {
const startCount = (page * PAGE_SIZE) + 1;
const endCount = (startCount + policies.length) - 1;
return {
startCount,
endCount,
total,
};
};
const rows: Row[] = getRows();
const columns: Column[] = getColumns();
const {startCount, endCount} = getPaginationProps();
let placeholderEmpty: JSX.Element = (
<FormattedMessage
id='admin.user_settings.policy_list.no_policies_found'
defaultMessage='No policies found'
/>
);
if (searchErrored) {
placeholderEmpty = (
<FormattedMessage
id='admin.user_settings.policy_list.search_policy_errored'
defaultMessage='Something went wrong. Try again'
/>
);
}
const rowsContainerStyles = {
minHeight: `${rows.length * 40}px`,
};
return (
<div className='PolicyTable'>
{!props.simpleMode && (
<div className='policy-header'>
<div className='policy-header-text'>
<h1>
<FormattedMessage
id='admin.access_control.policies.title'
defaultMessage='Access Control Policies'
/>
</h1>
<p>
<FormattedMessage
id='admin.access_control.policies.description'
defaultMessage='Create policies containing attribute based access rules and the resources they apply to.'
/>
</p>
</div>
<button
className='btn btn-primary'
onClick={() => {
history.push('/admin_console/user_management/attribute_based_access_control/edit_policy');
}}
>
<i className='icon icon-plus'/>
<span>
<FormattedMessage
id='admin.access_control.policies.add_policy'
defaultMessage='Add policy'
/>
</span>
</button>
</div>
)}
<DataGrid
columns={columns}
rows={rows}
loading={loading}
startCount={startCount}
endCount={endCount}
total={total}
onSearch={onSearch}
term={search}
placeholderEmpty={placeholderEmpty}
rowsContainerStyles={rowsContainerStyles}
page={page}
nextPage={nextPage}
previousPage={previousPage}
/>
</div>
);
}

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

@@ -0,0 +1,346 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/admin_console/access_control/policy_details/PolicyDetails should match snapshot with existing policy 1`] = `
<div
className="wrapper--fixed AccessControlPolicySettings"
>
<AdminHeader
withBackButton={true}
>
<div>
<Connect(Component)
className="fa fa-angle-left back"
to="/admin_console/user_management/attribute_based_access_control"
/>
<MemoizedFormattedMessage
defaultMessage="Edit Access Control Policy"
id="admin.access_control.policy.edit_policy.title"
/>
</div>
</AdminHeader>
<div
className="admin-console__wrapper"
>
<div
className="admin-console__content"
>
<div
className="admin-console__setting-group"
>
<TextSetting
id="admin.access_control.policy.edit_policy.policyName"
inputClassName="col-sm-8"
label={
<Memo(MemoizedFormattedMessage)
defaultMessage="Access control policy name:"
id="admin.access_control.policy.edit_policy.policyName"
/>
}
labelClassName="col-sm-4 vertically-centered-label"
onChange={[Function]}
placeholder="Add a unique policy name"
value=""
/>
<Memo(BooleanSetting)
helpText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Users who match the attribute values configured below will be automatically added as new members. Regardless of this setting, users who later no longer match the configured attribute values will be removed from the channel after the next sync."
id="admin.access_control.policy.edit_policy.autoSyncMembership.description"
/>
}
id="admin.access_control.policy.edit_policy.autoSyncMembership"
label={
<div
className="vertically-centered-label"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Auto-add members based on access rules:"
id="admin.access_control.policy.edit_policy.autoSyncMembership"
/>
</div>
}
onChange={[Function]}
setByEnv={false}
value={false}
/>
</div>
<Card
className="console"
expanded={true}
>
<CardHeader>
<TitleAndButtonCardHeader
buttonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Switch to Advanced Mode"
id="admin.access_control.policy.edit_policy.switch_to_advanced"
/>
}
isDisabled={false}
onClick={[Function]}
subtitle="Select user attributes and values as rules to restrict channel membership."
title="Attribute based access rules"
/>
</CardHeader>
<CardBody>
<TableEditor
onChange={[Function]}
onValidate={[Function]}
userAttributes={Array []}
value=""
/>
</CardBody>
</Card>
<Card
className="console add-channels"
expanded={true}
>
<CardHeader>
<TitleAndButtonCardHeader
buttonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Add channels"
id="admin.access_control.policy.edit_policy.channel_selector.addChannels"
/>
}
onClick={[Function]}
subtitle={
<Memo(MemoizedFormattedMessage)
defaultMessage="Add channels that this property based access policy will apply to."
id="admin.access_control.policy.edit_policy.channel_selector.subtitle"
/>
}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Assigned channels"
id="admin.access_control.policy.edit_policy.channel_selector.title"
/>
}
/>
</CardHeader>
<CardBody
expanded={true}
>
<Connect(ChannelList)
channelsToAdd={Object {}}
channelsToRemove={Object {}}
onRemoveCallback={[Function]}
onUndoRemoveCallback={[Function]}
policyId="policy1"
/>
</CardBody>
</Card>
<Card
className="console delete-policy"
expanded={true}
>
<CardHeader>
<TitleAndButtonCardHeader
buttonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Delete"
id="admin.access_control.policy.edit_policy.delete_policy.delete"
/>
}
isDisabled={false}
onClick={[Function]}
subtitle={
<Memo(MemoizedFormattedMessage)
defaultMessage="This policy will be deleted and cannot be recovered."
id="admin.access_control.policy.edit_policy.delete_policy.subtitle"
/>
}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Delete policy"
id="admin.access_control.policy.edit_policy.delete_policy.title"
/>
}
/>
</CardHeader>
</Card>
</div>
</div>
<div
className="admin-console-save"
>
<SaveButton
defaultMessage={
<Memo(MemoizedFormattedMessage)
defaultMessage="Save"
id="admin.access_control.edit_policy.save"
/>
}
disabled={true}
onClick={[Function]}
/>
<Connect(Component)
className="btn btn-quaternary"
to="/admin_console/user_management/attribute_based_access_control"
>
<MemoizedFormattedMessage
defaultMessage="Cancel"
id="admin.access_control.edit_policy.cancel"
/>
</Connect(Component)>
</div>
</div>
`;
exports[`components/admin_console/access_control/policy_details/PolicyDetails should match snapshot with new policy 1`] = `
<div
className="wrapper--fixed AccessControlPolicySettings"
>
<AdminHeader
withBackButton={true}
>
<div>
<Connect(Component)
className="fa fa-angle-left back"
to="/admin_console/user_management/attribute_based_access_control"
/>
<MemoizedFormattedMessage
defaultMessage="Edit Access Control Policy"
id="admin.access_control.policy.edit_policy.title"
/>
</div>
</AdminHeader>
<div
className="admin-console__wrapper"
>
<div
className="admin-console__content"
>
<div
className="admin-console__setting-group"
>
<TextSetting
id="admin.access_control.policy.edit_policy.policyName"
inputClassName="col-sm-8"
label={
<Memo(MemoizedFormattedMessage)
defaultMessage="Access control policy name:"
id="admin.access_control.policy.edit_policy.policyName"
/>
}
labelClassName="col-sm-4 vertically-centered-label"
onChange={[Function]}
placeholder="Add a unique policy name"
value=""
/>
<Memo(BooleanSetting)
helpText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Users who match the attribute values configured below will be automatically added as new members. Regardless of this setting, users who later no longer match the configured attribute values will be removed from the channel after the next sync."
id="admin.access_control.policy.edit_policy.autoSyncMembership.description"
/>
}
id="admin.access_control.policy.edit_policy.autoSyncMembership"
label={
<div
className="vertically-centered-label"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Auto-add members based on access rules:"
id="admin.access_control.policy.edit_policy.autoSyncMembership"
/>
</div>
}
onChange={[Function]}
setByEnv={false}
value={false}
/>
</div>
<Card
className="console"
expanded={true}
>
<CardHeader>
<TitleAndButtonCardHeader
buttonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Switch to Advanced Mode"
id="admin.access_control.policy.edit_policy.switch_to_advanced"
/>
}
isDisabled={false}
onClick={[Function]}
subtitle="Select user attributes and values as rules to restrict channel membership."
title="Attribute based access rules"
/>
</CardHeader>
<CardBody>
<TableEditor
onChange={[Function]}
onValidate={[Function]}
userAttributes={Array []}
value=""
/>
</CardBody>
</Card>
<Card
className="console add-channels"
expanded={true}
>
<CardHeader>
<TitleAndButtonCardHeader
buttonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Add channels"
id="admin.access_control.policy.edit_policy.channel_selector.addChannels"
/>
}
onClick={[Function]}
subtitle={
<Memo(MemoizedFormattedMessage)
defaultMessage="Add channels that this property based access policy will apply to."
id="admin.access_control.policy.edit_policy.channel_selector.subtitle"
/>
}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Assigned channels"
id="admin.access_control.policy.edit_policy.channel_selector.title"
/>
}
/>
</CardHeader>
<CardBody
expanded={true}
>
<Connect(ChannelList)
channelsToAdd={Object {}}
channelsToRemove={Object {}}
onRemoveCallback={[Function]}
onUndoRemoveCallback={[Function]}
policyId=""
/>
</CardBody>
</Card>
</div>
</div>
<div
className="admin-console-save"
>
<SaveButton
defaultMessage={
<Memo(MemoizedFormattedMessage)
defaultMessage="Save"
id="admin.access_control.edit_policy.save"
/>
}
disabled={true}
onClick={[Function]}
/>
<Connect(Component)
className="btn btn-quaternary"
to="/admin_console/user_management/attribute_based_access_control"
>
<MemoizedFormattedMessage
defaultMessage="Cancel"
id="admin.access_control.edit_policy.cancel"
/>
</Connect(Component)>
</div>
</div>
`;

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

@@ -0,0 +1,347 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/admin_console/access_control/channel_list should match snapshot with channels 1`] = `
<div
className="AccessControlPolicyChannelsList"
>
<DataGrid
className="customTable"
columns={
Array [
Object {
"field": "name",
"fixed": true,
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Name"
id="admin.channel_settings.channel_list.nameHeader"
/>,
},
Object {
"field": "team",
"fixed": true,
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Team"
id="admin.channel_settings.channel_list.teamHeader"
/>,
},
Object {
"field": "remove",
"fixed": true,
"name": "",
"textAlign": "right",
},
]
}
endCount={2}
filterProps={
Object {
"keys": Array [
"teams",
],
"onFilter": [Function],
"options": Object {
"teams": Object {
"keys": Array [
"team_ids",
],
"name": "Teams",
"type": Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
},
"values": Object {
"team_ids": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Teams"
id="admin.team_settings.title"
/>,
"value": Array [],
},
},
},
},
}
}
loading={true}
nextPage={[Function]}
onSearch={[Function]}
page={0}
previousPage={[Function]}
rows={Array []}
startCount={1}
term=""
total={2}
/>
</div>
`;
exports[`components/admin_console/access_control/channel_list should match snapshot with channels to add 1`] = `
<div
className="AccessControlPolicyChannelsList"
>
<DataGrid
className="customTable"
columns={
Array [
Object {
"field": "name",
"fixed": true,
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Name"
id="admin.channel_settings.channel_list.nameHeader"
/>,
},
Object {
"field": "team",
"fixed": true,
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Team"
id="admin.channel_settings.channel_list.teamHeader"
/>,
},
Object {
"field": "remove",
"fixed": true,
"name": "",
"textAlign": "right",
},
]
}
endCount={3}
filterProps={
Object {
"keys": Array [
"teams",
],
"onFilter": [Function],
"options": Object {
"teams": Object {
"keys": Array [
"team_ids",
],
"name": "Teams",
"type": Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
},
"values": Object {
"team_ids": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Teams"
id="admin.team_settings.title"
/>,
"value": Array [],
},
},
},
},
}
}
loading={true}
nextPage={[Function]}
onSearch={[Function]}
page={0}
previousPage={[Function]}
rows={
Array [
Object {
"cells": Object {
"id": "channel3",
"name": <div
className="ChannelList__nameColumn"
id="channel-name-channel3"
>
<ArchiveIcon
className="channel-icon"
data-testid="channel3-archive-icon"
/>
<div
className="ChannelList__nameText"
>
<b
id="display-name-channel-channel3"
>
Channel 3
</b>
</div>
</div>,
"remove": <a
className="group-actions TeamList_editText"
href="#"
id="remove-channel-channel3"
onClick={[Function]}
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Remove"
id="admin.access_control.policy.edit_policy.channel_selector.remove"
/>
</a>,
"team": "Team 1",
},
},
]
}
startCount={1}
term=""
total={3}
/>
</div>
`;
exports[`components/admin_console/access_control/channel_list should match snapshot with channels to remove 1`] = `
<div
className="AccessControlPolicyChannelsList"
>
<DataGrid
className="customTable"
columns={
Array [
Object {
"field": "name",
"fixed": true,
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Name"
id="admin.channel_settings.channel_list.nameHeader"
/>,
},
Object {
"field": "team",
"fixed": true,
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Team"
id="admin.channel_settings.channel_list.teamHeader"
/>,
},
Object {
"field": "remove",
"fixed": true,
"name": "",
"textAlign": "right",
},
]
}
endCount={2}
filterProps={
Object {
"keys": Array [
"teams",
],
"onFilter": [Function],
"options": Object {
"teams": Object {
"keys": Array [
"team_ids",
],
"name": "Teams",
"type": Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
},
"values": Object {
"team_ids": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Teams"
id="admin.team_settings.title"
/>,
"value": Array [],
},
},
},
},
}
}
loading={true}
nextPage={[Function]}
onSearch={[Function]}
page={0}
previousPage={[Function]}
rows={Array []}
startCount={1}
term=""
total={2}
/>
</div>
`;
exports[`components/admin_console/access_control/channel_list should match snapshot with no channels 1`] = `
<div
className="AccessControlPolicyChannelsList"
>
<DataGrid
className="customTable"
columns={
Array [
Object {
"field": "name",
"fixed": true,
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Name"
id="admin.channel_settings.channel_list.nameHeader"
/>,
},
Object {
"field": "team",
"fixed": true,
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Team"
id="admin.channel_settings.channel_list.teamHeader"
/>,
},
Object {
"field": "remove",
"fixed": true,
"name": "",
"textAlign": "right",
},
]
}
endCount={0}
filterProps={
Object {
"keys": Array [
"teams",
],
"onFilter": [Function],
"options": Object {
"teams": Object {
"keys": Array [
"team_ids",
],
"name": "Teams",
"type": Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
},
"values": Object {
"team_ids": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Teams"
id="admin.team_settings.title"
/>,
"value": Array [],
},
},
},
},
}
}
loading={false}
nextPage={[Function]}
onSearch={[Function]}
page={0}
previousPage={[Function]}
rows={Array []}
startCount={1}
term=""
total={0}
/>
</div>
`;

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

@@ -0,0 +1,47 @@
.AccessControlPolicyChannelsList {
.Filter_content.Filter__show {
width: 320px;
}
.FilterList {
width: auto;
}
.ChannelList__nameColumn {
display: inline-flex;
align-items: center;
justify-content: center;
span.channel-icon {
margin: 3px 8px 0 0;
}
}
.DataGrid {
padding: 0;
background-color: rgba(var(--sys-white-rgb), 0.04);
&_search {
border-bottom: none;
}
&_searchBar {
margin-left: 0;
}
.DataGrid_rows {
.DataGrid_row {
display: flex;
min-height: 40px;
border-left: none;
background-color: rgba(var(--sys-white-rgb), 0.04);
cursor: pointer;
&:hover {
border-left: none;
background-color: rgba(0, 0, 0, 0.05);
}
}
}
}
}

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

@@ -0,0 +1,100 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import type {ChannelWithTeamData} from '@mattermost/types/channels';
import ChannelList from './channel_list';
describe('components/admin_console/access_control/channel_list', () => {
const mockSearchChannels = jest.fn();
const mockSetChannelListSearch = jest.fn();
const mockSetChannelListFilters = jest.fn();
const mockOnRemoveCallback = jest.fn();
const mockOnUndoRemoveCallback = jest.fn();
const mockOnAddCallback = jest.fn();
const defaultProps = {
channels: [],
totalCount: 2,
searchTerm: '',
filters: {},
policyId: 'policy1',
onRemoveCallback: mockOnRemoveCallback,
onUndoRemoveCallback: mockOnUndoRemoveCallback,
onAddCallback: mockOnAddCallback,
channelsToRemove: {},
channelsToAdd: {},
actions: {
searchChannels: jest.fn().mockResolvedValue({
data: {
channels: [
{id: 'channel1', name: 'Channel 1', display_name: 'Channel 1', team_display_name: 'Team 1', type: 'O'} as ChannelWithTeamData,
{id: 'channel2', name: 'channel2', display_name: 'Channel 2', team_display_name: 'Team 2', type: 'P'} as ChannelWithTeamData,
],
},
}),
setChannelListSearch: mockSetChannelListSearch,
setChannelListFilters: mockSetChannelListFilters,
},
};
beforeEach(() => {
mockSearchChannels.mockReset();
mockSetChannelListSearch.mockReset();
mockSetChannelListFilters.mockReset();
mockOnRemoveCallback.mockReset();
mockOnUndoRemoveCallback.mockReset();
mockOnAddCallback.mockReset();
});
test('should match snapshot with no channels', () => {
const props = {
...defaultProps,
channels: [],
totalCount: 0,
policyId: '',
};
const wrapper = shallow(<ChannelList {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with channels', () => {
const props = {
...defaultProps,
totalCount: 2,
policyId: 'policy1',
actions: {
...defaultProps.actions,
},
};
const wrapper = shallow(<ChannelList {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with channels to remove', () => {
const props = {
...defaultProps,
totalCount: 2,
policyId: 'policy1',
channelsToRemove: {
channel1: {id: 'channel1', name: 'Channel 1', display_name: 'Channel 1', team_display_name: 'Team 1', type: 'O'} as ChannelWithTeamData,
},
};
const wrapper = shallow(<ChannelList {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with channels to add', () => {
const props = {
...defaultProps,
channelsToAdd: {
channel3: {id: 'channel3', name: 'channel3', display_name: 'Channel 3', team_display_name: 'Team 1', type: 'O'} as ChannelWithTeamData,
},
};
const wrapper = shallow(<ChannelList {...props}/>);
expect(wrapper).toMatchSnapshot();
});
});

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

@@ -0,0 +1,388 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import debounce from 'lodash/debounce';
import isEqual from 'lodash/isEqual';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import type {ChannelSearchOpts, ChannelWithTeamData} from '@mattermost/types/channels';
import type {ActionResult} from 'mattermost-redux/types/actions';
import DataGrid from 'components/admin_console/data_grid/data_grid';
import type {Column, Row} from 'components/admin_console/data_grid/data_grid';
import type {FilterOptions} from 'components/admin_console/filter/filter';
import TeamFilterDropdown from 'components/admin_console/filter/team_filter_dropdown';
import ArchiveIcon from 'components/widgets/icons/archive_icon';
import GlobeIcon from 'components/widgets/icons/globe_icon';
import LockIcon from 'components/widgets/icons/lock_icon';
import {isArchivedChannel} from 'utils/channel_utils';
import {Constants} from 'utils/constants';
import './channel_list.scss';
type Props = {
channels: ChannelWithTeamData[];
totalCount: number;
searchTerm: string;
filters: ChannelSearchOpts;
policyId?: string;
onRemoveCallback: (channel: ChannelWithTeamData) => void;
onUndoRemoveCallback: (channel: ChannelWithTeamData) => void;
channelsToRemove: Record<string, ChannelWithTeamData>;
channelsToAdd: Record<string, ChannelWithTeamData>;
actions: {
searchChannels: (id: string, term: string, opts: ChannelSearchOpts) => Promise<ActionResult>;
setChannelListSearch: (term: string) => void;
setChannelListFilters: (filters: ChannelSearchOpts) => void;
};
}
type State = {
loading: boolean;
page: number;
after: string;
cursorHistory: string[];
}
const PAGE_SIZE = 10;
export default class ChannelList extends React.PureComponent<Props, State> {
private mounted = false;
private searchDebounced;
public constructor(props: Props) {
super(props);
this.state = {
after: '',
loading: false,
page: 0,
cursorHistory: [],
};
this.searchDebounced = debounce(
async () => {
const {policyId, searchTerm, filters, actions} = this.props;
if (policyId) {
await actions.searchChannels(policyId, searchTerm, filters);
}
this.setState({loading: false});
},
Constants.SEARCH_TIMEOUT_MILLISECONDS,
);
}
componentDidMount = () => {
this.mounted = true;
this.loadPage(0, PAGE_SIZE + 1);
};
componentWillUnmount = () => {
this.searchDebounced.cancel();
this.mounted = false;
};
public async componentDidUpdate(prevProps: Props) {
const {policyId, searchTerm, filters} = this.props;
const filtersModified = !isEqual(prevProps.filters, filters);
const searchTermModified = prevProps.searchTerm !== searchTerm;
if (searchTermModified || filtersModified) {
this.setState({loading: true});
if (searchTerm === '') {
if (filtersModified && policyId) {
await prevProps.actions.searchChannels(policyId, searchTerm, filters);
} else {
// Reset pagination state when clearing search
this.setState({
after: '',
page: 0,
cursorHistory: [],
});
await this.loadPage(0, PAGE_SIZE + 1);
}
this.setState({loading: false});
return;
}
this.searchDebounced();
}
}
private loadPage = async (page: number, pageSize = PAGE_SIZE + 1) => {
const {policyId, searchTerm, filters, actions} = this.props;
if (!policyId || !this.mounted) {
return;
}
this.setState({loading: true});
const searchFilters = {...filters, page, per_page: pageSize};
try {
const action = await actions.searchChannels(policyId, searchTerm, searchFilters);
const data = action.data.channels || [];
// Check if we have more data than the page size, indicating there's a next page
const hasNextPage = data.length > PAGE_SIZE;
// If we have more data than needed, remove the extra item (which is used to check for next page)
const channels = hasNextPage ? data.slice(0, PAGE_SIZE) : data;
// Get the ID of the last channel for the next cursor
const lastChannelId = channels.length > 0 ? channels[channels.length - 1].id : '';
this.setState({
after: lastChannelId,
loading: false,
});
} catch (error) {
this.setState({loading: false});
}
};
private nextPage = async () => {
const {after, cursorHistory, page} = this.state;
// Save current cursor to history for "previous" navigation
const newCursorHistory = [...cursorHistory, after];
this.setState({
loading: true,
page: page + 1,
cursorHistory: newCursorHistory,
});
await this.loadPage(page + 1, PAGE_SIZE);
};
private previousPage = async () => {
const {cursorHistory, page} = this.state;
if (cursorHistory.length === 0) {
return;
}
// Remove the current cursor from history
const newCursorHistory = [...cursorHistory];
newCursorHistory.pop();
this.setState({
loading: true,
page: page - 1,
cursorHistory: newCursorHistory,
});
await this.loadPage(page - 1, PAGE_SIZE);
};
private getVisibleTotalCount = (): number => {
const {channelsToAdd, totalCount} = this.props;
const channelsToAddCount = Object.keys(channelsToAdd).length;
return (totalCount + channelsToAddCount);
};
public getPaginationProps = (): {startCount: number; endCount: number; total: number} => {
const {page} = this.state;
const startCount = (page * PAGE_SIZE) + 1;
const total = this.getVisibleTotalCount();
const endCount = Math.min((page + 1) * PAGE_SIZE, total);
return {startCount, endCount, total};
};
private removeChannel = (channel: ChannelWithTeamData) => {
const {channelsToRemove, onRemoveCallback, onUndoRemoveCallback} = this.props;
const {page} = this.state;
// Toggle between adding and removing the channel
if (channelsToRemove[channel.id] === channel) {
// If the channel is already marked for removal, undo it
onUndoRemoveCallback(channel);
return;
}
// If the channel is not marked for removal, mark it
onRemoveCallback(channel);
const {endCount} = this.getPaginationProps();
if (endCount > this.getVisibleTotalCount() && (endCount % PAGE_SIZE) === 1 && page > 0) {
this.setState({page: page - 1});
}
};
getColumns = (): Column[] => {
return [
{
name: (
<FormattedMessage
id='admin.channel_settings.channel_list.nameHeader'
defaultMessage='Name'
/>
),
field: 'name',
fixed: true,
},
{
name: (
<FormattedMessage
id='admin.channel_settings.channel_list.teamHeader'
defaultMessage='Team'
/>
),
field: 'team',
fixed: true,
},
{
name: '',
field: 'remove',
textAlign: 'right',
fixed: true,
},
];
};
getRows = () => {
const {channels, channelsToRemove, channelsToAdd} = this.props;
const {startCount, endCount} = this.getPaginationProps();
// Combine channels to add with existing channels
const channelsToDisplay = [
...Object.values(channelsToAdd),
...channels,
].slice(startCount - 1, endCount);
return channelsToDisplay.map((channel) => {
// Determine which icon to display based on channel type
let iconToDisplay = <GlobeIcon className='channel-icon'/>;
if (channel.type === Constants.PRIVATE_CHANNEL) {
iconToDisplay = <LockIcon className='channel-icon'/>;
}
if (isArchivedChannel(channel)) {
iconToDisplay = (
<ArchiveIcon
className='channel-icon'
data-testid={`${channel.name}-archive-icon`}
/>
);
}
const isMarkedForRemoval = channelsToRemove[channel.id] === channel;
// Determine the button text and action based on the channel state
const buttonClassName = `group-actions TeamList_editText${isMarkedForRemoval ? ' marked-for-removal' : ''}`;
const buttonText = isMarkedForRemoval ? (
<FormattedMessage
id='admin.access_control.policy.edit_policy.channel_selector.to_be_removed'
defaultMessage='To be removed'
/>
) : (
<FormattedMessage
id='admin.access_control.policy.edit_policy.channel_selector.remove'
defaultMessage='Remove'
/>
);
return {
cells: {
id: channel.id,
name: (
<div
className='ChannelList__nameColumn'
id={`channel-name-${channel.id}`}
>
{iconToDisplay}
<div className='ChannelList__nameText'>
<b id={`display-name-channel-${channel.id}`}>
{channel.display_name}
</b>
</div>
</div>
),
team: channel.team_display_name,
remove: (
<a
id={`remove-channel-${channel.id}`}
className={buttonClassName}
onClick={(e) => {
e.preventDefault();
this.removeChannel(channel);
}}
href='#'
>
{buttonText}
</a>
),
},
};
});
};
onSearch = (searchTerm: string) => {
this.props.actions.setChannelListSearch(searchTerm);
};
onFilter = (filterOptions: FilterOptions) => {
const filters: ChannelSearchOpts = {};
const {team_ids: teamIds} = filterOptions.teams.values;
if ((teamIds.value as string[]).length) {
filters.team_ids = teamIds.value as string[];
}
this.props.actions.setChannelListFilters(filters);
};
render() {
const rows: Row[] = this.getRows();
const columns: Column[] = this.getColumns();
const {startCount, endCount, total} = this.getPaginationProps();
const filterOptions: FilterOptions = {
teams: {
name: 'Teams',
values: {
team_ids: {
name: (
<FormattedMessage
id='admin.team_settings.title'
defaultMessage='Teams'
/>
),
value: [],
},
},
keys: ['team_ids'],
type: TeamFilterDropdown,
},
};
const filterProps = {
options: filterOptions,
keys: ['teams'],
onFilter: this.onFilter,
};
return (
<div className='AccessControlPolicyChannelsList'>
<DataGrid
columns={columns}
rows={rows}
loading={this.state.loading}
page={this.state.page}
nextPage={this.nextPage}
previousPage={this.previousPage}
startCount={startCount}
endCount={endCount}
total={total}
className={'customTable'}
onSearch={this.onSearch}
term={this.props.searchTerm}
filterProps={filterProps}
/>
</div>
);
}
}

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

@@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import memoize from 'memoize-one';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import type {Channel, ChannelSearchOpts, ChannelWithTeamData} from '@mattermost/types/channels';
import {searchAccessControlPolicyChannels as searchChannels} from 'mattermost-redux/actions/access_control';
import {searchChannelsInheritsPolicy, makeGetChannelsInAccessControlPolicy} from 'mattermost-redux/selectors/entities/access_control';
import {filterChannelList} from 'mattermost-redux/selectors/entities/channels';
import {filterChannelsMatchingTerm, channelListToMap} from 'mattermost-redux/utils/channel_utils';
import {setChannelListSearch, setChannelListFilters} from 'actions/views/search';
import type {GlobalState} from 'types/store';
import ChannelList from './channel_list';
type OwnProps = {
policyId?: string;
channelsToAdd: Record<string, ChannelWithTeamData>;
}
const EMPTY_FILTERS: ChannelSearchOpts = {};
const EMPTY_SEARCH_TERM = '';
function searchChannelsToAdd(channels: Record<string, Channel>, term: string, filters: ChannelSearchOpts): Record<string, Channel> {
const filteredChannels = filterChannelsMatchingTerm(Object.values(channels), term);
const filteredWithFilters = filterChannelList(filteredChannels, filters);
return channelListToMap(filteredWithFilters);
}
function makeMapStateToProps() {
const getPolicyChannels = makeGetChannelsInAccessControlPolicy();
const memoizedSearchChannelsToAdd = memoize(searchChannelsToAdd);
return (state: GlobalState, ownProps: OwnProps) => {
const {channelsToAdd, policyId} = ownProps;
const searchTerm = state.views.search.channelListSearch.term || EMPTY_SEARCH_TERM;
const filters = state.views.search.channelListSearch?.filters || EMPTY_FILTERS;
let channels: ChannelWithTeamData[] = [];
let totalCount = 0;
if (searchTerm || Object.keys(filters).length !== 0) {
channels = policyId ? searchChannelsInheritsPolicy(state, policyId, searchTerm, filters) as ChannelWithTeamData[] : [];
const filteredChannelsToAdd = memoizedSearchChannelsToAdd(channelsToAdd, searchTerm, filters) as Record<string, ChannelWithTeamData>;
totalCount = channels.length;
return {
channels,
totalCount,
searchTerm,
channelsToAdd: filteredChannelsToAdd,
filters,
};
}
channels = policyId ? getPolicyChannels(state, {policyId}) as ChannelWithTeamData[] : [];
totalCount = channels.length;
return {
channels,
totalCount,
searchTerm,
channelsToAdd,
filters,
};
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
searchChannels,
setChannelListSearch,
setChannelListFilters,
}, dispatch),
};
}
export default connect(makeMapStateToProps, mapDispatchToProps)(ChannelList);

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

@@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {getAccessControlPolicy as fetchPolicy, createAccessControlPolicy as createPolicy, deleteAccessControlPolicy as deletePolicy, searchAccessControlPolicyChannels as searchChannels, assignChannelsToAccessControlPolicy, unassignChannelsFromAccessControlPolicy, getAccessControlFields, updateAccessControlPolicyActive} from 'mattermost-redux/actions/access_control';
import {createJob} from 'mattermost-redux/actions/jobs';
import {getAccessControlPolicy as getPolicy} from 'mattermost-redux/selectors/entities/access_control';
import {setNavigationBlocked} from 'actions/admin_actions.jsx';
import type {GlobalState} from 'types/store';
import PolicyDetails from './policy_details';
type OwnProps = {
match: {
params: {
policy_id: string;
};
};
}
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const policyId = ownProps.match.params.policy_id;
const policy = getPolicy(state, policyId);
return {
policy,
policyId,
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
fetchPolicy,
createPolicy,
deletePolicy,
searchChannels,
assignChannelsToAccessControlPolicy,
unassignChannelsFromAccessControlPolicy,
setNavigationBlocked,
getAccessControlFields,
createJob,
updateAccessControlPolicyActive,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(PolicyDetails);

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

@@ -0,0 +1,28 @@
.AccessControlPolicySettings {
.form-group {
min-height: 40px;
}
.delete-policy {
.btn-primary:not(:disabled) {
background-color: var(--dnd-indicator);
}
}
.vertically-centered-label {
padding-top: 7px;
}
.EditPolicy__error {
display: flex;
align-items: center;
margin-left: 12px;
color: var(--error-text);
font-size: 14px;
i {
margin-right: 4px;
font-size: 16px;
}
}
}

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

@@ -0,0 +1,167 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import {act} from 'react-dom/test-utils';
import type {ChannelWithTeamData} from '@mattermost/types/channels';
import PolicyDetails from './policy_details';
jest.mock('utils/browser_history', () => ({
getHistory: () => ({
push: jest.fn(),
}),
}));
describe('components/admin_console/access_control/policy_details/PolicyDetails', () => {
const mockCreatePolicy = jest.fn();
const mockUpdatePolicy = jest.fn();
const mockDeletePolicy = jest.fn();
const mockSearchChannels = jest.fn();
const mockSetChannelListSearch = jest.fn();
const mockSetChannelListFilters = jest.fn();
const mockOnRemoveCallback = jest.fn();
const mockOnUndoRemoveCallback = jest.fn();
const mockOnAddCallback = jest.fn();
const mockFetchPolicy = jest.fn();
const mockSetNavigationBlocked = jest.fn();
const mockAssignChannelsToAccessControlPolicy = jest.fn();
const mockUnassignChannelsFromAccessControlPolicy = jest.fn();
const mockGetAccessControlExpressionAutocomplete = jest.fn();
const mockGetAccessControlFields = jest.fn();
const mockCreateJob = jest.fn();
const mockUpdateAccessControlPolicyActive = jest.fn();
const defaultProps = {
policyId: 'policy1',
channels: [
{id: 'channel1', name: 'Channel 1', display_name: 'Channel 1', team_display_name: 'Team 1', type: 'O'} as ChannelWithTeamData,
{id: 'channel2', name: 'channel2', display_name: 'Channel 2', team_display_name: 'Team 2', type: 'P'} as ChannelWithTeamData,
],
totalCount: 2,
searchTerm: '',
filters: {},
onRemoveCallback: mockOnRemoveCallback,
onUndoRemoveCallback: mockOnUndoRemoveCallback,
onAddCallback: mockOnAddCallback,
channelsToRemove: {},
channelsToAdd: {},
autocompleteResult: {entities: {}},
actions: {
createPolicy: mockCreatePolicy,
updatePolicy: mockUpdatePolicy,
deletePolicy: mockDeletePolicy,
searchChannels: mockSearchChannels,
setChannelListSearch: mockSetChannelListSearch,
setChannelListFilters: mockSetChannelListFilters,
fetchPolicy: mockFetchPolicy,
setNavigationBlocked: mockSetNavigationBlocked,
assignChannelsToAccessControlPolicy: mockAssignChannelsToAccessControlPolicy,
unassignChannelsFromAccessControlPolicy: mockUnassignChannelsFromAccessControlPolicy,
getAccessControlExpressionAutocomplete: mockGetAccessControlExpressionAutocomplete,
getAccessControlFields: mockGetAccessControlFields,
createJob: mockCreateJob,
updateAccessControlPolicyActive: mockUpdateAccessControlPolicyActive,
},
};
beforeEach(() => {
mockCreatePolicy.mockReset();
mockUpdatePolicy.mockReset();
mockDeletePolicy.mockReset();
mockSearchChannels.mockReset();
mockSetChannelListSearch.mockReset();
mockSetChannelListFilters.mockReset();
mockOnRemoveCallback.mockReset();
mockOnUndoRemoveCallback.mockReset();
mockOnAddCallback.mockReset();
mockFetchPolicy.mockReset();
mockSetNavigationBlocked.mockReset();
mockAssignChannelsToAccessControlPolicy.mockReset();
mockUnassignChannelsFromAccessControlPolicy.mockReset();
mockGetAccessControlExpressionAutocomplete.mockReset();
mockGetAccessControlFields.mockReset();
mockCreateJob.mockReset();
mockUpdateAccessControlPolicyActive.mockReset();
});
test('should match snapshot with new policy', () => {
const props = {
...defaultProps,
policyId: '',
};
const wrapper = shallow(<PolicyDetails {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with existing policy', () => {
const props = {
...defaultProps,
actions: {
...defaultProps.actions,
fetchPolicy: jest.fn().mockResolvedValue({
data: {
policy: {
id: 'policy1',
name: 'Policy 1',
rules: [{expression: 'true'}],
},
},
}),
},
};
const wrapper = shallow(<PolicyDetails {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should handle delete policy', async () => {
const props = {
...defaultProps,
policyId: 'policy1',
actions: {
...defaultProps.actions,
deletePolicy: mockDeletePolicy.mockResolvedValue({data: {}}),
},
};
const wrapper = shallow(<PolicyDetails {...props}/>);
// Find the delete-policy card
const deleteCard = wrapper.find('Card.delete-policy');
expect(deleteCard.exists()).toBe(true);
// Find the header with button inside the card
const deleteButtonHeader = deleteCard.find('TitleAndButtonCardHeader');
expect(deleteButtonHeader.exists()).toBe(true);
const onClickProp = deleteButtonHeader.props().onClick;
expect(onClickProp).toBeDefined();
// Click the delete button
await act(async () => {
if (onClickProp) {
await onClickProp({} as React.MouseEvent);
}
});
// Update the wrapper to see the modal
wrapper.update();
// Find the confirmation modal
const confirmationModal = wrapper.find('GenericModal');
expect(confirmationModal.exists()).toBe(true);
// Trigger the confirmation
await act(async () => {
// Get the handleConfirm prop with proper type assertion
const handleConfirm = (confirmationModal.props() as any).handleConfirm;
if (handleConfirm) {
await handleConfirm();
}
});
expect(mockDeletePolicy).toHaveBeenCalledWith('policy1');
});
});

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

@@ -0,0 +1,585 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import cloneDeep from 'lodash/cloneDeep';
import React, {useState, useEffect} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {GenericModal} from '@mattermost/components';
import type {AccessControlPolicy, AccessControlPolicyRule} from '@mattermost/types/access_control';
import type {ChannelSearchOpts, ChannelWithTeamData} from '@mattermost/types/channels';
import type {JobTypeBase} from '@mattermost/types/jobs';
import type {PropertyField} from '@mattermost/types/properties';
import type {ActionResult} from 'mattermost-redux/types/actions';
import BlockableLink from 'components/admin_console/blockable_link';
import BooleanSetting from 'components/admin_console/boolean_setting';
import Card from 'components/card/card';
import TitleAndButtonCardHeader from 'components/card/title_and_button_card_header/title_and_button_card_header';
import ChannelSelectorModal from 'components/channel_selector_modal';
import SaveButton from 'components/save_button';
import AdminHeader from 'components/widgets/admin_console/admin_header';
import TextSetting from 'components/widgets/settings/text_setting';
import {getHistory} from 'utils/browser_history';
import {JobTypes} from 'utils/constants';
import ChannelList from './channel_list';
import CELEditor from '../editors/cel_editor/editor';
import TableEditor from '../editors/table_editor/table_editor';
import PolicyConfirmationModal from '../modals/confirmation/confirmation_modal';
import './policy_details.scss';
const DEFAULT_PAGE_SIZE = 10;
interface PolicyActions {
fetchPolicy: (id: string) => Promise<ActionResult>;
createPolicy: (policy: AccessControlPolicy) => Promise<ActionResult>;
deletePolicy: (id: string) => Promise<ActionResult>;
searchChannels: (id: string, term: string, opts: ChannelSearchOpts) => Promise<ActionResult>;
setNavigationBlocked: (blocked: boolean) => void;
assignChannelsToAccessControlPolicy: (policyId: string, channelIds: string[]) => Promise<ActionResult>;
unassignChannelsFromAccessControlPolicy: (policyId: string, channelIds: string[]) => Promise<ActionResult>;
getAccessControlFields: (after: string, limit: number) => Promise<ActionResult>;
createJob: (job: JobTypeBase & { data: any }) => Promise<ActionResult>;
updateAccessControlPolicyActive: (policyId: string, active: boolean) => Promise<ActionResult>;
}
export interface PolicyDetailsProps {
policy?: AccessControlPolicy;
policyId?: string;
actions: PolicyActions;
}
interface ChannelChanges {
removed: Record<string, ChannelWithTeamData>;
added: Record<string, ChannelWithTeamData>;
removedCount: number;
}
function PolicyDetails({
policy,
policyId,
actions,
}: PolicyDetailsProps): JSX.Element {
const [policyName, setPolicyName] = useState(policy?.name || '');
const [expression, setExpression] = useState(policy?.rules?.[0]?.expression || '');
const [autoSyncMembership, setAutoSyncMembership] = useState(policy?.active || false);
const [serverError, setServerError] = useState<string | undefined>(undefined);
const [addChannelOpen, setAddChannelOpen] = useState(false);
const [editorMode, setEditorMode] = useState<'cel' | 'table'>('table');
const [channelChanges, setChannelChanges] = useState<ChannelChanges>({
removed: {},
added: {},
removedCount: 0,
});
const [saveNeeded, setSaveNeeded] = useState(false);
const [channelsCount, setChannelsCount] = useState(0);
const [autocompleteResult, setAutocompleteResult] = useState<PropertyField[]>([]);
const [showConfirmationModal, setShowConfirmationModal] = useState(false);
const [showDeleteConfirmationModal, setShowDeleteConfirmationModal] = useState(false);
const {formatMessage} = useIntl();
useEffect(() => {
loadPage();
}, [policyId]);
// Check if expression is simple enough for table mode
const isSimpleExpression = (expr: string): boolean => {
if (!expr) {
return true;
}
// Expression is simple if it only contains user.attributes.X == "Y" or user.attributes.X in ["Y", "Z"]
// or user.attributes.X.startsWith/endsWith/contains("Y")
return expr.split('&&').every((condition) => {
const trimmed = condition.trim();
return trimmed.match(/^user\.attributes\.\w+\s*(==|!=)\s*['"][^'"]+['"]$/) ||
trimmed.match(/^user\.attributes\.\w+\s+in\s+\[.*?\]$/) ||
trimmed.match(/^user\.attributes\.\w+\.startsWith\(['"][^'"]+['"].*?\)$/) ||
trimmed.match(/^user\.attributes\.\w+\.endsWith\(['"][^'"]+['"].*?\)$/) ||
trimmed.match(/^user\.attributes\.\w+\.contains\(['"][^'"]+['"].*?\)$/);
});
};
const loadPage = async () => {
// Fetch autocomplete fields first, as they are general and needed for both new and existing policies.
const fieldsPromise = actions.getAccessControlFields('', 100).then((result) => {
if (result.data) {
setAutocompleteResult(result.data);
}
});
if (policyId) {
// For existing policies, fetch policy details and channels
const policyPromise = actions.fetchPolicy(policyId).then((result) => {
setPolicyName(result.data?.name || '');
setExpression(result.data?.rules?.[0]?.expression || '');
setAutoSyncMembership(result.data?.active || false);
});
const channelsPromise = actions.searchChannels(policyId, '', {per_page: DEFAULT_PAGE_SIZE}).then((result) => {
setChannelsCount(result.data?.total_count || 0);
});
// Wait for all fetches for an existing policy
await Promise.all([fieldsPromise, policyPromise, channelsPromise]);
} else {
// For new policies, just ensure general fields are fetched.
// Policy name, expression, etc., are already initialized from props or defaults by useState.
await fieldsPromise;
}
};
const handleSubmit = async (apply = false) => {
let success = true;
let currentPolicyId = policyId;
// --- Step 1: Create/Update Policy ---
await actions.createPolicy({
id: currentPolicyId || '',
name: policyName,
rules: [{expression, actions: ['*']}] as AccessControlPolicyRule[],
type: 'parent',
version: 'v0.1',
}).then((result) => {
if (result.error) {
setServerError(result.error.message);
success = false;
return;
}
currentPolicyId = result.data?.id;
setPolicyName(result.data?.name || '');
setExpression(result.data?.rules?.[0]?.expression || '');
setAutoSyncMembership(result.data?.active || false);
});
if (!currentPolicyId || !success) {
return;
}
// --- Step 2: Update Policy Active ---
try {
await actions.updateAccessControlPolicyActive(currentPolicyId, autoSyncMembership);
} catch (error) {
setServerError(`Error updating policy active status: ${error.message}`);
success = false;
return;
}
// --- Step 3: Assign Channels ---
if (success) {
try {
if (channelChanges.removedCount > 0) {
await actions.unassignChannelsFromAccessControlPolicy(currentPolicyId, Object.keys(channelChanges.removed));
}
if (Object.keys(channelChanges.added).length > 0) {
await actions.assignChannelsToAccessControlPolicy(currentPolicyId, Object.keys(channelChanges.added));
}
setChannelChanges({removed: {}, added: {}, removedCount: 0});
} catch (error) {
setServerError(`Error assigning channels: ${error.message}`);
success = false;
return;
}
}
// --- Step 4: Create Job if necessary ---
if (apply) {
try {
const job: JobTypeBase & { data: any } = {
type: JobTypes.ACCESS_CONTROL_SYNC,
data: {parent_id: currentPolicyId},
};
await actions.createJob(job);
} catch (error) {
setServerError(`Error creating job: ${error.message}`);
success = false;
return;
}
}
// --- Step 5: Navigate lastly ---
setSaveNeeded(false);
setShowConfirmationModal(false);
actions.setNavigationBlocked(false);
getHistory().push('/admin_console/user_management/attribute_based_access_control');
};
const handleDelete = async () => {
if (!policyId) {
return; // Should not happen if delete button is enabled correctly
}
let success = true;
// --- Step 1: Unassign Channels (if necessary) ---
if (channelChanges.removedCount > 0) {
try {
await actions.unassignChannelsFromAccessControlPolicy(policyId, Object.keys(channelChanges.removed));
} catch (error) {
setServerError(`Error unassigning channels: ${error.message}`);
success = false;
}
}
// --- Step 2: Delete Policy and Navigate ---
if (success) {
try {
await actions.deletePolicy(policyId);
} catch (error) {
setServerError(`Error deleting policy: ${error.message}`);
}
}
if (success) {
getHistory().push('/admin_console/user_management/attribute_based_access_control');
}
};
const handleChannelChanges = (channels: ChannelWithTeamData[], isAdding: boolean) => {
setChannelChanges((prev) => {
const newChanges = cloneDeep(prev);
channels.forEach((channel) => {
if (isAdding) {
if (newChanges.removed[channel.id]) {
delete newChanges.removed[channel.id];
newChanges.removedCount--;
} else {
newChanges.added[channel.id] = channel;
}
} else if (newChanges.added[channel.id]) {
delete newChanges.added[channel.id];
} else if (!newChanges.removed[channel.id]) {
newChanges.removedCount++;
newChanges.removed[channel.id] = channel;
}
});
return newChanges;
});
setSaveNeeded(true);
actions.setNavigationBlocked(true);
};
const handleUndoRemove = (channel: ChannelWithTeamData) => {
setChannelChanges((prev) => {
const newChanges = cloneDeep(prev);
if (newChanges.removed[channel.id]) {
delete newChanges.removed[channel.id];
newChanges.removedCount--;
}
return newChanges;
});
setSaveNeeded(true);
actions.setNavigationBlocked(true);
};
const hasChannels = () => {
// If there are channels on the server (minus any pending removals) or newly added channels
return (
(channelsCount > channelChanges.removedCount) ||
(Object.keys(channelChanges.added).length > 0)
);
};
return (
<div className='wrapper--fixed AccessControlPolicySettings'>
<AdminHeader withBackButton={true}>
<div>
<BlockableLink
to='/admin_console/user_management/attribute_based_access_control'
className='fa fa-angle-left back'
/>
<FormattedMessage
id='admin.access_control.policy.edit_policy.title'
defaultMessage='Edit Access Control Policy'
/>
</div>
</AdminHeader>
<div className='admin-console__wrapper'>
<div className='admin-console__content'>
<div className='admin-console__setting-group'>
<TextSetting
id='admin.access_control.policy.edit_policy.policyName'
label={
<FormattedMessage
id='admin.access_control.policy.edit_policy.policyName'
defaultMessage='Access control policy name:'
/>
}
value={policyName}
placeholder={formatMessage({
id: 'admin.access_control.policy.edit_policy.policyName.placeholder',
defaultMessage: 'Add a unique policy name',
})}
onChange={(_, value) => {
setPolicyName(value);
setSaveNeeded(true);
}}
labelClassName='col-sm-4 vertically-centered-label'
inputClassName='col-sm-8'
/>
<BooleanSetting
id='admin.access_control.policy.edit_policy.autoSyncMembership'
label={
<div className='vertically-centered-label'>
<FormattedMessage
id='admin.access_control.policy.edit_policy.autoSyncMembership'
defaultMessage='Auto-add members based on access rules:'
/>
</div>
}
value={autoSyncMembership}
onChange={(_, value) => {
setAutoSyncMembership(value);
setSaveNeeded(true);
}}
setByEnv={false}
helpText={
<FormattedMessage
id='admin.access_control.policy.edit_policy.autoSyncMembership.description'
defaultMessage='Users who match the attribute values configured below will be automatically added as new members. Regardless of this setting, users who later no longer match the configured attribute values will be removed from the channel after the next sync.'
/>
}
/>
</div>
<Card
expanded={true}
className={'console'}
>
<Card.Header>
<TitleAndButtonCardHeader
title={'Attribute based access rules'}
subtitle={'Select user attributes and values as rules to restrict channel membership.'}
buttonText={
editorMode === 'table' ? (
<FormattedMessage
id='admin.access_control.policy.edit_policy.switch_to_advanced'
defaultMessage='Switch to Advanced Mode'
/>
) : (
<FormattedMessage
id='admin.access_control.policy.edit_policy.switch_to_simple'
defaultMessage='Switch to Simple Mode'
/>
)
}
onClick={() => setEditorMode(editorMode === 'table' ? 'cel' : 'table')}
isDisabled={editorMode === 'cel' && !isSimpleExpression(expression)}
tooltipText={
editorMode === 'cel' && !isSimpleExpression(expression) ?
'Complex expression detected. Simple expressions editor is not available at the moment.' :
undefined
}
/>
</Card.Header>
<Card.Body>
{editorMode === 'cel' ? (
<CELEditor
value={expression}
onChange={(value) => {
setExpression(value);
setSaveNeeded(true);
}}
onValidate={() => {}}
userAttributes={autocompleteResult.map((attr) => ({
attribute: attr.name,
values: [],
}))}
/>
) : (
<TableEditor
value={expression}
onChange={(value) => {
setExpression(value);
setSaveNeeded(true);
}}
onValidate={() => {}}
userAttributes={autocompleteResult.map((attr) => ({
attribute: attr.name,
values: [],
}))}
/>
)}
</Card.Body>
</Card>
<Card
expanded={true}
className={'console add-channels'}
>
<Card.Header>
<TitleAndButtonCardHeader
title={
<FormattedMessage
id='admin.access_control.policy.edit_policy.channel_selector.title'
defaultMessage='Assigned channels'
/>
}
subtitle={
<FormattedMessage
id='admin.access_control.policy.edit_policy.channel_selector.subtitle'
defaultMessage='Add channels that this property based access policy will apply to.'
/>
}
buttonText={
<FormattedMessage
id='admin.access_control.policy.edit_policy.channel_selector.addChannels'
defaultMessage='Add channels'
/>
}
onClick={() => setAddChannelOpen(true)}
/>
</Card.Header>
<Card.Body expanded={true}>
<ChannelList
onRemoveCallback={(channel) => handleChannelChanges([channel], false)}
onUndoRemoveCallback={handleUndoRemove}
channelsToRemove={channelChanges.removed}
channelsToAdd={channelChanges.added}
policyId={policyId}
/>
</Card.Body>
</Card>
{policyId && (
<Card
expanded={true}
className={'console delete-policy'}
>
<Card.Header>
<TitleAndButtonCardHeader
title={
<FormattedMessage
id='admin.access_control.policy.edit_policy.delete_policy.title'
defaultMessage='Delete policy'
/>
}
subtitle={
hasChannels() ? (
<FormattedMessage
id='admin.access_control.policy.edit_policy.delete_policy.subtitle.has_resources'
defaultMessage='Remove all assigned resources (eg. Channels) to be able to delete this policy'
/>
) : (
<FormattedMessage
id='admin.access_control.policy.edit_policy.delete_policy.subtitle'
defaultMessage='This policy will be deleted and cannot be recovered.'
/>
)
}
buttonText={
<FormattedMessage
id='admin.access_control.policy.edit_policy.delete_policy.delete'
defaultMessage='Delete'
/>
}
onClick={() => {
if (hasChannels()) {
return;
}
setShowDeleteConfirmationModal(true);
}}
isDisabled={hasChannels()}
/>
</Card.Header>
</Card>
)}
</div>
</div>
{addChannelOpen && (
<ChannelSelectorModal
onModalDismissed={() => setAddChannelOpen(false)}
onChannelsSelected={(channels) => handleChannelChanges(channels, true)}
groupID={''}
alreadySelected={Object.values(channelChanges.added).map((channel) => channel.id)}
excludeAccessControlPolicyEnforced={true}
excludeTypes={['O', 'D', 'G']}
/>
)}
{showConfirmationModal && (
<PolicyConfirmationModal
active={autoSyncMembership}
onExited={() => setShowConfirmationModal(false)}
onConfirm={handleSubmit}
channelsAffected={(channelsCount - channelChanges.removedCount) + Object.keys(channelChanges.added).length}
/>
)}
{showDeleteConfirmationModal && (
<GenericModal
onExited={() => setShowDeleteConfirmationModal(false)}
handleConfirm={handleDelete}
handleCancel={() => setShowDeleteConfirmationModal(false)}
modalHeaderText={
<FormattedMessage
id='admin.access_control.policy.edit_policy.delete_confirmation.title'
defaultMessage='Confirm Policy Deletion'
/>
}
confirmButtonText={
<FormattedMessage
id='admin.access_control.policy.edit_policy.delete_confirmation.confirm_button'
defaultMessage='Delete Policy'
/>
}
confirmButtonClassName='btn btn-danger'
isDeleteModal={true}
compassDesign={true}
>
<FormattedMessage
id='admin.access_control.policy.edit_policy.delete_confirmation.message'
defaultMessage='Are you sure you want to delete this policy? This action cannot be undone.'
/>
</GenericModal>
)}
<div className='admin-console-save'>
<SaveButton
disabled={!saveNeeded}
onClick={() => {
if (hasChannels()) {
setShowConfirmationModal(true);
} else {
handleSubmit();
}
}}
defaultMessage={
<FormattedMessage
id='admin.access_control.edit_policy.save'
defaultMessage='Save'
/>
}
/>
<BlockableLink
className='btn btn-quaternary'
to='/admin_console/user_management/attribute_based_access_control'
>
<FormattedMessage
id='admin.access_control.edit_policy.cancel'
defaultMessage='Cancel'
/>
</BlockableLink>
{serverError && (
<span className='EditPolicy__error'>
<i className='icon icon-alert-outline'/>
<FormattedMessage
id='admin.access_control.edit_policy.serverError'
defaultMessage='There are errors in the form above: {serverError}'
values={{serverError}}
/>
</span>
)}
</div>
</div>
);
}
export default PolicyDetails;

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

@@ -41,6 +41,9 @@ import {isCloudLicense} from 'utils/license_utils';
import {ID_PATH_PATTERN} from 'utils/path';
import {getSiteURL} from 'utils/url';
import PolicyList from './access_control';
import AccessControlPolicyJobs from './access_control/jobs';
import PolicyDetails from './access_control/policy_details';
import * as DefinitionConstants from './admin_definition_constants';
import AuditLoggingCertificateUploadSetting from './audit_logging';
import Audits from './audits';
@@ -77,6 +80,7 @@ import {
GroupsFeatureDiscovery,
MobileSecurityFeatureDiscovery,
} from './feature_discovery/features';
import AttributeBasedAccessControlFeatureDiscovery from './feature_discovery/features/attribute_based_access_control';
import FeatureFlags, {messages as featureFlagsMessages} from './feature_flags';
import GroupDetails from './group_settings/group_details';
import GroupSettings from './group_settings/group_settings';
@@ -664,6 +668,117 @@ const AdminDefinition: AdminDefinitionType = {
},
restrictedIndicator: getRestrictedIndicator(true, LicenseSkus.Enterprise),
},
access_control_policy_details_edit: {
url: `user_management/attribute_based_access_control/edit_policy/:policy_id(${ID_PATH_PATTERN})`,
isHidden: it.any(
it.configIsFalse('AccessControlSettings', 'EnableAttributeBasedAccessControl'),
it.not(it.licensedForSku(LicenseSkus.EnterpriseAdvanced)),
it.not(it.userHasReadPermissionOnResource(RESOURCE_KEYS.USER_MANAGEMENT.SYSTEM_ROLES)),
),
isDisabled: it.any(
it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.USER_MANAGEMENT.SYSTEM_ROLES)),
it.configIsFalse('FeatureFlags', 'AttributeBasedAccessControl'),
),
schema: {
id: 'AccessControlPolicy',
component: PolicyDetails,
},
},
access_control_policy_details: {
url: 'user_management/attribute_based_access_control/edit_policy',
isHidden: it.any(
it.configIsFalse('AccessControlSettings', 'EnableAttributeBasedAccessControl'),
it.not(it.licensedForSku(LicenseSkus.EnterpriseAdvanced)),
it.not(it.userHasReadPermissionOnResource(RESOURCE_KEYS.USER_MANAGEMENT.SYSTEM_ROLES)),
it.configIsFalse('FeatureFlags', 'AttributeBasedAccessControl'),
),
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.USER_MANAGEMENT.SYSTEM_ROLES)),
schema: {
id: 'AccessControlPolicy',
component: PolicyDetails,
},
},
attribute_based_access_control: {
url: 'user_management/attribute_based_access_control',
title: defineMessage({id: 'admin.sidebar.attributeBasedAccessControl', defaultMessage: 'Attribute-Based Access'}),
isHidden: it.any(
it.not(it.licensedForSku(LicenseSkus.EnterpriseAdvanced)),
it.not(it.userHasReadPermissionOnResource(RESOURCE_KEYS.USER_MANAGEMENT.SYSTEM_ROLES)),
it.configIsFalse('FeatureFlags', 'AttributeBasedAccessControl'),
),
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.USER_MANAGEMENT.SYSTEM_ROLES)),
schema: {
id: 'AttributeBasedAccessControl',
isBeta: true,
name: defineMessage({id: 'admin.accesscontrol.title', defaultMessage: 'Attribute-Based Access'}),
sections: [
{
key: 'admin.accesscontrol.settings',
settings: [
{
type: 'bool',
key: 'AccessControlSettings.EnableAttributeBasedAccessControl',
label: defineMessage({id: 'admin.accesscontrol.enableTitle', defaultMessage: 'Allow attribute based access controls on this server'}),
help_text: defineMessage({id: 'admin.accesscontrol.enableDesc', defaultMessage: 'Allow access restrictions based on user attributes using custom access policies'}),
},
],
},
{
key: 'admin.accesscontrol.policies',
isHidden: it.any(
it.configIsFalse('AccessControlSettings', 'EnableAttributeBasedAccessControl'),
it.stateIsFalse('AccessControlSettings.EnableAttributeBasedAccessControl'),
),
settings: [
{
type: 'custom',
component: PolicyList,
key: 'PolicyListPanel',
},
],
},
{
key: 'admin.accesscontrol.policyjobs',
isHidden: it.any(
it.configIsFalse('AccessControlSettings', 'EnableAttributeBasedAccessControl'),
it.stateIsFalse('AccessControlSettings.EnableAttributeBasedAccessControl'),
),
settings: [
{
type: 'custom',
component: AccessControlPolicyJobs,
key: 'AcessControlPolicyJobs',
},
],
},
],
},
restrictedIndicator: getRestrictedIndicator(false, LicenseSkus.EnterpriseAdvanced),
},
attribute_based_access_control_feature_discovery: {
url: 'user_management/attribute_based_access_control',
isDiscovery: true,
title: defineMessage({id: 'admin.sidebar.attributeBasedAccessControl', defaultMessage: 'Attribute-Based Access'}),
isHidden: it.any(
it.licensedForSku(LicenseSkus.EnterpriseAdvanced),
it.not(it.enterpriseReady),
it.configIsFalse('FeatureFlags', 'AttributeBasedAccessControl'),
),
schema: {
id: 'AttributeBasedAccessControl',
name: defineMessage({id: 'admin.accesscontrol.title', defaultMessage: 'Attribute-Based Access (Beta)'}),
settings: [
{
type: 'custom',
component: AttributeBasedAccessControlFeatureDiscovery,
key: 'AttributeBasedAccessControlFeatureDiscovery',
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ABOUT.EDITION_AND_LICENSE)),
},
],
},
restrictedIndicator: getRestrictedIndicator(true, LicenseSkus.EnterpriseAdvanced),
},
},
},
environment: {

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

@@ -1496,6 +1496,30 @@ exports[`components/AdminSidebar should match snapshot with license with enterpr
/>
}
/>
<AdminSidebarSection
definitionKey="user_management.attribute_based_access_control_feature_discovery"
key="user_management.attribute_based_access_control_feature_discovery"
name="user_management/attribute_based_access_control"
restrictedIndicator={
<RestrictedIndicator
blocked={true}
minimumPlanRequiredForFeature="advanced"
tooltipMessageBlocked={
Object {
"defaultMessage": "This is {article} {minimumPlanRequiredForFeature} feature, available with an upgrade or free {trialLength}-day trial",
"id": "admin.sidebar.restricted_indicator.tooltip.message.blocked",
}
}
useModal={false}
/>
}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Attribute-Based Access"
id="admin.sidebar.attributeBasedAccessControl"
/>
}
/>
</AdminSidebarCategory>
<AdminSidebarCategory
definitionKey="environment"
@@ -2543,6 +2567,17 @@ exports[`components/AdminSidebar should match snapshot with license with enterpr
/>
}
/>
<AdminSidebarSection
definitionKey="user_management.attribute_based_access_control"
key="user_management.attribute_based_access_control"
name="user_management/attribute_based_access_control"
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Attribute-Based Access"
id="admin.sidebar.attributeBasedAccessControl"
/>
}
/>
</AdminSidebarCategory>
<AdminSidebarCategory
definitionKey="environment"

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

@@ -386,6 +386,7 @@ describe('components/AdminSidebar', () => {
Scope: 'scope',
} as Office365Settings,
FeatureFlags: {
AttributeBasedAccessControl: true,
CustomProfileAttributes: true,
CloudDedicatedExportUI: true,
CloudIPFiltering: true,
@@ -521,6 +522,7 @@ describe('components/AdminSidebar', () => {
Scope: 'scope',
} as Office365Settings,
FeatureFlags: {
AttributeBasedAccessControl: true,
CustomProfileAttributes: true,
CloudDedicatedExportUI: true,
CloudIPFiltering: true,

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

@@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {defineMessage} from 'react-intl';
import {LicenseSkus} from 'utils/constants';
import SystemRolesSVG from './images/system_roles_svg';
import FeatureDiscovery from '../index';
const AttributeBasedAccessControlFeatureDiscovery: React.FC = () => {
return (
<FeatureDiscovery
featureName='attribute_based_access_control'
minimumSKURequiredForFeature={LicenseSkus.EnterpriseAdvanced}
title={defineMessage({
id: 'admin.attribute_based_access_control_feature_discovery.title',
defaultMessage: 'Use attribute based access policies to control channel access with Mattermost Enterprise Advanced',
})}
copy={defineMessage({
id: 'admin.attribute_based_access_control_feature_discovery.desc',
defaultMessage: 'Create policies containing access rules based on user attributes and apply them to channels and other resources within Mattermost.',
})}
learnMoreURL='https://docs.mattermost.com/deployment/'
featureDiscoveryImage={
<SystemRolesSVG
width={294}
height={180}
/>
}
/>
);
};
export default AttributeBasedAccessControlFeatureDiscovery;

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

@@ -26,3 +26,18 @@
width: 30px;
}
}
.JobTable {
.clickable {
cursor: pointer;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
}
}
.DataGrid_footer {
padding: 8px;
border-top: solid 1px rgba(0, 0, 0, 0.1);
text-align: right;
}
}

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

@@ -6,8 +6,8 @@ import React from 'react';
import {FormattedMessage} from 'react-intl';
import JobCancelButton from './job_cancel_button';
import JobTable from './table';
import type {Props} from './table';
import JobTable from './table';
describe('components/admin_console/jobs/table', () => {
const createJobButtonText = (

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

@@ -9,6 +9,9 @@ import type {Job, JobType} from '@mattermost/types/jobs';
import type {ActionResult} from 'mattermost-redux/types/actions';
import NextIcon from 'components/widgets/icons/fa_next_icon';
import PreviousIcon from 'components/widgets/icons/fa_previous_icon';
import {JobTypes} from 'utils/constants';
import JobCancelButton from './job_cancel_button';
@@ -31,6 +34,8 @@ export type Props = {
createJobButtonText: React.ReactNode;
hideTable?: boolean;
jobData?: any;
onRowClick?: (job: Job) => void;
perPage?: number;
actions: {
getJobsByType: (jobType: JobType) => void;
cancelJob: (jobId: string) => Promise<ActionResult>;
@@ -38,9 +43,20 @@ export type Props = {
};
}
class JobTable extends React.PureComponent<Props> {
type State = {
currentPage: number;
}
class JobTable extends React.PureComponent<Props, State> {
interval: ReturnType<typeof setInterval>|null = null;
constructor(props: Props) {
super(props);
this.state = {
currentPage: 0,
};
}
componentDidMount() {
this.props.actions.getJobsByType(this.props.jobType);
this.interval = setInterval(this.reload, 15000);
@@ -84,24 +100,45 @@ class JobTable extends React.PureComponent<Props> {
this.reload();
};
handleNextPage = () => {
if (this.props.perPage) {
const totalPages = Math.ceil(this.props.jobs.length / this.props.perPage);
if (this.state.currentPage < totalPages) {
this.setState({currentPage: this.state.currentPage + 1});
}
}
};
handlePrevPage = () => {
if (this.state.currentPage > 0) {
this.setState({currentPage: this.state.currentPage - 1});
}
};
render() {
const {perPage} = this.props;
const {currentPage} = this.state;
let paginatedJobs = this.props.jobs;
let startIndex = 0;
let endIndex = this.props.jobs.length;
if (perPage) {
startIndex = currentPage * perPage;
endIndex = Math.min(startIndex + perPage, this.props.jobs.length);
paginatedJobs = this.props.jobs.slice(startIndex, endIndex);
}
const showFilesColumn = this.props.jobType === JobTypes.MESSAGE_EXPORT && this.props.downloadExportResults;
const items = this.props.jobs.map((job) => {
const hideDetailsColumn = this.props.jobType === JobTypes.ACCESS_CONTROL_SYNC;
const items = paginatedJobs.map((job) => {
return (
<tr
key={job.id}
onClick={this.props.onRowClick ? () => this.props.onRowClick!(job) : undefined}
className={this.props.onRowClick ? 'clickable' : ''}
>
<td className='cancel-button-field whitespace--nowrap text-center'>
<JobCancelButton
job={job}
onClick={this.handleCancelJob}
disabled={this.props.disabled}
/>
</td>
<td className='whitespace--nowrap'><JobStatus job={job}/></td>
{showFilesColumn &&
<td className='whitespace--nowrap'><JobDownloadLink job={job}/></td>
}
<td className='whitespace--nowrap'>
<JobFinishAt
status={job.status}
@@ -109,11 +146,68 @@ class JobTable extends React.PureComponent<Props> {
/>
</td>
<td className='whitespace--nowrap'><JobRunLength job={job}/></td>
<td>{this.getExtraInfoText(job)}</td>
{showFilesColumn &&
<td className='whitespace--nowrap'><JobDownloadLink job={job}/></td>
}
{!hideDetailsColumn && (
<td>{this.getExtraInfoText(job)}</td>
)}
<td className='cancel-button-field whitespace--nowrap text-center'>
<JobCancelButton
job={job}
onClick={this.handleCancelJob}
disabled={this.props.disabled}
/>
</td>
</tr>
);
});
const renderFooter = (): JSX.Element | null => {
let footer: JSX.Element | null = null;
if (perPage) {
const firstPage = startIndex <= 0;
const lastPage = endIndex >= this.props.jobs.length;
footer = (
<div className='DataGrid_footer'>
<div className='DataGrid_cell'>
<FormattedMessage
id='admin.data_grid.paginatorCount'
defaultMessage='{startCount, number} - {endCount, number} of {total, number}'
values={{
startCount: startIndex + 1,
endCount: endIndex,
total: this.props.jobs.length,
}}
/>
<button
type='button'
className={'btn btn-quaternary btn-icon btn-sm ml-2 prev ' + (firstPage ? 'disabled' : '')}
onClick={this.handlePrevPage}
disabled={firstPage}
aria-label={'Previous page'}
>
<PreviousIcon/>
</button>
<button
type='button'
className={'btn btn-quaternary btn-icon btn-sm next ' + (lastPage ? 'disabled' : '')}
onClick={this.handleNextPage}
disabled={lastPage}
aria-label={'Next page'}
>
<NextIcon/>
</button>
</div>
</div>
);
}
return footer;
};
return (
<div className={classNames('JobTable', 'job-table__panel', this.props.className)}>
<div className='job-table__create-button'>
@@ -143,21 +237,12 @@ class JobTable extends React.PureComponent<Props> {
>
<thead>
<tr>
<th className='cancel-button-field'/>
<th>
<FormattedMessage
id='admin.jobTable.headerStatus'
defaultMessage='Status'
/>
</th>
{showFilesColumn &&
<th>
<FormattedMessage
id='admin.jobTable.headerFiles'
defaultMessage='Files'
/>
</th>
}
<th>
<FormattedMessage
id='admin.jobTable.headerFinishAt'
@@ -170,18 +255,32 @@ class JobTable extends React.PureComponent<Props> {
defaultMessage='Run Time'
/>
</th>
<th colSpan={3}>
{showFilesColumn &&
<th>
<FormattedMessage
id='admin.jobTable.headerExtraInfo'
defaultMessage='Details'
id='admin.jobTable.headerFiles'
defaultMessage='Files'
/>
</th>
}
{!hideDetailsColumn && (
<th colSpan={3}>
<FormattedMessage
id='admin.jobTable.headerExtraInfo'
defaultMessage='Details'
/>
</th>
)}
<th className='cancel-button-field'/>
</tr>
</thead>
<tbody>
{items}
</tbody>
</table>
{perPage && this.props.jobs.length > 0 && (
renderFooter()
)}
</div>
}
</div>

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

@@ -29,3 +29,8 @@
.config-section:nth-child(1) {
margin-top: 0;
}
.admin-header-beta-badge {
margin-right: auto;
margin-inline-start: 8px;
}

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

@@ -32,6 +32,7 @@ import Markdown from 'components/markdown';
import SaveButton from 'components/save_button';
import AdminHeader from 'components/widgets/admin_console/admin_header';
import WarningIcon from 'components/widgets/icons/fa_warning_icon';
import BetaTag from 'components/widgets/tag/beta_tag';
import WithTooltip from 'components/with_tooltip';
import * as I18n from 'i18n/i18n.jsx';
@@ -39,7 +40,7 @@ import Constants from 'utils/constants';
import {mappingValueFromRoles, rolesFromMapping} from 'utils/policy_roles_adapter';
import Setting from './setting';
import type {AdminDefinitionSetting, AdminDefinitionSettingBanner, AdminDefinitionSettingDropdownOption, AdminDefinitionSubSectionSchema, ConsoleAccess} from './types';
import type {AdminDefinitionConfigSchemaSection, AdminDefinitionSetting, AdminDefinitionSettingBanner, AdminDefinitionSettingDropdownOption, AdminDefinitionSubSectionSchema, ConsoleAccess} from './types';
import './schema_admin_settings.scss';
@@ -330,10 +331,19 @@ export class SchemaAdminSettings extends React.PureComponent<Props, State> {
name = this.props.schema.name;
}
const betaBadge = this.props.schema.isBeta && (
<BetaTag
variant='default'
size='sm'
className='admin-header-beta-badge'
/>
);
if (typeof name === 'string') {
return (
<AdminHeader>
{name}
{betaBadge}
</AdminHeader>
);
}
@@ -343,6 +353,7 @@ export class SchemaAdminSettings extends React.PureComponent<Props, State> {
<FormattedMessage
{...name}
/>
{betaBadge}
</AdminHeader>
);
};
@@ -434,6 +445,13 @@ export class SchemaAdminSettings extends React.PureComponent<Props, State> {
return Boolean(setting.isHidden);
};
isSectionHidden = (section: AdminDefinitionConfigSchemaSection) => {
if (typeof section.isHidden === 'function') {
return section.isHidden(this.props.config, this.state, this.props.license);
}
return Boolean(section.isHidden);
};
buildButtonSetting = (setting: AdminDefinitionSetting) => {
if (!this.props.schema || setting.type !== 'button') {
return (<></>);
@@ -1058,6 +1076,10 @@ export class SchemaAdminSettings extends React.PureComponent<Props, State> {
const sections: React.ReactNode[] = [];
schema.sections.forEach((section) => {
if (this.isSectionHidden(section)) {
return;
}
const settingsList: React.ReactNode[] = [];
if (section.settings) {
section.settings.forEach((setting) => {

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

@@ -125,12 +125,15 @@ exports[`admin_console/team_channel_settings/channel/ChannelDetails should match
toPublic={true}
/>
<ChannelModes
abacSupported={true}
groupsSupported={true}
isDefault={false}
isDisabled={false}
isPublic={true}
isSynced={false}
onToggle={[Function]}
policyEnforced={false}
policyEnforcedToggleAvailable={true}
/>
<ChannelGroups
channel={
@@ -307,12 +310,15 @@ exports[`admin_console/team_channel_settings/channel/ChannelDetails should match
toPublic={true}
/>
<ChannelModes
abacSupported={true}
groupsSupported={true}
isDefault={false}
isDisabled={false}
isPublic={true}
isSynced={false}
onToggle={[Function]}
policyEnforced={false}
policyEnforcedToggleAvailable={true}
/>
<ChannelGroups
channel={
@@ -508,12 +514,15 @@ exports[`admin_console/team_channel_settings/channel/ChannelDetails should match
toPublic={true}
/>
<ChannelModes
abacSupported={true}
groupsSupported={false}
isDefault={false}
isDisabled={false}
isPublic={true}
isSynced={false}
onToggle={[Function]}
policyEnforced={false}
policyEnforcedToggleAvailable={true}
/>
<Connect(ChannelMembers)
channelId="123"
@@ -642,12 +651,15 @@ exports[`admin_console/team_channel_settings/channel/ChannelDetails should match
toPublic={true}
/>
<ChannelModes
abacSupported={true}
groupsSupported={false}
isDefault={false}
isDisabled={false}
isPublic={true}
isSynced={false}
onToggle={[Function]}
policyEnforced={false}
policyEnforcedToggleAvailable={true}
/>
<Connect(ChannelMembers)
channelId="123"
@@ -795,12 +807,15 @@ exports[`admin_console/team_channel_settings/channel/ChannelDetails should match
toPublic={true}
/>
<ChannelModes
abacSupported={false}
groupsSupported={false}
isDefault={false}
isDisabled={false}
isPublic={true}
isSynced={false}
onToggle={[Function]}
policyEnforced={false}
policyEnforcedToggleAvailable={true}
/>
<Connect(ChannelMembers)
channelId="123"
@@ -929,12 +944,15 @@ exports[`admin_console/team_channel_settings/channel/ChannelDetails should match
toPublic={true}
/>
<ChannelModes
abacSupported={false}
groupsSupported={false}
isDefault={false}
isDisabled={false}
isPublic={true}
isSynced={false}
onToggle={[Function]}
policyEnforced={false}
policyEnforcedToggleAvailable={true}
/>
<Connect(ChannelMembers)
channelId="123"

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

@@ -28,6 +28,8 @@ exports[`admin_console/team_channel_settings/channel/ChannelModes should match s
isPublic={true}
isSynced={false}
onToggle={[MockFunction]}
policyEnforced={false}
policyEnforcedToggleAvailable={false}
/>
</div>
</div>
@@ -62,6 +64,8 @@ exports[`admin_console/team_channel_settings/channel/ChannelModes should match s
isPublic={true}
isSynced={false}
onToggle={[MockFunction]}
policyEnforced={false}
policyEnforcedToggleAvailable={false}
/>
<AllowAllToggle
isDefault={false}
@@ -69,6 +73,8 @@ exports[`admin_console/team_channel_settings/channel/ChannelModes should match s
isPublic={true}
isSynced={false}
onToggle={[MockFunction]}
policyEnforced={false}
policyEnforcedToggleAvailable={false}
/>
</div>
</div>

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

@@ -0,0 +1,80 @@
.policy-table-container {
width: 100%;
margin-block: -20px;
.policy-table {
width: 100%;
margin-bottom: 0;
background-color: var(--center-channel-bg);
border-collapse: collapse;
th, td {
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
line-height: 20px;
padding-block: 16px;
text-align: left;
white-space: nowrap;
}
thead tr {
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
}
th {
background-color: var(--center-channel-bg);
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600;
}
td {
background-color: var(--center-channel-bg);
color: var(--center-channel-color);
font-size: 14px;
&.policy-name {
font-weight: 600;
}
}
tbody tr:last-child td {
border-bottom: none;
}
.text-right {
width: 50px;
padding-right: 16px;
text-align: right;
}
.policy-edit-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: rgba(var(--center-channel-color-rgb), 0.72);
text-decoration: none;
&:hover {
color: rgba(var(--center-channel-color-rgb), 0.9);
}
i {
font-size: 16px;
}
}
}
}
// Style for the Remove policy button
#channel_access_control_with_policy {
.btn.btn-primary {
border-color: var(--dnd-indicator);
background-color: var(--dnd-indicator);
color: var(--button-color);
&:hover, &:active, &:focus {
border-color: rgba(var(--dnd-indicator-rgb), 0.88);
background-color: rgba(var(--dnd-indicator-rgb), 0.88);
}
}
}

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

@@ -0,0 +1,148 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import {FormattedMessage, defineMessage, useIntl} from 'react-intl';
import {Link} from 'react-router-dom';
import type {AccessControlPolicy} from '@mattermost/types/access_control';
import type {ActionResult} from 'mattermost-redux/types/actions';
import PolicySelectionModal from 'components/admin_console/access_control/modals/policy_selection/policy_selection_modal';
import AdminPanelWithButton from 'components/widgets/admin_console/admin_panel_with_button';
import './channel_access_control_policy.scss';
interface Props {
accessControlPolicies: AccessControlPolicy[];
actions: {
searchPolicies: (term: string, type: string, after: string, limit: number) => Promise<ActionResult>;
onPolicySelected?: (policy: AccessControlPolicy) => void;
onPolicyRemoved: () => void;
};
}
export const ChannelAccessControl: React.FC<Props> = (props: Props): JSX.Element => {
const {accessControlPolicies, actions} = props;
const [showPolicySelectionModal, setShowPolicySelectionModal] = useState<boolean>(false);
const intl = useIntl();
const handlePolicySelected = (policy: AccessControlPolicy) => {
if (actions.onPolicySelected && policy) {
actions.onPolicySelected(policy);
}
setShowPolicySelectionModal(false);
};
const handleClosePolicyModal = () => {
setShowPolicySelectionModal(false);
};
const handleOpenPolicyModal = () => {
setShowPolicySelectionModal(true);
};
const renderTable = () => {
if (!accessControlPolicies || accessControlPolicies.length === 0) {
return null;
}
return (
<div className='policy-table-container'>
<table className='policy-table'>
<thead>
<tr>
<th>
<FormattedMessage
id='admin.channel_settings.channel_detail.access_control_policy_name'
defaultMessage='Name'
/>
</th>
<th className='text-right'>
<span className='sr-only'>
<FormattedMessage
id='admin.channel_settings.channel_detail.access_control_policy_actions'
defaultMessage='Actions'
/>
</span>
</th>
</tr>
</thead>
<tbody>
{accessControlPolicies.map((policy) => (
<tr key={policy.id}>
<td className='policy-name'>{policy.name}</td>
<td className='text-right'>
<Link
to={'/admin_console/user_management/attribute_based_access_control/edit_policy/' + policy.id}
className='policy-edit-icon'
aria-label={intl.formatMessage({
id: 'admin.channel_settings.channel_detail.go_to_policy.aria_label',
defaultMessage: 'Go to the policy',
})}
>
<i className='fa fa-external-link'/>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
// Attribute based access is enabled, but no policy
if (accessControlPolicies.length === 0) {
return (
<AdminPanelWithButton
id='channel_access_control_policy'
title={defineMessage({id: 'admin.channel_settings.channel_detail.access_control_policy_title', defaultMessage: 'Access policy'})}
subtitle={defineMessage({id: 'admin.channel_settings.channel_detail.access_control_policy_description', defaultMessage: 'Select an access policy for this channel to restrict membership.'})}
buttonText={defineMessage({id: 'admin.channel_settings.channel_detail.link_policy', defaultMessage: 'Link to a policy'})}
onButtonClick={() => {
handleOpenPolicyModal();
}}
>
<PolicySelectionModal
show={showPolicySelectionModal}
onHide={handleClosePolicyModal}
onPolicySelected={handlePolicySelected}
actions={{
searchPolicies: actions.searchPolicies,
}}
/>
</AdminPanelWithButton>
);
}
return (
<AdminPanelWithButton
id='channel_access_control_with_policy'
title={defineMessage({id: 'admin.channel_settings.channel_detail.access_control_policy_title', defaultMessage: 'Access Policy'})}
subtitle={defineMessage({id: 'admin.channel_settings.channel_detail.policy_following', defaultMessage: 'This channel is currently using the following access policy.'})}
buttonText={defineMessage({id: 'admin.channel_settings.channel_detail.remove_policy', defaultMessage: 'Remove policy'})}
onButtonClick={() => {
actions.onPolicyRemoved();
}}
>
<div className='group-teams-and-channels'>
<div className='group-teams-and-channels--body'>
<div className='access-policy-container'>
{!accessControlPolicies && (
<div className='access-policy-description'>
<FormattedMessage
id='admin.channel_settings.channel_detail.select_policy'
defaultMessage='Select an access policy for this channel to restrict membership'
/>
</div>
)}
{renderTable()}
</div>
</div>
</div>
</AdminPanelWithButton>
);
};

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

@@ -93,6 +93,10 @@ describe('admin_console/team_channel_settings/channel/ChannelDetails', () => {
updateChannelMemberSchemeRoles: jest.fn(),
deleteChannel: jest.fn(),
unarchiveChannel: jest.fn(),
getAccessControlPolicy: jest.fn(),
deleteAccessControlPolicy: jest.fn(),
assignChannelToAccessControlPolicy: jest.fn(),
searchPolicies: jest.fn(),
};
const additionalProps = {
@@ -100,6 +104,7 @@ describe('admin_console/team_channel_settings/channel/ChannelDetails', () => {
guestAccountsEnabled: true,
channelModerationEnabled: true,
channelGroupsEnabled: true,
abacSupported: true,
isDisabled: false,
};
@@ -217,6 +222,10 @@ describe('admin_console/team_channel_settings/channel/ChannelDetails', () => {
updateChannelMemberSchemeRoles: jest.fn(),
deleteChannel: jest.fn(),
unarchiveChannel: jest.fn(),
getAccessControlPolicy: jest.fn(),
deleteAccessControlPolicy: jest.fn(),
assignChannelToAccessControlPolicy: jest.fn(),
searchPolicies: jest.fn(),
};
const additionalProps = {
@@ -225,6 +234,7 @@ describe('admin_console/team_channel_settings/channel/ChannelDetails', () => {
channelModerationEnabled: true,
channelGroupsEnabled: false,
isDisabled: false,
abacSupported: false,
};
if (!testChannel.id) {
@@ -342,6 +352,10 @@ describe('admin_console/team_channel_settings/channel/ChannelDetails', () => {
updateChannelMemberSchemeRoles: jest.fn(),
deleteChannel: jest.fn(),
unarchiveChannel: jest.fn(),
getAccessControlPolicy: jest.fn(),
deleteAccessControlPolicy: jest.fn(),
assignChannelToAccessControlPolicy: jest.fn(),
searchPolicies: jest.fn(),
};
const additionalProps = {
@@ -350,6 +364,7 @@ describe('admin_console/team_channel_settings/channel/ChannelDetails', () => {
channelModerationEnabled: true,
channelGroupsEnabled: false,
isDisabled: false,
abacSupported: true,
};
if (!testChannel.id) {

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

@@ -5,6 +5,7 @@ import cloneDeep from 'lodash/cloneDeep';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import type {AccessControlPolicy} from '@mattermost/types/access_control';
import type {Channel, ChannelModeration as ChannelPermissions, ChannelModerationPatch} from '@mattermost/types/channels';
import {SyncableType} from '@mattermost/types/groups';
import type {SyncablePatch, Group} from '@mattermost/types/groups';
@@ -25,6 +26,7 @@ import AdminHeader from 'components/widgets/admin_console/admin_header';
import {getHistory} from 'utils/browser_history';
import Constants from 'utils/constants';
import {ChannelAccessControl} from './channel_access_control_policy';
import {ChannelGroups} from './channel_groups';
import ChannelMembers from './channel_members';
import ChannelModeration from './channel_moderation';
@@ -50,6 +52,7 @@ export interface ChannelDetailsProps {
guestAccountsEnabled: boolean;
channelModerationEnabled: boolean;
channelGroupsEnabled: boolean;
abacSupported: boolean;
isDisabled?: boolean;
actions: ChannelDetailsActions;
}
@@ -81,6 +84,9 @@ interface ChannelDetailsState {
teamScheme?: Scheme;
isLocalArchived: boolean;
showArchiveConfirmModal: boolean;
policyToggled: boolean;
accessControlPolicies: AccessControlPolicy[];
abacSupported: boolean;
}
export type ChannelDetailsActions = {
@@ -102,6 +108,10 @@ export type ChannelDetailsActions = {
updateChannelMemberSchemeRoles: (channelId: string, userId: string, isSchemeUser: boolean, isSchemeAdmin: boolean) => Promise<ActionResult>;
deleteChannel: (channelId: string) => Promise<ActionResult>;
unarchiveChannel: (channelId: string) => Promise<ActionResult>;
getAccessControlPolicy: (channelId: string) => Promise<ActionResult>;
searchPolicies: (term: string, type: string, after: string, limit: number) => Promise<ActionResult>;
assignChannelToAccessControlPolicy: (policyId: string, channelId: string) => Promise<ActionResult>;
deleteAccessControlPolicy: (policyId: string) => Promise<ActionResult>;
};
export default class ChannelDetails extends React.PureComponent<ChannelDetailsProps, ChannelDetailsState> {
@@ -129,6 +139,9 @@ export default class ChannelDetails extends React.PureComponent<ChannelDetailsPr
teamScheme: props.teamScheme,
isLocalArchived: props.channel?.delete_at !== 0,
showArchiveConfirmModal: false,
policyToggled: false,
accessControlPolicies: [],
abacSupported: props.abacSupported,
};
}
@@ -141,6 +154,7 @@ export default class ChannelDetails extends React.PureComponent<ChannelDetailsPr
isPublic: channel?.type === Constants.OPEN_CHANNEL,
isDefault: channel?.name === Constants.DEFAULT_CHANNEL,
isLocalArchived: channel?.delete_at !== 0,
policyToggled: channel?.policy_enforced || false,
});
}
@@ -165,6 +179,11 @@ export default class ChannelDetails extends React.PureComponent<ChannelDetailsPr
actions.getChannelModerations(channelID).then(() => this.restrictChannelMentions());
}
actions.getChannel(channelID);
if (channel?.policy_enforced) {
this.fetchAccessControlPolicies(channelID);
this.setState({policyToggled: true});
}
}
if (channel?.team_id) {
@@ -204,7 +223,7 @@ export default class ChannelDetails extends React.PureComponent<ChannelDetailsPr
this.setState({channelPermissions});
}
private setToggles = (isSynced: boolean, isPublic: boolean) => {
private setToggles = (isSynced: boolean, isPublic: boolean, policyEnforced: boolean) => {
const {channel} = this.props;
const isOriginallyPublic = channel?.type === Constants.OPEN_CHANNEL;
this.setState(
@@ -213,6 +232,7 @@ export default class ChannelDetails extends React.PureComponent<ChannelDetailsPr
isSynced,
isPublic,
isPrivacyChanging: isPublic !== isOriginallyPublic,
policyToggled: policyEnforced,
},
() => this.processGroupsChange(this.state.groups),
);
@@ -387,7 +407,7 @@ export default class ChannelDetails extends React.PureComponent<ChannelDetailsPr
}
this.setState({showConvertConfirmModal: false, showRemoveConfirmModal: false, showConvertAndRemoveConfirmModal: false, showArchiveConfirmModal: false, saving: true});
const {groups, isSynced, isPublic, isPrivacyChanging, channelPermissions, usersToAdd, usersToRemove, rolesToUpdate} = this.state;
const {groups, isSynced, isPublic, isPrivacyChanging, channelPermissions, usersToAdd, usersToRemove, rolesToUpdate, policyToggled, accessControlPolicies} = this.state;
let serverError: JSX.Element | undefined;
let saveNeeded = false;
@@ -527,6 +547,59 @@ export default class ChannelDetails extends React.PureComponent<ChannelDetailsPr
this.restrictChannelMentions();
}
if (policyToggled) {
if (isPublic) {
serverError = (
<FormError
error={
<FormattedMessage
id='admin.channel_details.policy_public_error'
defaultMessage='You cannot assign a policy to a public channel.'
/>}
/>
);
saveNeeded = true;
this.setState({serverError, saving: false, saveNeeded});
actions.setNavigationBlocked(saveNeeded);
return;
}
if (isSynced) {
serverError = (
<FormError
error={
<FormattedMessage
id='admin.channel_details.policy_synced_error'
defaultMessage='You cannot assign a policy to a synced channel.'
/>}
/>
);
saveNeeded = true;
this.setState({serverError, saving: false, saveNeeded});
actions.setNavigationBlocked(saveNeeded);
return;
}
}
if (accessControlPolicies.length > 0) {
await actions.assignChannelToAccessControlPolicy(accessControlPolicies[0].id, channelID).catch((error) => {
this.setState({
serverError: <FormError error={error.message}/>,
saving: false,
saveNeeded: true,
});
actions.setNavigationBlocked(true);
});
} else {
await actions.deleteAccessControlPolicy(channelID).catch((error) => {
this.setState({
serverError: <FormError error={error.message}/>,
saving: false,
saveNeeded: true,
});
});
}
let privacyChanging = isPrivacyChanging;
if (serverError == null) {
privacyChanging = false;
@@ -691,6 +764,61 @@ export default class ChannelDetails extends React.PureComponent<ChannelDetailsPr
this.setState(newState);
};
private onPolicySelected = (policy: AccessControlPolicy) => {
this.setState({
accessControlPolicies: [policy],
saveNeeded: true,
});
this.props.actions.setNavigationBlocked(true);
};
private onPolicyRemoved = () => {
this.setState({
accessControlPolicies: [],
saveNeeded: true,
});
this.props.actions.setNavigationBlocked(true);
};
private fetchAccessControlPolicies = (channelId: string) => {
if (!channelId) {
return;
}
// Always try to fetch policies, even if policy_enforced is false
// This ensures we have policies when toggling the flag
this.props.actions.getAccessControlPolicy(channelId).then((result) => {
if (result.data) {
const currentAccessControlPolicy = result.data;
const policies: AccessControlPolicy[] = [];
const promises: Array<Promise<any>> = [];
if (currentAccessControlPolicy.imports && currentAccessControlPolicy.imports.length > 0) {
for (const policyId of currentAccessControlPolicy.imports) {
const promise = this.props.actions.getAccessControlPolicy(policyId).then((policyResult) => {
if (policyResult.data) {
policies.push(policyResult.data as AccessControlPolicy);
}
});
promises.push(promise);
}
Promise.all(promises).then(() => {
this.setState({
accessControlPolicies: policies,
});
});
} else {
// If there are no imports, still update state with empty array
this.setState({
accessControlPolicies: [],
});
}
}
});
};
public render = () => {
const {
totalGroups,
@@ -711,6 +839,8 @@ export default class ChannelDetails extends React.PureComponent<ChannelDetailsPr
usersToAdd,
isLocalArchived,
showArchiveConfirmModal,
policyToggled,
accessControlPolicies,
} = this.state;
const {channel, team} = this.props;
@@ -766,9 +896,23 @@ export default class ChannelDetails extends React.PureComponent<ChannelDetailsPr
onToggle={this.setToggles}
isDisabled={this.props.isDisabled}
groupsSupported={this.props.channelGroupsEnabled}
abacSupported={this.props.abacSupported}
policyEnforced={policyToggled}
policyEnforcedToggleAvailable={accessControlPolicies.length === 0}
/>
{this.props.channelGroupsEnabled &&
{this.props.abacSupported && policyToggled && (
<ChannelAccessControl
accessControlPolicies={this.state.accessControlPolicies}
actions={{
onPolicySelected: this.onPolicySelected,
onPolicyRemoved: this.onPolicyRemoved,
searchPolicies: this.props.actions.searchPolicies,
}}
/>
)}
{this.props.channelGroupsEnabled && !policyToggled &&
<ChannelGroups
synced={isSynced}
channel={channel}

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

@@ -16,6 +16,8 @@ describe('admin_console/team_channel_settings/channel/ChannelModes', () => {
isDefault={false}
isDisabled={false}
groupsSupported={true}
policyEnforced={false}
policyEnforcedToggleAvailable={false}
/>,
);
expect(wrapper).toMatchSnapshot();
@@ -30,6 +32,8 @@ describe('admin_console/team_channel_settings/channel/ChannelModes', () => {
isDefault={false}
isDisabled={false}
groupsSupported={false}
policyEnforced={false}
policyEnforcedToggleAvailable={false}
/>,
);
expect(wrapper).toMatchSnapshot();

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

@@ -13,24 +13,27 @@ interface Props {
isPublic: boolean;
isSynced: boolean;
isDefault: boolean;
onToggle: (isSynced: boolean, isPublic: boolean) => void;
onToggle: (isSynced: boolean, isPublic: boolean, policyEnforced: boolean) => void;
isDisabled?: boolean;
groupsSupported?: boolean;
abacSupported?: boolean;
policyEnforced: boolean;
policyEnforcedToggleAvailable: boolean;
}
const SyncGroupsToggle: React.SFC<Props> = (props: Props): JSX.Element => {
const {isPublic, isSynced, isDefault, onToggle, isDisabled} = props;
const {isPublic, isSynced, isDefault, onToggle, isDisabled, policyEnforced} = props;
return (
<LineSwitch
id='syncGroupSwitch'
disabled={isDisabled || isDefault}
disabled={isDisabled || isDefault || policyEnforced}
toggled={isSynced}
last={isSynced}
onToggle={() => {
if (isDefault) {
return;
}
onToggle(!isSynced, isPublic);
onToggle(!isSynced, isPublic, policyEnforced);
}}
title={(
<FormattedMessage
@@ -59,21 +62,21 @@ const SyncGroupsToggle: React.SFC<Props> = (props: Props): JSX.Element => {
};
const AllowAllToggle: React.SFC<Props> = (props: Props): JSX.Element | null => {
const {isPublic, isSynced, isDefault, onToggle, isDisabled} = props;
const {isPublic, isSynced, isDefault, onToggle, isDisabled, policyEnforced} = props;
if (isSynced) {
return null;
}
return (
<LineSwitch
id='allow-all-toggle'
disabled={isDisabled || isDefault}
disabled={isDisabled || isDefault || policyEnforced}
toggled={isPublic}
last={true}
last={false}
onToggle={() => {
if (isDefault) {
return;
}
onToggle(isSynced, !isPublic);
onToggle(isSynced, !isPublic, policyEnforced);
}}
title={(
<FormattedMessage
@@ -110,8 +113,47 @@ const AllowAllToggle: React.SFC<Props> = (props: Props): JSX.Element | null => {
);
};
const PolicyEnforceToggle: React.SFC<Props> = (props: Props): JSX.Element | null => {
const {isPublic, isSynced, isDefault, onToggle, isDisabled, policyEnforced, policyEnforcedToggleAvailable} = props;
if (isSynced) {
return null;
}
return (
<LineSwitch
id='policy-enforce-toggle'
disabled={isDisabled || isSynced || isPublic || !policyEnforcedToggleAvailable}
toggled={policyEnforced}
last={true}
onToggle={() => {
if (isDefault || !policyEnforcedToggleAvailable) {
return;
}
onToggle(isSynced, isPublic, !policyEnforced);
}}
title={(
<FormattedMessage
id='admin.channel_settings.channel_details.policy_enforced_title'
defaultMessage='Enable attribute based channel access'
/>
)}
subTitle={isDefault || isPublic ? (
<FormattedMessage
id='admin.channel_settings.channel_details.private_channel_only'
defaultMessage='Only private channels can be attribute based.'
/>
) : (
<FormattedMessage
id='admin.channel_settings.channel_details.attribute_based_description'
defaultMessage='Restrict which users can be invited to this channel based on their user attributes and values. Only people who match the specified conditions will be allowed to be selected and added to this channel.'
/>
)
}
/>
);
};
export const ChannelModes: React.SFC<Props> = (props: Props): JSX.Element => {
const {isPublic, isSynced, isDefault, onToggle, isDisabled, groupsSupported} = props;
const {isPublic, isSynced, isDefault, onToggle, isDisabled, groupsSupported, policyEnforced, policyEnforcedToggleAvailable, abacSupported} = props;
return (
<AdminPanel
id='channel_manage'
@@ -120,13 +162,15 @@ export const ChannelModes: React.SFC<Props> = (props: Props): JSX.Element => {
>
<div className='group-teams-and-channels'>
<div className='group-teams-and-channels--body'>
{groupsSupported &&
{!policyEnforced && groupsSupported &&
<SyncGroupsToggle
isPublic={isPublic}
isSynced={isSynced}
isDefault={isDefault}
onToggle={onToggle}
isDisabled={isDisabled}
policyEnforced={policyEnforced}
policyEnforcedToggleAvailable={policyEnforcedToggleAvailable}
/> }
<AllowAllToggle
isPublic={isPublic}
@@ -134,9 +178,23 @@ export const ChannelModes: React.SFC<Props> = (props: Props): JSX.Element => {
isDefault={isDefault}
onToggle={onToggle}
isDisabled={isDisabled}
policyEnforced={policyEnforced}
policyEnforcedToggleAvailable={policyEnforcedToggleAvailable}
/>
{abacSupported &&
<PolicyEnforceToggle
isPublic={isPublic}
isSynced={isSynced}
isDefault={isDefault}
onToggle={onToggle}
isDisabled={isDisabled}
policyEnforced={policyEnforced}
policyEnforcedToggleAvailable={policyEnforcedToggleAvailable}
/>
}
</div>
</div>
</AdminPanel>
);
};

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

@@ -7,6 +7,7 @@ import type {Dispatch} from 'redux';
import type {GlobalState} from '@mattermost/types/store';
import {getAccessControlPolicy, deleteAccessControlPolicy, assignChannelsToAccessControlPolicy, searchAccessControlPolicies} from 'mattermost-redux/actions/access_control';
import {
addChannelMember,
deleteChannel,
@@ -36,7 +37,7 @@ import {getTeam} from 'mattermost-redux/selectors/entities/teams';
import {setNavigationBlocked} from 'actions/admin_actions';
import {isMinimumEnterpriseLicense, isMinimumProfessionalLicense} from 'utils/license_utils';
import {isMinimumEnterpriseAdvancedLicense, isMinimumEnterpriseLicense, isMinimumProfessionalLicense} from 'utils/license_utils';
import ChannelDetails from './channel_details';
@@ -60,6 +61,8 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
// Channel Groups is only available for Enterprise and above
const channelGroupsEnabled = isLicensed && isMinimumEnterpriseLicense(license);
const abacSupported = isLicensed && isMinimumEnterpriseAdvancedLicense(license) && config.FeatureFlagAttributeBasedAccessControl === 'true';
const guestAccountsEnabled = config.EnableGuestAccounts === 'true';
const channelID = ownProps.match.params.channel_id;
const channel = getChannel(state, channelID);
@@ -81,10 +84,14 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
guestAccountsEnabled,
channelModerationEnabled,
channelGroupsEnabled,
abacSupported,
};
}
function mapDispatchToProps(dispatch: Dispatch) {
const assignChannelToAccessControlPolicy = (policyId: string, channelId: string) => {
return assignChannelsToAccessControlPolicy(policyId, [channelId]);
};
return {
actions: bindActionCreators({
getGroups: fetchAssociatedGroups,
@@ -105,6 +112,10 @@ function mapDispatchToProps(dispatch: Dispatch) {
updateChannelMemberSchemeRoles,
deleteChannel,
unarchiveChannel,
getAccessControlPolicy,
assignChannelToAccessControlPolicy,
deleteAccessControlPolicy,
searchPolicies: searchAccessControlPolicies,
}, dispatch),
};
}

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

@@ -86,9 +86,17 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
"keys": Array [
"group_constrained",
"exclude_group_constrained",
"access_control_policy_enforced",
],
"name": "Management",
"values": Object {
"access_control_policy_enforced": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Attribute Based"
id="admin.channel_list.attributed_based"
/>,
"value": false,
},
"exclude_group_constrained": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
@@ -161,10 +169,14 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
"management": <span
className="group-description adjusted row-content"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
<span
className="group-indicator channel-indicator channel-indicator--larger"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
</span>
</span>,
"name": <span
className="group-name overflow--ellipsis row-content"
@@ -287,9 +299,17 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
"keys": Array [
"group_constrained",
"exclude_group_constrained",
"access_control_policy_enforced",
],
"name": "Management",
"values": Object {
"access_control_policy_enforced": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Attribute Based"
id="admin.channel_list.attributed_based"
/>,
"value": false,
},
"exclude_group_constrained": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
@@ -362,10 +382,14 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
"management": <span
className="group-description adjusted row-content"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
<span
className="group-indicator channel-indicator channel-indicator--larger"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
</span>
</span>,
"name": <span
className="group-name overflow--ellipsis row-content"
@@ -407,10 +431,14 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
"management": <span
className="group-description adjusted row-content"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
<span
className="group-indicator channel-indicator channel-indicator--larger"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
</span>
</span>,
"name": <span
className="group-name overflow--ellipsis row-content"
@@ -452,10 +480,14 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
"management": <span
className="group-description adjusted row-content"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
<span
className="group-indicator channel-indicator channel-indicator--larger"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
</span>
</span>,
"name": <span
className="group-name overflow--ellipsis row-content"
@@ -497,10 +529,14 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
"management": <span
className="group-description adjusted row-content"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
<span
className="group-indicator channel-indicator channel-indicator--larger"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
</span>
</span>,
"name": <span
className="group-name overflow--ellipsis row-content"
@@ -542,10 +578,14 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
"management": <span
className="group-description adjusted row-content"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
<span
className="group-indicator channel-indicator channel-indicator--larger"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
</span>
</span>,
"name": <span
className="group-name overflow--ellipsis row-content"
@@ -587,10 +627,14 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
"management": <span
className="group-description adjusted row-content"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
<span
className="group-indicator channel-indicator channel-indicator--larger"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
</span>
</span>,
"name": <span
className="group-name overflow--ellipsis row-content"
@@ -632,10 +676,14 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
"management": <span
className="group-description adjusted row-content"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
<span
className="group-indicator channel-indicator channel-indicator--larger"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
</span>
</span>,
"name": <span
className="group-name overflow--ellipsis row-content"
@@ -677,10 +725,14 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
"management": <span
className="group-description adjusted row-content"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
<span
className="group-indicator channel-indicator channel-indicator--larger"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
</span>
</span>,
"name": <span
className="group-name overflow--ellipsis row-content"
@@ -722,10 +774,14 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
"management": <span
className="group-description adjusted row-content"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
<span
className="group-indicator channel-indicator channel-indicator--larger"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
</span>
</span>,
"name": <span
className="group-name overflow--ellipsis row-content"
@@ -767,10 +823,14 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
"management": <span
className="group-description adjusted row-content"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
<span
className="group-indicator channel-indicator channel-indicator--larger"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
</span>
</span>,
"name": <span
className="group-name overflow--ellipsis row-content"
@@ -893,9 +953,17 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
"keys": Array [
"group_constrained",
"exclude_group_constrained",
"access_control_policy_enforced",
],
"name": "Management",
"values": Object {
"access_control_policy_enforced": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Attribute Based"
id="admin.channel_list.attributed_based"
/>,
"value": false,
},
"exclude_group_constrained": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
@@ -968,10 +1036,14 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
"management": <span
className="group-description adjusted row-content"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
<span
className="group-indicator channel-indicator channel-indicator--larger"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
</span>
</span>,
"name": <span
className="group-name overflow--ellipsis row-content"

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

@@ -31,7 +31,7 @@ import './channel_list.scss';
export interface ChannelListProps {
actions: {
searchAllChannels: (term: string, opts: ChannelSearchOpts) => Promise<ActionResult>;
getData: (page: number, perPage: number, notAssociatedToGroup?: string, excludeDefaultChannels?: boolean, includeDeleted?: boolean) => Promise<ActionResult>;
getData: (page: number, perPage: number, notAssociatedToGroup?: string, excludeDefaultChannels?: boolean, includeDeleted?: boolean, accessControlPolicyEnforced?: boolean, excludeAccessControlPolicyEnforced?: boolean) => Promise<ActionResult>;
};
data: ChannelWithTeamData[];
total: number;
@@ -58,6 +58,10 @@ const messages = defineMessages({
id: 'admin.channel_settings.channel_row.managementMethod.manual',
defaultMessage: 'Manual Invites',
},
attribute_based: {
id: 'admin.channel_settings.channel_row.managementMethod.attribute_based',
defaultMessage: 'Attribute Based',
},
});
export default class ChannelList extends React.PureComponent<ChannelListProps, ChannelListState> {
@@ -102,7 +106,7 @@ export default class ChannelList extends React.PureComponent<ChannelListProps, C
return;
}
await this.props.actions.getData(page, PAGE_SIZE, '', false, true);
await this.props.actions.getData(page, PAGE_SIZE, '', false, true, false, false);
this.setState({page, loading: false});
};
@@ -232,7 +236,16 @@ export default class ChannelList extends React.PureComponent<ChannelListProps, C
),
management: (
<span className='group-description adjusted row-content'>
<FormattedMessage {...(channel.group_constrained ? messages.group : messages.manual)}/>
<span className='group-indicator channel-indicator channel-indicator--larger'>
{(() => {
if (channel.policy_enforced) {
return <FormattedMessage {...messages.attribute_based}/>;
} else if (channel.group_constrained) {
return <FormattedMessage {...messages.group}/>;
}
return <FormattedMessage {...messages.manual}/>;
})()}
</span>
</span>
),
edit: (
@@ -256,10 +269,10 @@ export default class ChannelList extends React.PureComponent<ChannelListProps, C
onFilter = (filterOptions: FilterOptions) => {
const filters: ChannelSearchOpts = {};
const {group_constrained: groupConstrained, exclude_group_constrained: excludeGroupConstrained} = filterOptions.management.values;
const {group_constrained: groupConstrained, exclude_group_constrained: excludeGroupConstrained, access_control_policy_enforced: accessControlPolicyEnforced} = filterOptions.management.values;
const {public: publicChannels, private: privateChannels, deleted} = filterOptions.channels.values;
const {team_ids: teamIds} = filterOptions.teams.values;
if (publicChannels.value || privateChannels.value || deleted.value || groupConstrained.value || excludeGroupConstrained.value || (teamIds.value as string[]).length) {
if (publicChannels.value || privateChannels.value || deleted.value || groupConstrained.value || excludeGroupConstrained.value || (teamIds.value as string[]).length || accessControlPolicyEnforced.value) {
filters.public = publicChannels.value as boolean;
if (filters.public) {
trackEvent('admin_channels_page', 'public_filter_applied_to_channel_list');
@@ -275,6 +288,11 @@ export default class ChannelList extends React.PureComponent<ChannelListProps, C
trackEvent('admin_channels_page', 'archived_filter_applied_to_channel_list');
}
filters.access_control_policy_enforced = accessControlPolicyEnforced.value as boolean;
if (filters.access_control_policy_enforced) {
trackEvent('admin_channels_page', 'attributed_based_filter_applied_to_channel_list');
}
if (!(groupConstrained.value && excludeGroupConstrained.value)) {
filters.group_constrained = groupConstrained.value as boolean;
if (filters.group_constrained) {
@@ -358,8 +376,17 @@ export default class ChannelList extends React.PureComponent<ChannelListProps, C
),
value: false,
},
access_control_policy_enforced: {
name: (
<FormattedMessage
id='admin.channel_list.attributed_based'
defaultMessage='Attribute Based'
/>
),
value: false,
},
},
keys: ['group_constrained', 'exclude_group_constrained'],
keys: ['group_constrained', 'exclude_group_constrained', 'access_control_policy_enforced'],
},
channels: {
name: 'Channels',

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

@@ -22,6 +22,7 @@ type Component = any
type AdminDefinitionConfigSchemaComponent = {
id: string;
component: Component;
isBeta?: boolean;
}
export type ConsoleAccess = {read: {[key: string]: boolean}; write: {[key: string]: boolean}}
@@ -164,6 +165,7 @@ AdminDefinitionSettingRadio | AdminDefinitionSettingRole;
type AdminDefinitionConfigSchemaSettings = {
id: string;
name: string | MessageDescriptor;
isBeta?: boolean;
isHidden?: Check;
onConfigLoad?: (config: Partial<AdminConfig>) => {[x: string]: string};
onConfigSave?: (displayVal: any) => any;
@@ -181,6 +183,7 @@ export type AdminDefinitionConfigSchemaSection = {
header?: string | MessageDescriptor;
footer?: string | MessageDescriptor;
component?: Component;
isHidden?: Check;
}
type RestrictedIndicatorType = {

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

@@ -3,16 +3,39 @@
import React from 'react';
import WithTooltip from 'components/with_tooltip';
type Props = {
title: React.ReactNode;
subtitle?: React.ReactNode;
buttonText?: React.ReactNode;
isDisabled?: boolean;
onClick?: () => void;
tooltipText?: string;
};
// This component can be used in the card header
const TitleAndButtonCardHeader: React.FC<Props> = (props: Props) => {
let button = (
<button
disabled={props.isDisabled}
className='btn btn-primary'
onClick={props.onClick}
>
{props.buttonText}
</button>
);
if (props.isDisabled && props.tooltipText) {
button = (
<WithTooltip
title={props.tooltipText}
>
{button}
</WithTooltip>
);
}
return (
<>
<div>
@@ -27,16 +50,8 @@ const TitleAndButtonCardHeader: React.FC<Props> = (props: Props) => {
}
</div>
{
props.buttonText && props.onClick &&
<button
disabled={props.isDisabled}
className='btn btn-primary'
onClick={props.onClick}
>
{props.buttonText}
</button>
props.buttonText && props.onClick && button
}
</>
);
};

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

@@ -24,13 +24,15 @@ type Props = {
intl: IntlShape;
groupID: string;
actions: {
loadChannels: (page?: number, perPage?: number, notAssociatedToGroup?: string, excludeDefaultChannels?: boolean, excludePolicyConstrained?: boolean) => Promise<ActionResult<ChannelWithTeamData[]>>;
loadChannels: (page?: number, perPage?: number, notAssociatedToGroup?: string, excludeDefaultChannels?: boolean, excludePolicyConstrained?: boolean, excludeAccessControlPolicyEnforced?: boolean) => Promise<ActionResult<ChannelWithTeamData[]>>;
setModalSearchTerm: (term: string) => void;
searchAllChannels: (term: string, opts?: ChannelSearchOpts) => Promise<ActionResult<ChannelWithTeamData[]>>;
};
alreadySelected?: string[];
excludePolicyConstrained?: boolean;
excludeAccessControlPolicyEnforced?: boolean;
excludeTeamIds?: string[];
excludeTypes?: string[];
}
type State = {
@@ -56,7 +58,7 @@ export class ChannelSelectorModal extends React.PureComponent<Props, State> {
};
componentDidMount() {
this.props.actions.loadChannels(0, CHANNELS_PER_PAGE + 1, this.props.groupID, false, this.props.excludePolicyConstrained).then((response) => {
this.props.actions.loadChannels(0, CHANNELS_PER_PAGE + 1, this.props.groupID, false, this.props.excludePolicyConstrained, this.props.excludeAccessControlPolicyEnforced).then((response) => {
this.setState({channels: response.data!.sort(compareChannels)});
this.setChannelsLoadingState(false);
});
@@ -68,7 +70,7 @@ export class ChannelSelectorModal extends React.PureComponent<Props, State> {
const searchTerm = this.props.searchTerm;
if (searchTerm === '') {
this.props.actions.loadChannels(0, CHANNELS_PER_PAGE + 1, this.props.groupID, false, this.props.excludePolicyConstrained).then((response) => {
this.props.actions.loadChannels(0, CHANNELS_PER_PAGE + 1, this.props.groupID, false, this.props.excludePolicyConstrained, this.props.excludeAccessControlPolicyEnforced).then((response) => {
this.setState({channels: response.data!.sort(compareChannels)});
this.setChannelsLoadingState(false);
});
@@ -130,7 +132,7 @@ export class ChannelSelectorModal extends React.PureComponent<Props, State> {
handlePageChange = (page: number, prevPage: number) => {
if (page > prevPage) {
this.setChannelsLoadingState(true);
this.props.actions.loadChannels(page, CHANNELS_PER_PAGE + 1, this.props.groupID, false, this.props.excludePolicyConstrained).then((response) => {
this.props.actions.loadChannels(page, CHANNELS_PER_PAGE + 1, this.props.groupID, false, this.props.excludePolicyConstrained, this.props.excludeAccessControlPolicyEnforced).then((response) => {
const newState = [...this.state.channels];
const stateChannelIDs = this.state.channels.map((stateChannel) => stateChannel.id);
response.data!.forEach((serverChannel) => {
@@ -221,6 +223,9 @@ export class ChannelSelectorModal extends React.PureComponent<Props, State> {
if (this.props.excludeTeamIds) {
options = options.filter((channel) => this.props.excludeTeamIds?.indexOf(channel.team_id) === -1);
}
if (this.props.excludeTypes) {
options = options.filter((channel) => this.props.excludeTypes?.indexOf(channel.type) === -1);
}
const values = this.state.values.map((i): ChannelWithTeamDataValue => ({...i, label: i.display_name, value: i.id}));
return (

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

@@ -15,6 +15,7 @@ type Props = {
total: number;
extraInfo?: {[key: string]: Array<string | JSX.Element>};
nextPage: (page: number) => void;
previousPage?: (page: number) => void;
search: (term: string) => void;
actions?: React.ReactNode[];
actionProps?: {
@@ -53,6 +54,7 @@ export default function SearchableUserListContainer(props: Props) {
const previousPage = () => {
setPage(page - 1);
props.previousPage?.(page - 1);
};
const search = (term: string) => {

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

@@ -6,20 +6,21 @@ import React from 'react';
import {useIntl} from 'react-intl';
import Tag from './tag';
import type {TagSize} from './tag';
import type {TagSize, TagVariant} from './tag';
type Props = {
className?: string;
size?: TagSize;
variant?: TagVariant;
}
const BetaTag = ({className = '', size = 'xs'}: Props) => {
const BetaTag = ({className = '', size = 'xs', variant = 'info'}: Props) => {
const {formatMessage} = useIntl();
return (
<Tag
uppercase={true}
size={size}
variant='info'
variant={variant}
className={classNames('BetaTag', className)}
text={formatMessage({
id: 'tag.default.beta',

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

@@ -9,7 +9,7 @@ import styled, {css} from 'styled-components';
import glyphMap from '@mattermost/compass-icons/components';
import type {IconGlyphTypes} from '@mattermost/compass-icons/IconGlyphs';
export type TagVariant = 'info' | 'success' | 'warning' | 'danger' | 'dangerDim';
export type TagVariant = 'info' | 'success' | 'warning' | 'danger' | 'dangerDim' | 'default';
export type TagSize = 'xs' | 'sm' | 'md' | 'lg'

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

@@ -253,10 +253,84 @@
"adldap_upsell_banner.confirm.title": "Your trial has started!",
"adldap_upsell_banner.sales_btn": "Contact sales to use",
"admin_settings.save_unsaved_changes": "Please save unsaved changes first",
"admin.access_control.cel_help_modal.external_link": "For more information, visit <link>CEL Documentation</link>.",
"admin.access_control.cel_help_modal.important_notes_title": "Important Notes",
"admin.access_control.cel_help_modal.subheader": "With CEL you can define conditions to filter user attributes and control resource access.",
"admin.access_control.cel_help_modal.title": "Common Expression Language (CEL)",
"admin.access_control.cel.line_and_column_number": "L{lineNumber}:{columnNumber}",
"admin.access_control.cel.validateSyntax": "Validate syntax",
"admin.access_control.cel.validating": "Validating...",
"admin.access_control.delete": "Delete",
"admin.access_control.edit": "Edit",
"admin.access_control.edit_policy.apply_policy": "Apply policy",
"admin.access_control.edit_policy.cancel": "Cancel",
"admin.access_control.edit_policy.save": "Save",
"admin.access_control.edit_policy.save_policy": "Save policy",
"admin.access_control.edit_policy.serverError": "There are errors in the form above: {serverError}",
"admin.access_control.jobTable.details.subheader": "Finished at {finishedAt}",
"admin.access_control.policies.add_policy": "Add policy",
"admin.access_control.policies.applies_to": "Applies to",
"admin.access_control.policies.description": "Create policies containing attribute based access rules and the resources they apply to.",
"admin.access_control.policies.menu.aria_label": "Policy actions menu",
"admin.access_control.policies.name": "Name",
"admin.access_control.policies.resources.channels": "{count, number} {count, plural, one {channel} other {channels}}",
"admin.access_control.policies.resources.none": "None",
"admin.access_control.policies.title": "Access Control Policies",
"admin.access_control.policy.channels_affected": "Are you sure you want to save and apply the access control policy?",
"admin.access_control.policy.edit_policy.autoSyncMembership": "Auto-add members based on access rules:",
"admin.access_control.policy.edit_policy.autoSyncMembership.description": "Users who match the attribute values configured below will be automatically added as new members. Regardless of this setting, users who later no longer match the configured attribute values will be removed from the channel after the next sync.",
"admin.access_control.policy.edit_policy.channel_selector.addChannels": "Add channels",
"admin.access_control.policy.edit_policy.channel_selector.remove": "Remove",
"admin.access_control.policy.edit_policy.channel_selector.subtitle": "Add channels that this attribute based access policy will apply to.",
"admin.access_control.policy.edit_policy.channel_selector.title": "Assigned channels",
"admin.access_control.policy.edit_policy.channel_selector.to_be_removed": "To be removed",
"admin.access_control.policy.edit_policy.delete_confirmation.confirm_button": "Delete Policy",
"admin.access_control.policy.edit_policy.delete_confirmation.message": "Are you sure you want to delete this policy? This action cannot be undone.",
"admin.access_control.policy.edit_policy.delete_confirmation.title": "Confirm Policy Deletion",
"admin.access_control.policy.edit_policy.delete_policy.delete": "Delete",
"admin.access_control.policy.edit_policy.delete_policy.subtitle": "This policy will be deleted and cannot be recovered.",
"admin.access_control.policy.edit_policy.delete_policy.subtitle.has_resources": "Remove all assigned resources (eg. Channels) to be able to delete this policy",
"admin.access_control.policy.edit_policy.delete_policy.title": "Delete policy",
"admin.access_control.policy.edit_policy.policyName": "Access control policy name:",
"admin.access_control.policy.edit_policy.policyName.placeholder": "Add a unique policy name",
"admin.access_control.policy.edit_policy.switch_to_advanced": "Switch to Advanced Mode",
"admin.access_control.policy.edit_policy.switch_to_simple": "Switch to Simple Mode",
"admin.access_control.policy.edit_policy.title": "Edit Access Control Policy",
"admin.access_control.policy.enforce_immediately": "Enforce policy immediately",
"admin.access_control.policy.save_only": "Are you sure you want to save this access control policy?",
"admin.access_control.policy.save_policy_confirmation_body": "Applying this policy will allow users with the appropriate attribute values to be added to the selected channels. Existing channel members will be removed from these channels if they are not assigned the values defined in this access policy.",
"admin.access_control.policy.save_policy_confirmation_body.inactive": "Only users who match the attribute values configured below can be added to the selected channels. Existing channel members will be removed from these channels if they are not assigned the values defined in this access policy.",
"admin.access_control.policy.save_policy_confirmation_subheader": "{count} channels will be affected.",
"admin.access_control.policy.save_policy_confirmation_title": "Save access control policy ",
"admin.access_control.table_editor.add_attribute": "Add attribute",
"admin.access_control.table_editor.attribute": "Attribute",
"admin.access_control.table_editor.blank_state": "Select a user attribute and values to create a rule",
"admin.access_control.table_editor.learnMore": "Learn more about creating access expressions with examples.",
"admin.access_control.table_editor.operator": "Operator",
"admin.access_control.table_editor.operator.contains": "contains",
"admin.access_control.table_editor.operator.ends_with": "ends with",
"admin.access_control.table_editor.operator.in": "in",
"admin.access_control.table_editor.operator.is": "is",
"admin.access_control.table_editor.operator.is_not": "is not",
"admin.access_control.table_editor.operator.starts_with": "starts with",
"admin.access_control.table_editor.remove_row": "Remove row",
"admin.access_control.table_editor.selector.filter_attributes": "Search attributes...",
"admin.access_control.table_editor.selector.filter_operators": "Search operators...",
"admin.access_control.table_editor.selector.select_attribute": "Select attribute",
"admin.access_control.table_editor.test_access_rule": "Test access rule",
"admin.access_control.table_editor.value.placeholder": "Add value...",
"admin.access_control.table_editor.values": "Values",
"admin.access_control.table_editor.values.placeholder": "Add values...",
"admin.access_control.testResults": "Access Rule Test Results",
"admin.accesscontrol.enableDesc": "Allow access restrictions based on user attributes using custom access policies",
"admin.accesscontrol.enableTitle": "Allow attribute based access controls on this servers",
"admin.accesscontrol.title": "Attribute-Based Access",
"admin.advance.cluster": "High Availability",
"admin.advance.metrics": "Performance Monitoring",
"admin.announcement_banner_feature_discovery.copy": "Create announcement banners to notify all members of important information.",
"admin.announcement_banner_feature_discovery.title": "Create custom announcement banners with Mattermost Professional",
"admin.attribute_based_access_control_feature_discovery.desc": "Create policies containing access rules based on user attributes and apply them to channels and other resources within Mattermost.",
"admin.attribute_based_access_control_feature_discovery.title": "Use attribute based access policies to control channel access with Mattermost Enterprise Advanced",
"admin.audit_logging_experimental.certificate.help_text": "The certificate file used for audit logging encryption.",
"admin.audit_logging_experimental.certificate.remove_button": "Remove Certificate",
"admin.audit_logging_experimental.certificate.remove_help_text": "Remove the certificate used for audit logging encryption.",
@@ -456,31 +530,48 @@
"admin.cacheSettings.redisPassword": "Redis Password",
"admin.cacheSettings.redisPasswordDesc": "The password of the Redis server.",
"admin.cacheSettings.title": "Cache Settings",
"admin.channel_details.policy_public_error": "You cannot assign a policy to a public channel.",
"admin.channel_details.policy_synced_error": "You cannot assign a policy to a synced channel.",
"admin.channel_list.archived": "Archived",
"admin.channel_list.attributed_based": "Attribute Based",
"admin.channel_list.group_sync": "Group Sync",
"admin.channel_list.manual_invites": "Manual Invites",
"admin.channel_list.private": "Private",
"admin.channel_list.public": "Public",
"admin.channel_settings.channel_detail.access_control_policy_actions": "Actions",
"admin.channel_settings.channel_detail.access_control_policy_description": "Select an access policy for this channel to restrict membership.",
"admin.channel_settings.channel_detail.access_control_policy_name": "Name",
"admin.channel_settings.channel_detail.access_control_policy_title": "Access policy",
"admin.channel_settings.channel_detail.archive_confirm.button": "Save and Archive Channel",
"admin.channel_settings.channel_detail.archive_confirm.message": "Saving will archive the channel from the team and make it's contents inaccessible for all users. Are you sure you wish to save and archive this channel?",
"admin.channel_settings.channel_detail.archive_confirm.title": "Save and Archive Channel",
"admin.channel_settings.channel_detail.channel_configuration": "Channel Configuration",
"admin.channel_settings.channel_detail.channelOrganizationsMessage": "Shared with trusted organizations",
"admin.channel_settings.channel_detail.go_to_policy.aria_label": "Go to the policy",
"admin.channel_settings.channel_detail.groupsDescription": "Select groups to be added to this channel.",
"admin.channel_settings.channel_detail.groupsTitle": "Groups",
"admin.channel_settings.channel_detail.link_policy": "Link to a policy",
"admin.channel_settings.channel_detail.manageDescription": "Choose between inviting members manually or syncing members automatically from groups.",
"admin.channel_settings.channel_detail.manageTitle": "Channel Management",
"admin.channel_settings.channel_detail.membersDescription": "A list of users who are currently in the channel right now",
"admin.channel_settings.channel_detail.membersTitle": "Members",
"admin.channel_settings.channel_detail.policy_following": "This channel is currently using the following access policy.",
"admin.channel_settings.channel_detail.profileDescription": "Summary of the channel, including the channel name.",
"admin.channel_settings.channel_detail.profileTitle": "Channel Profile",
"admin.channel_settings.channel_detail.remove_policy": "Remove policy",
"admin.channel_settings.channel_detail.select_policy": "Select an access policy for this channel to restrict membership",
"admin.channel_settings.channel_detail.select_policy_description": "An access control policy will restrict channel membership based on user attributes.",
"admin.channel_settings.channel_detail.select_policy_title": "Select an Access Control Policy",
"admin.channel_settings.channel_detail.syncedGroupsDescription": "Add and remove channel members based on their group membership.",
"admin.channel_settings.channel_detail.syncedGroupsTitle": "Synced Groups",
"admin.channel_settings.channel_details.add_group": "Add Group",
"admin.channel_settings.channel_details.archiveChannel": "Archive Channel",
"admin.channel_settings.channel_details.attribute_based_description": "Restrict which users can be invited to this channel based on their user attributes and values. Only people who match the specified conditions will be allowed to be selected and added to this channel.",
"admin.channel_settings.channel_details.isDefaultDescr": "This default channel cannot be converted into a private channel.",
"admin.channel_settings.channel_details.isPublic": "Public channel or private channel",
"admin.channel_settings.channel_details.isPublicDescr": "Select Public for a channel any user can find and join. {br}Select Private to require channel invitations to join. {br}Use this switch to change this channel from public to private or from private to public.",
"admin.channel_settings.channel_details.policy_enforced_title": "Enable attribute based channel access",
"admin.channel_settings.channel_details.private_channel_only": "Only private channels can be attribute based.",
"admin.channel_settings.channel_details.syncGroupMembers": "Sync Group Members",
"admin.channel_settings.channel_details.syncGroupMembersDescr": "When enabled, adding and removing users from groups will add or remove them from this channel. The only way of inviting members to this channel is by adding the groups they belong to. <link>Learn More</link>",
"admin.channel_settings.channel_details.unarchiveChannel": "Unarchive Channel",
@@ -528,6 +619,7 @@
"admin.channel_settings.channel_moderation.subtitleMembers": "Manage the actions available to channel members.",
"admin.channel_settings.channel_moderation.title": "Advanced Access Control",
"admin.channel_settings.channel_row.configure": "Edit",
"admin.channel_settings.channel_row.managementMethod.attribute_based": "Attribute Based",
"admin.channel_settings.channel_row.managementMethod.group": "Group Sync",
"admin.channel_settings.channel_row.managementMethod.manual": "Manual Invites",
"admin.channel_settings.description": "Manage channel settings.",
@@ -1274,6 +1366,7 @@
"admin.ip_filtering.your_current_ip_is": "Your current IP address is {ip}",
"admin.ip_filtering.your_current_ip_is_not_in_allowed_rules": "Your IP address {ip} is not included in your allowed IP address rules.",
"admin.jobTable.cancelButton": "Cancel",
"admin.jobTable.details.title": "Job Details",
"admin.jobTable.downloadLink": "Download",
"admin.jobTable.headerExtraInfo": "Details",
"admin.jobTable.headerFiles": "Files",
@@ -1291,6 +1384,11 @@
"admin.jobTable.statusPending": "Pending",
"admin.jobTable.statusSuccess": "Success",
"admin.jobTable.statusWarning": "Warning",
"admin.jobTable.syncResults.added": "Added ({count, number})",
"admin.jobTable.syncResults.error": "An error occurred while syncing the channels.",
"admin.jobTable.syncResults.noResultsSearchable": "No channels match your search or filter.",
"admin.jobTable.syncResults.removed": "Removed ({count, number})",
"admin.jobTable.syncResults.userListTitle": "Channel Membership Changes",
"admin.ldap_feature_discovery_cloud.call_to_action.primary_sales": "Contact sales",
"admin.ldap_feature_discovery.call_to_action.primary": "Start trial",
"admin.ldap_feature_discovery.call_to_action.secondary": "Learn more",
@@ -2466,6 +2564,7 @@
"admin.set_by_env": "This setting has been set through an environment variable. It cannot be changed through the System Console.",
"admin.sidebar.about": "About",
"admin.sidebar.announcement": "System-wide Notifications",
"admin.sidebar.attributeBasedAccessControl": "Attribute-Based Access",
"admin.sidebar.audit_logging_experimental": "Audit Logging",
"admin.sidebar.authentication": "Authentication",
"admin.sidebar.billing": "Billing & Account",
@@ -2931,6 +3030,8 @@
"admin.user_item.userMFARemoveFailed": "Failed to remove user's MFA",
"admin.user_item.userNotFound": "Cannot load User",
"admin.user_item.userUpdateFailed": "Failed to update user",
"admin.user_settings.policy_list.no_policies_found": "No policies found",
"admin.user_settings.policy_list.search_policy_errored": "Something went wrong. Try again",
"admin.userManagement.userDetail.addTeam": "Add Team",
"admin.userManagement.userDetail.authenticationMethod": "Authentication Method",
"admin.userManagement.userDetail.email": "Email",

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

@@ -60,4 +60,13 @@ export default keyMirror({
REMOVE_DATA_RETENTION_CUSTOM_POLICY_TEAMS_FAILURE: null,
REMOVE_DATA_RETENTION_CUSTOM_POLICY_CHANNELS_SUCCESS: null,
REMOVE_DATA_RETENTION_CUSTOM_POLICY_CHANNELS_FAILURE: null,
RECEIVED_ACCESS_CONTROL_POLICIES: null,
RECEIVED_ACCESS_CONTROL_POLICY: null,
CREATE_ACCESS_CONTROL_POLICY_SUCCESS: null,
DELETE_ACCESS_CONTROL_POLICY_SUCCESS: null,
RECEIVED_ACCESS_CONTROL_CHILD_POLICIES: null,
RECEIVED_ACCESS_CONTROL_POLICIES_SEARCH: null,
ASSIGN_CHANNELS_TO_ACCESS_CONTROL_POLICY_SUCCESS: null,
UNASSIGN_CHANNELS_FROM_ACCESS_CONTROL_POLICY_SUCCESS: null,
});

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

@@ -0,0 +1,152 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {batchActions} from 'redux-batched-actions';
import type {AccessControlPoliciesResult, AccessControlPolicy, AccessControlTestResult} from '@mattermost/types/access_control';
import type {ChannelSearchOpts, ChannelsWithTotalCount} from '@mattermost/types/channels';
import type {ServerError} from '@mattermost/types/errors';
import {AdminTypes, ChannelTypes, UserTypes} from 'mattermost-redux/action_types';
import {Client4} from 'mattermost-redux/client';
import type {ActionFuncAsync} from 'mattermost-redux/types/actions';
import {bindClientFunc, forceLogoutIfNecessary} from './helpers';
export function getAccessControlPolicy(id: string) {
return bindClientFunc({
clientFunc: Client4.getAccessControlPolicy,
onSuccess: [AdminTypes.RECEIVED_ACCESS_CONTROL_POLICY],
params: [
id,
],
});
}
export function createAccessControlPolicy(policy: AccessControlPolicy): ActionFuncAsync<AccessControlPolicy> {
return async (dispatch, getState) => {
let data;
try {
data = await Client4.updateOrCreateAccessControlPolicy(policy);
} catch (error) {
forceLogoutIfNecessary(error as ServerError, dispatch, getState);
return {error};
}
dispatch(
{type: AdminTypes.CREATE_ACCESS_CONTROL_POLICY_SUCCESS, data},
);
return {data};
};
}
export function deleteAccessControlPolicy(id: string) {
return bindClientFunc({
clientFunc: Client4.deleteAccessControlPolicy,
onSuccess: [AdminTypes.DELETE_ACCESS_CONTROL_POLICY_SUCCESS],
params: [
id,
],
});
}
export function searchAccessControlPolicies(term: string, type: string, after: string, limit: number): ActionFuncAsync<AccessControlPoliciesResult> {
return async (dispatch, getState) => {
let data;
try {
data = await Client4.searchAccessControlPolicies(term, type, after, limit);
} catch (error) {
forceLogoutIfNecessary(error as ServerError, dispatch, getState);
return {error};
}
dispatch(
{type: AdminTypes.RECEIVED_ACCESS_CONTROL_POLICIES_SEARCH, data: data.policies},
);
return {data};
};
}
export function searchAccessControlPolicyChannels(id: string, term: string, opts: ChannelSearchOpts): ActionFuncAsync<ChannelsWithTotalCount> {
return async (dispatch, getState) => {
let data;
try {
data = await Client4.searchChildAccessControlPolicyChannels(id, term, opts);
} catch (error) {
forceLogoutIfNecessary(error as ServerError, dispatch, getState);
return {error};
}
const childs: Record<string, string[]> = {};
childs[id] = data.channels.map((channel) => channel.id);
dispatch(batchActions([
{type: AdminTypes.RECEIVED_ACCESS_CONTROL_CHILD_POLICIES, data: childs},
{type: ChannelTypes.RECEIVED_CHANNELS, data: data.channels},
]));
return {data};
};
}
export function assignChannelsToAccessControlPolicy(policyId: string, channelIds: string[]) {
return bindClientFunc({
clientFunc: Client4.assignChannelsToAccessControlPolicy,
onSuccess: [AdminTypes.ASSIGN_CHANNELS_TO_ACCESS_CONTROL_POLICY_SUCCESS],
params: [
policyId,
channelIds,
],
});
}
export function unassignChannelsFromAccessControlPolicy(policyId: string, channelIds: string[]) {
return bindClientFunc({
clientFunc: Client4.unassignChannelsFromAccessControlPolicy,
onSuccess: [AdminTypes.UNASSIGN_CHANNELS_FROM_ACCESS_CONTROL_POLICY_SUCCESS],
params: [
policyId,
channelIds,
],
});
}
export function getAccessControlFields(after: string, limit: number) {
return bindClientFunc({
clientFunc: Client4.getAccessControlFields,
params: [
after,
limit,
],
});
}
export function updateAccessControlPolicyActive(policyId: string, active: boolean) {
return bindClientFunc({
clientFunc: Client4.updateAccessControlPolicyActive,
params: [
policyId,
active,
],
});
}
export function searchUsersForExpression(expression: string, term: string, after: string, limit: number): ActionFuncAsync<AccessControlTestResult> {
return async (dispatch, getState) => {
let data;
try {
data = await Client4.testAccessControlExpression(expression, term, after, limit);
} catch (error) {
forceLogoutIfNecessary(error as ServerError, dispatch, getState);
return {error};
}
dispatch(
{type: UserTypes.RECEIVED_PROFILES, data: data.users},
);
return {data};
};
}

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

@@ -1063,11 +1063,8 @@ describe('Actions.Channels', () => {
const mockQuery = {
page: 0,
per_page: 50,
not_associated_to_group: '',
exclude_default_channels: false,
include_total_count: true,
include_deleted: false,
exclude_policy_constrained: false,
};
nock(Client4.getBaseRoute()).
get('/channels').

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

@@ -789,13 +789,13 @@ export function getArchivedChannels(teamId: string, page = 0, perPage: number =
};
}
export function getAllChannelsWithCount(page = 0, perPage: number = General.CHANNELS_CHUNK_SIZE, notAssociatedToGroup = '', excludeDefaultChannels = false, includeDeleted = false, excludePolicyConstrained = false): ActionFuncAsync<ChannelsWithTotalCount> {
export function getAllChannelsWithCount(page = 0, perPage: number = General.CHANNELS_CHUNK_SIZE, notAssociatedToGroup = '', excludeDefaultChannels = false, includeDeleted = false, excludePolicyConstrained = false, accessControlPolicyEnforced = false, excludeAccessControlPolicyEnforced = false): ActionFuncAsync<ChannelsWithTotalCount> {
return async (dispatch, getState) => {
dispatch({type: ChannelTypes.GET_ALL_CHANNELS_REQUEST, data: null});
let payload;
try {
payload = await Client4.getAllChannels(page, perPage, notAssociatedToGroup, excludeDefaultChannels, true, includeDeleted, excludePolicyConstrained);
payload = await Client4.getAllChannels(page, perPage, notAssociatedToGroup, excludeDefaultChannels, true, includeDeleted, excludePolicyConstrained, accessControlPolicyEnforced, excludeAccessControlPolicyEnforced);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch({type: ChannelTypes.GET_ALL_CHANNELS_FAILURE, error});
@@ -821,13 +821,13 @@ export function getAllChannelsWithCount(page = 0, perPage: number = General.CHAN
};
}
export function getAllChannels(page = 0, perPage: number = General.CHANNELS_CHUNK_SIZE, notAssociatedToGroup = '', excludeDefaultChannels = false, excludePolicyConstrained = false): ActionFuncAsync<ChannelWithTeamData[]> {
export function getAllChannels(page = 0, perPage: number = General.CHANNELS_CHUNK_SIZE, notAssociatedToGroup = '', excludeDefaultChannels = false, excludePolicyConstrained = false, excludeAccessControlPolicyEnforced = false, accessControlPolicyEnforced = false): ActionFuncAsync<ChannelWithTeamData[]> {
return async (dispatch, getState) => {
dispatch({type: ChannelTypes.GET_ALL_CHANNELS_REQUEST, data: null});
let channels;
try {
channels = await Client4.getAllChannels(page, perPage, notAssociatedToGroup, excludeDefaultChannels, false, false, excludePolicyConstrained);
channels = await Client4.getAllChannels(page, perPage, notAssociatedToGroup, excludeDefaultChannels, false, false, excludePolicyConstrained, accessControlPolicyEnforced, excludeAccessControlPolicyEnforced);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch({type: ChannelTypes.GET_ALL_CHANNELS_FAILURE, error});

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

@@ -3,6 +3,7 @@
import {combineReducers} from 'redux';
import type {AccessControlPolicy} from '@mattermost/types/access_control';
import type {ClusterInfo, AnalyticsRow, AnalyticsState, AdminState} from '@mattermost/types/admin';
import type {Audit} from '@mattermost/types/audits';
import type {Compliance} from '@mattermost/types/compliance';
@@ -649,6 +650,64 @@ function dataRetentionCustomPoliciesCount(state = 0, action: MMReduxAction) {
}
}
function accessControlPolicies(state: IDMappedObjects<AccessControlPolicy> = {}, action: MMReduxAction) {
switch (action.type) {
case AdminTypes.CREATE_ACCESS_CONTROL_POLICY_SUCCESS:
case AdminTypes.RECEIVED_ACCESS_CONTROL_POLICY:
return {
...state,
[action.data.id]: action.data,
};
case AdminTypes.RECEIVED_ACCESS_CONTROL_POLICIES: {
const nextState: IDMappedObjects<AccessControlPolicy> = {};
for (const policy of action.data) {
nextState[policy.id] = policy;
}
return nextState;
}
case AdminTypes.DELETE_ACCESS_CONTROL_POLICY_SUCCESS: {
const nextState = {...state};
Reflect.deleteProperty(nextState, action.data.id);
return nextState;
}
case AdminTypes.RECEIVED_ACCESS_CONTROL_POLICIES_SEARCH: {
const nextState = {...state};
for (const policy of action.data) {
nextState[policy.id] = policy;
}
return nextState;
}
case UserTypes.LOGOUT_SUCCESS:
return {};
default:
return state;
}
}
function channelsForAccessControlPolicy(state: Record<string, string[]> = {}, action: MMReduxAction) {
switch (action.type) {
case AdminTypes.RECEIVED_ACCESS_CONTROL_CHILD_POLICIES:
if (action.data) {
return {...state, ...action.data};
}
return state;
case AdminTypes.ASSIGN_CHANNELS_TO_ACCESS_CONTROL_POLICY_SUCCESS:
return {
...state,
[action.data.policyId]: action.data.channelIds,
};
case AdminTypes.UNASSIGN_CHANNELS_FROM_ACCESS_CONTROL_POLICY_SUCCESS:
return {
...state,
[action.data.policyId]: action.data.channelIds,
};
case UserTypes.LOGOUT_SUCCESS:
return {};
default:
return state;
}
}
export default combineReducers({
// array of LogObjects each representing a log entry (JSON)
@@ -711,4 +770,10 @@ export default combineReducers({
// the last trial license the server used.
prevTrialLicense,
// object with policy ids as keys and objects representing the policies as values
accessControlPolicies,
// object with policy ids as keys and arrays of channel ids as values
channelsForAccessControlPolicy,
});

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

@@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Channel, ChannelWithTeamData, ChannelSearchOpts} from '@mattermost/types/channels';
import type {GlobalState} from '@mattermost/types/store';
import {filterChannelsMatchingTerm} from 'mattermost-redux/utils/channel_utils';
import {filterChannelList} from './channels';
import {createSelector} from '../create_selector';
export function getAccessControlPolicy(state: GlobalState, id: string) {
return state.entities.admin.accessControlPolicies[id];
}
export const getChannelIdsForAccessControlPolicy = createSelector(
'getChannelIdsForAccessControlPolicy',
(state: GlobalState, parentId: string) => state.entities.admin.channelsForAccessControlPolicy[parentId],
(channelIds) => (Array.isArray(channelIds) ? channelIds : []),
) as (state: GlobalState, parentId: string) => string[];
export function makeGetChannelsInAccessControlPolicy() {
return (createSelector(
'getChannelsInAccessControlPolicy',
(state: GlobalState) => state.entities.channels.channels,
(state: GlobalState, props: {policyId: string}) => getChannelIdsForAccessControlPolicy(state, props.policyId),
(state: GlobalState) => state.entities.teams.teams,
(channels, ids, teams) => {
if (!ids) {
return [];
}
const policyChannels: ChannelWithTeamData[] = [];
ids.forEach((channelId) => {
const channel = channels[channelId];
if (channel) {
const team = teams[channel.team_id] || {};
policyChannels.push({
...channel,
team_id: channel.team_id,
team_display_name: team.display_name || '',
team_name: team.name || '',
team_update_at: team.update_at || 0,
});
}
});
return policyChannels;
}) as (b: GlobalState, a: {
policyId: string;
}) => ChannelWithTeamData[]);
}
export function searchChannelsInheritsPolicy(state: GlobalState, policyId: string, term: string, filters: ChannelSearchOpts): Channel[] {
const channelsInPolicy = makeGetChannelsInAccessControlPolicy();
const channelArray = channelsInPolicy(state, {policyId});
let channels = filterChannelList(channelArray, filters);
channels = filterChannelsMatchingTerm(channels, term);
return channels;
}

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

@@ -119,6 +119,8 @@ const state: GlobalState = {
dataRetentionCustomPolicies: {},
dataRetentionCustomPoliciesCount: 0,
prevTrialLicense: {},
accessControlPolicies: {},
channelsForAccessControlPolicy: {},
},
jobs: {
jobs: {},

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

@@ -344,6 +344,7 @@ export const ModalIdentifiers = {
CHANNEL_MEMBERS: 'channel_members',
CHANNEL_SETTINGS: 'channel_settings',
TEAM_MEMBERS: 'team_members',
TEST_RESULTS: 'test_results',
ADD_USER_TO_CHANNEL: 'add_user_to_channel',
ADD_USER_TO_ROLE: 'add_user_to_role',
ADD_USER_TO_TEAM: 'add_user_to_team',
@@ -933,6 +934,7 @@ export const JobTypes = {
BLEVE_POST_INDEXING: 'bleve_post_indexing',
LDAP_SYNC: 'ldap_sync',
MESSAGE_EXPORT: 'message_export',
ACCESS_CONTROL_SYNC: 'access_control_sync',
} as const;
export const JobStatuses = {

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

@@ -13,6 +13,7 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const webpack = require('webpack');
const {ModuleFederationPlugin} = require('webpack').container;
const WebpackPwaManifest = require('webpack-pwa-manifest');
const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin');
const packageJson = require('./package.json');
@@ -267,6 +268,40 @@ var config = {
sizes: '96x96',
}],
}),
new MonacoWebpackPlugin({
languages: [],
// don't include features we disable. these generally correspond to the options
// passed to editor initialization in note-content-editor.tsx
// @see https://github.com/microsoft/monaco-editor/blob/main/webpack-plugin/README.md#options
features: [
'!bracketMatching',
'!codeAction',
'!codelens',
'!colorPicker',
'!comment',
'!diffEditor',
'!diffEditorBreadcrumbs',
'!folding',
'!gotoError',
'!gotoLine',
'!gotoSymbol',
'!gotoZoom',
'!inspectTokens',
'!multicursor',
'!parameterHints',
'!quickCommand',
'!quickHelp',
'!quickOutline',
'!referenceSearch',
'!rename',
'!snippet',
'!stickyScroll',
'!suggest',
'!toggleHighContrast',
'!unicodeHighlighter',
],
}),
],
};

130
webapp/package-lock.json сгенерированный
Просмотреть файл

@@ -108,6 +108,8 @@
"marked": "github:mattermost/marked#3b13ba8ddf725327ddf0298361d6d304a021f2d1",
"memoize-one": "6.0.0",
"moment-timezone": "0.5.38",
"monaco-editor": "0.52.2",
"monaco-editor-webpack-plugin": "7.1.0",
"p-queue": "7.3.0",
"pdfjs-dist": "4.4.168",
"process": "0.11.10",
@@ -5090,7 +5092,6 @@
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
"integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
"dev": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
@@ -6735,8 +6736,7 @@
"node_modules/@types/estree": {
"version": "0.0.51",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz",
"integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==",
"dev": true
"integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ=="
},
"node_modules/@types/express": {
"version": "4.17.21",
@@ -6942,8 +6942,7 @@
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
},
"node_modules/@types/json-stable-stringify": {
"version": "1.1.0",
@@ -7005,7 +7004,6 @@
"version": "20.11.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz",
"integrity": "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
}
@@ -8037,7 +8035,6 @@
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz",
"integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==",
"dev": true,
"dependencies": {
"@webassemblyjs/helper-numbers": "1.11.6",
"@webassemblyjs/helper-wasm-bytecode": "1.11.6"
@@ -8046,26 +8043,22 @@
"node_modules/@webassemblyjs/floating-point-hex-parser": {
"version": "1.11.6",
"resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz",
"integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==",
"dev": true
"integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw=="
},
"node_modules/@webassemblyjs/helper-api-error": {
"version": "1.11.6",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz",
"integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==",
"dev": true
"integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q=="
},
"node_modules/@webassemblyjs/helper-buffer": {
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz",
"integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==",
"dev": true
"integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw=="
},
"node_modules/@webassemblyjs/helper-numbers": {
"version": "1.11.6",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz",
"integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==",
"dev": true,
"dependencies": {
"@webassemblyjs/floating-point-hex-parser": "1.11.6",
"@webassemblyjs/helper-api-error": "1.11.6",
@@ -8075,14 +8068,12 @@
"node_modules/@webassemblyjs/helper-wasm-bytecode": {
"version": "1.11.6",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz",
"integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==",
"dev": true
"integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA=="
},
"node_modules/@webassemblyjs/helper-wasm-section": {
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz",
"integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==",
"dev": true,
"dependencies": {
"@webassemblyjs/ast": "1.12.1",
"@webassemblyjs/helper-buffer": "1.12.1",
@@ -8094,7 +8085,6 @@
"version": "1.11.6",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz",
"integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==",
"dev": true,
"dependencies": {
"@xtuc/ieee754": "^1.2.0"
}
@@ -8103,7 +8093,6 @@
"version": "1.11.6",
"resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz",
"integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==",
"dev": true,
"dependencies": {
"@xtuc/long": "4.2.2"
}
@@ -8111,14 +8100,12 @@
"node_modules/@webassemblyjs/utf8": {
"version": "1.11.6",
"resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz",
"integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==",
"dev": true
"integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA=="
},
"node_modules/@webassemblyjs/wasm-edit": {
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz",
"integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==",
"dev": true,
"dependencies": {
"@webassemblyjs/ast": "1.12.1",
"@webassemblyjs/helper-buffer": "1.12.1",
@@ -8134,7 +8121,6 @@
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz",
"integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==",
"dev": true,
"dependencies": {
"@webassemblyjs/ast": "1.12.1",
"@webassemblyjs/helper-wasm-bytecode": "1.11.6",
@@ -8147,7 +8133,6 @@
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz",
"integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==",
"dev": true,
"dependencies": {
"@webassemblyjs/ast": "1.12.1",
"@webassemblyjs/helper-buffer": "1.12.1",
@@ -8159,7 +8144,6 @@
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz",
"integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==",
"dev": true,
"dependencies": {
"@webassemblyjs/ast": "1.12.1",
"@webassemblyjs/helper-api-error": "1.11.6",
@@ -8173,7 +8157,6 @@
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz",
"integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==",
"dev": true,
"dependencies": {
"@webassemblyjs/ast": "1.12.1",
"@xtuc/long": "4.2.2"
@@ -8240,14 +8223,12 @@
"node_modules/@xtuc/ieee754": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
"integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
"dev": true
"integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="
},
"node_modules/@xtuc/long": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
"dev": true
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="
},
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
@@ -8306,7 +8287,6 @@
"version": "1.9.5",
"resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz",
"integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==",
"dev": true,
"peerDependencies": {
"acorn": "^8"
}
@@ -8436,7 +8416,6 @@
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
"dev": true,
"peerDependencies": {
"ajv": "^6.9.1"
}
@@ -9233,7 +9212,6 @@
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
"integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==",
"dev": true,
"engines": {
"node": "*"
}
@@ -9881,7 +9859,6 @@
"version": "4.24.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz",
"integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -10038,8 +10015,7 @@
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"node_modules/buffer-xor": {
"version": "1.0.3",
@@ -10187,7 +10163,6 @@
"version": "1.0.30001674",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001674.tgz",
"integrity": "sha512-jOsKlZVRnzfhLojb+Ykb+gyUSp9Xb57So+fAiFlLzzTKpqg8xxSav0e40c8/4F/v9N8QSvrRRaLeVzQbLqomYw==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -10355,7 +10330,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
"integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==",
"dev": true,
"engines": {
"node": ">=6.0"
}
@@ -12126,8 +12100,7 @@
"node_modules/electron-to-chromium": {
"version": "1.5.49",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.49.tgz",
"integrity": "sha512-ZXfs1Of8fDb6z7WEYZjXpgIRF6MEu8JdeGA0A40aZq6OQbS+eJpnnV49epZRna2DU/YsEjSQuGtQPPtvt6J65A==",
"dev": true
"integrity": "sha512-ZXfs1Of8fDb6z7WEYZjXpgIRF6MEu8JdeGA0A40aZq6OQbS+eJpnnV49epZRna2DU/YsEjSQuGtQPPtvt6J65A=="
},
"node_modules/elliptic": {
"version": "6.6.0",
@@ -12187,7 +12160,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz",
"integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==",
"dev": true,
"engines": {
"node": ">= 4"
}
@@ -12509,8 +12481,7 @@
"node_modules/es-module-lexer": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz",
"integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==",
"dev": true
"integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw=="
},
"node_modules/es-object-atoms": {
"version": "1.0.0",
@@ -12564,7 +12535,6 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"engines": {
"node": ">=6"
}
@@ -13322,7 +13292,6 @@
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"dev": true,
"engines": {
"node": ">=0.8.x"
}
@@ -14495,8 +14464,7 @@
"node_modules/glob-to-regexp": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
"dev": true
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="
},
"node_modules/global": {
"version": "4.4.0",
@@ -14642,8 +14610,7 @@
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
},
"node_modules/graphemer": {
"version": "1.4.0",
@@ -20234,7 +20201,6 @@
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"bin": {
"json5": "lib/cli.js"
},
@@ -20447,7 +20413,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
"dev": true,
"engines": {
"node": ">=6.11.5"
}
@@ -20772,8 +20737,7 @@
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"dev": true
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
},
"node_modules/merge2": {
"version": "1.4.1",
@@ -21016,6 +20980,36 @@
"node": "*"
}
},
"node_modules/monaco-editor": {
"version": "0.52.2",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ=="
},
"node_modules/monaco-editor-webpack-plugin": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-7.1.0.tgz",
"integrity": "sha512-ZjnGINHN963JQkFqjjcBtn1XBtUATDZBMgNQhDQwd78w2ukRhFXAPNgWuacaQiDZsUr4h1rWv5Mv6eriKuOSzA==",
"dependencies": {
"loader-utils": "^2.0.2"
},
"peerDependencies": {
"monaco-editor": ">= 0.31.0",
"webpack": "^4.5.0 || 5.x"
}
},
"node_modules/monaco-editor-webpack-plugin/node_modules/loader-utils": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz",
"integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==",
"dependencies": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
},
"engines": {
"node": ">=8.9.0"
}
},
"node_modules/moo": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz",
@@ -21135,8 +21129,7 @@
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
},
"node_modules/nice-try": {
"version": "1.0.5",
@@ -21258,8 +21251,7 @@
"node_modules/node-releases": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
"integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==",
"dev": true
"integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g=="
},
"node_modules/nopt": {
"version": "5.0.0",
@@ -24637,7 +24629,6 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"dev": true,
"dependencies": {
"randombytes": "^2.1.0"
}
@@ -26364,7 +26355,6 @@
"version": "5.36.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz",
"integrity": "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==",
"dev": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
@@ -26382,7 +26372,6 @@
"version": "5.3.10",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz",
"integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==",
"dev": true,
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.20",
"jest-worker": "^27.4.5",
@@ -26416,7 +26405,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -26425,7 +26413,6 @@
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
"integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
"dev": true,
"dependencies": {
"@types/node": "*",
"merge-stream": "^2.0.0",
@@ -26439,7 +26426,6 @@
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
"integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
"dev": true,
"dependencies": {
"@types/json-schema": "^7.0.8",
"ajv": "^6.12.5",
@@ -26457,7 +26443,6 @@
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"dependencies": {
"has-flag": "^4.0.0"
},
@@ -26471,14 +26456,12 @@
"node_modules/terser/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
},
"node_modules/terser/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@@ -26487,7 +26470,6 @@
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
@@ -27058,8 +27040,7 @@
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
},
"node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1",
@@ -27147,7 +27128,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz",
"integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -27338,7 +27318,6 @@
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz",
"integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==",
"dev": true,
"dependencies": {
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.1.2"
@@ -27374,7 +27353,6 @@
"version": "5.95.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.95.0.tgz",
"integrity": "sha512-2t3XstrKULz41MNMBF+cJ97TyHdyQ8HCt//pqErqDvNjU9YQBnZxIHa11VXsi7F3mb5/aO2tuDxdeTPdU7xu9Q==",
"dev": true,
"dependencies": {
"@types/estree": "^1.0.5",
"@webassemblyjs/ast": "^1.12.1",
@@ -27655,7 +27633,6 @@
"version": "5.17.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz",
"integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==",
"dev": true,
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.2.0"
@@ -27668,7 +27645,6 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"dev": true,
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^4.1.1"
@@ -27681,7 +27657,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"dev": true,
"engines": {
"node": ">=4.0"
}
@@ -27690,7 +27665,6 @@
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
"integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
"dev": true,
"dependencies": {
"@types/json-schema": "^7.0.8",
"ajv": "^6.12.5",
@@ -27708,7 +27682,6 @@
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
"dev": true,
"engines": {
"node": ">=6"
}
@@ -27717,7 +27690,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz",
"integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==",
"dev": true,
"engines": {
"node": ">=10.13.0"
}

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

@@ -3,6 +3,7 @@
/* eslint-disable max-lines */
import type {AccessControlPolicy, CELExpressionError, AccessControlTestResult, AccessControlPoliciesResult, AccessControlPolicyChannelsResult, AccessControlVisualAST} from '@mattermost/types/access_control';
import type {ClusterInfo, AnalyticsRow, SchemaMigration, LogFilterQuery} from '@mattermost/types/admin';
import type {AppBinding, AppCallRequest, AppCallResponse} from '@mattermost/types/apps';
import type {Audit} from '@mattermost/types/audits';
@@ -108,7 +109,7 @@ import type {
import type {Post, PostList, PostSearchResults, PostsUsageResponse, TeamsUsageResponse, PaginatedPostList, FilesUsageResponse, PostAcknowledgement, PostAnalytics, PostInfo} from '@mattermost/types/posts';
import type {PreferenceType} from '@mattermost/types/preferences';
import type {ProductNotices} from '@mattermost/types/product_notices';
import type {UserPropertyField, UserPropertyFieldPatch} from '@mattermost/types/properties';
import type {PropertyField, UserPropertyField, UserPropertyFieldPatch} from '@mattermost/types/properties';
import type {Reaction} from '@mattermost/types/reactions';
import type {RemoteCluster, RemoteClusterAcceptInvite, RemoteClusterPatch, RemoteClusterWithPassword} from '@mattermost/types/remote_clusters';
import type {UserReport, UserReportFilter, UserReportOptions} from '@mattermost/types/reports';
@@ -1547,7 +1548,9 @@ export default class Client4 {
excludeDefaultChannels: boolean | undefined,
includeTotalCount: false | undefined,
includeDeleted: boolean | undefined,
excludePolicyConstrained: boolean | undefined
excludePolicyConstrained: boolean | undefined,
accessControlPolicyEnforced: boolean | undefined,
excludeAccessControlPolicyEnforced: boolean | undefined
): Promise<ChannelWithTeamData[]>;
getAllChannels(
page: number | undefined,
@@ -1556,7 +1559,9 @@ export default class Client4 {
excludeDefaultChannels: boolean | undefined,
includeTotalCount: true,
includeDeleted: boolean | undefined,
excludePolicyConstrained: boolean | undefined
excludePolicyConstrained: boolean | undefined,
accessControlPolicyEnforced: boolean | undefined,
excludeAccessControlPolicyEnforced: boolean | undefined
): Promise<ChannelsWithTotalCount>;
getAllChannels(
page = 0,
@@ -1566,16 +1571,36 @@ export default class Client4 {
includeTotalCount = false,
includeDeleted = false,
excludePolicyConstrained = false,
accessControlPolicyEnforced = false,
excludeAccessControlPolicyEnforced = false,
) {
const queryData = {
const queryData: Record<string, any> = {
page,
per_page: perPage,
not_associated_to_group: notAssociatedToGroup,
exclude_default_channels: excludeDefaultChannels,
include_total_count: includeTotalCount,
include_deleted: includeDeleted,
exclude_policy_constrained: excludePolicyConstrained,
};
if (notAssociatedToGroup) {
queryData.not_associated_to_group = notAssociatedToGroup;
}
if (excludeDefaultChannels) {
queryData.exclude_default_channels = excludeDefaultChannels;
}
if (excludePolicyConstrained) {
queryData.exclude_policy_constrained = excludePolicyConstrained;
}
if (accessControlPolicyEnforced) {
queryData.access_control_policy_enforced = accessControlPolicyEnforced;
}
if (excludeAccessControlPolicyEnforced) {
queryData.exclude_access_control_policy_enforced = excludeAccessControlPolicyEnforced;
}
return this.doFetch<ChannelWithTeamData[] | ChannelsWithTotalCount>(
`${this.getChannelsRoute()}${buildQueryString(queryData)}`,
{method: 'get'},
@@ -4382,6 +4407,111 @@ export default class Client4 {
{method: 'post', headers: {'Connection-Id': connectionId}},
);
};
getAccessControlPolicy = (id: string) => {
return this.doFetch<AccessControlPolicy>(
`${this.getBaseRoute()}/access_control_policies/${id}`,
{method: 'get'},
);
};
updateOrCreateAccessControlPolicy = (policy: AccessControlPolicy) => {
return this.doFetch<AccessControlPolicy>(
`${this.getBaseRoute()}/access_control_policies`,
{method: 'put', body: JSON.stringify(policy)},
);
};
deleteAccessControlPolicy = (id: string) => {
return this.doFetch<AccessControlPolicy>(
`${this.getBaseRoute()}/access_control_policies/${id}`,
{method: 'delete'},
);
};
getAccessControlPolicies = (after: string, limit: number) => {
return this.doFetch<AccessControlPoliciesResult>(
`${this.getBaseRoute()}/access_control_policies/search`,
{method: 'post', body: JSON.stringify({type: 'parent', cursor: {id: after}, limit})},
);
};
getChildPolicies = (parentId: string, after: string, limit: number) => {
return this.doFetch<AccessControlPoliciesResult>(
`${this.getBaseRoute()}/access_control_policies/search`,
{method: 'post', body: JSON.stringify({parent_id: parentId, cursor: {id: after}, limit})},
);
};
getChannelsForAccessControlPolicy = (policyId: string, after: string, limit: number) => {
return this.doFetch<AccessControlPolicyChannelsResult>(
`${this.getBaseRoute()}/access_control_policies/${policyId}/resources/channels?after=${after}&limit=${limit}`,
{method: 'get'},
);
};
searchAccessControlPolicies = (term: string, type: string, after: string, limit: number) => {
return this.doFetch<AccessControlPoliciesResult>(
`${this.getBaseRoute()}/access_control_policies/search`,
{method: 'post', body: JSON.stringify({term, type, cursor: {id: after}, limit, include_children: true})},
);
};
searchChildAccessControlPolicyChannels = (policyId: string, term: string, opts: ChannelSearchOpts) => {
return this.doFetch<ChannelsWithTotalCount>(
`${this.getBaseRoute()}/access_control_policies/${policyId}/resources/channels/search?term=${term}`,
{method: 'post', body: JSON.stringify({term, ...opts})},
);
};
updateAccessControlPolicyActive = (policyId: string, active: boolean) => {
return this.doFetch<StatusOK>(
`${this.getBaseRoute()}/access_control_policies/${policyId}/activate?active=${active}`,
{method: 'get'},
);
};
assignChannelsToAccessControlPolicy = (policyId: string, channelIds: string[]) => {
return this.doFetch<StatusOK>(
`${this.getBaseRoute()}/access_control_policies/${policyId}/assign`,
{method: 'post', body: JSON.stringify({channel_ids: channelIds})},
);
};
unassignChannelsFromAccessControlPolicy = (policyId: string, channelIds: string[]) => {
return this.doFetch<StatusOK>(
`${this.getBaseRoute()}/access_control_policies/${policyId}/unassign`,
{method: 'delete', body: JSON.stringify({channel_ids: channelIds})},
);
};
getAccessControlFields = (after: string, limit: number) => {
return this.doFetch<PropertyField[]>(
`${this.getBaseRoute()}/access_control_policies/cel/autocomplete/fields?after=${after}&limit=${limit}`,
{method: 'get'},
);
};
checkAccessControlExpression = (expression: string) => {
return this.doFetch<CELExpressionError[]>(
`${this.getBaseRoute()}/access_control_policies/cel/check`,
{method: 'post', body: JSON.stringify({expression})},
);
};
testAccessControlExpression = (expression: string, term: string, after: string, limit: number) => {
return this.doFetch<AccessControlTestResult>(
`${this.getBaseRoute()}/access_control_policies/cel/test`,
{method: 'post', body: JSON.stringify({expression, term, after, limit})},
);
};
expressionToVisualFormat = (expression: string) => {
return this.doFetch<AccessControlVisualAST>(
`${this.getBaseRoute()}/access_control_policies/cel/visual_ast`,
{method: 'post', body: JSON.stringify({expression})},
);
};
}
export function parseAndMergeNestedHeaders(originalHeaders: any) {

75
webapp/platform/types/src/access_control.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ChannelWithTeamData} from './channels';
import type {UserProfile} from './users';
export type AccessControlPolicy = {
id: string;
name: string;
type: string;
revision?: number;
created_at?: number;
version?: string;
active?: boolean;
imports?: string[];
props?: Record<string, unknown[]>;
rules: AccessControlPolicyRule[];
}
export type AccessControlPolicyCursor = {
id: string;
}
export type AccessControlPoliciesResult = {
policies: AccessControlPolicy[];
total: number;
}
export type AccessControlPolicySearchOpts = {
term: string;
type: string;
cursor: AccessControlPolicyCursor;
limit: number;
}
export type AccessControlPolicyChannelsResult = {
channels: ChannelWithTeamData[];
total: number;
}
export type AccessControlPolicyRule = {
actions?: string[];
expression: string;
}
export type CELExpressionError = {
message: string;
line: number;
column: number;
}
export type AccessControlTestResult = {
users: UserProfile[];
total: number;
}
export type AccessControlEntity = {
name: string;
attributes: AccessControlAttribute[];
}
export type AccessControlAttribute = {
name: string;
values: string[];
}
export type AccessControlVisualAST = {
conditions: AccessControlVisualASTNode[];
}
export type AccessControlVisualASTNode = {
attribute: string;
operator: string;
value: any;
value_type: number;
}

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

@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {AccessControlPolicy} from './access_control';
import type {Audit} from './audits';
import type {Compliance} from './compliance';
import type {AdminConfig, ClientLicense, EnvironmentConfig} from './config';
@@ -10,7 +11,7 @@ import type {PluginRedux, PluginStatusRedux} from './plugins';
import type {SamlCertificateStatus, SamlMetadataResponse} from './saml';
import type {Team} from './teams';
import type {UserAccessToken, UserProfile} from './users';
import type {RelationOneToOne} from './utilities';
import type {RelationOneToOne, IDMappedObjects} from './utilities';
export enum LogLevelEnum {
SILLY = 'silly',
@@ -69,6 +70,8 @@ export type AdminState = {
dataRetentionCustomPolicies: DataRetentionCustomPolicies;
dataRetentionCustomPoliciesCount: number;
prevTrialLicense: ClientLicense;
accessControlPolicies: IDMappedObjects<AccessControlPolicy>;
channelsForAccessControlPolicy: Record<string, string[]>;
};
export type AnalyticsState = {

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

@@ -68,6 +68,7 @@ export type Channel = {
props?: Record<string, any>;
policy_id?: string | null;
banner_info?: ChannelBanner;
policy_enforced?: boolean;
};
export type ServerChannel = Channel & {
@@ -230,4 +231,7 @@ export type ChannelSearchOpts = {
deleted?: boolean;
page?: number;
per_page?: number;
access_control_policy_enforced?: boolean;
exclude_access_control_policy_enforced?: boolean;
parent_access_control_policy_id?: string;
};

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

@@ -125,6 +125,7 @@ export type ClientConfig = {
FeatureFlagAppsEnabled: string;
FeatureFlagCallsEnabled: string;
FeatureFlagCustomProfileAttributes: string;
FeatureFlagAttributeBasedAccessControl: string;
FeatureFlagWebSocketEventScope: string;
ForgotPasswordLink: string;
GiphySdkKey: string;

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

@@ -3,7 +3,7 @@
import type {IDMappedObjects} from './utilities';
export type JobType = 'data_retention' | 'elasticsearch_post_indexing' | 'bleve_post_indexing' | 'ldap_sync' | 'message_export';
export type JobType = 'data_retention' | 'elasticsearch_post_indexing' | 'bleve_post_indexing' | 'ldap_sync' | 'message_export' | 'access_control_sync';
export type JobStatus = 'pending' | 'in_progress' | 'success' | 'error' | 'cancel_requested' | 'canceled' | 'warning';
export type Job = JobTypeBase & {
id: string;