[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>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
4b445cbf16
Коммит
a344b3225b
@@ -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 `user.attributes.Program == "Delta"`\n- To match any of multiple teams:\n `user.attributes.Team in ["Sales", "Engineering"]`\n- To match an email domain:\n `user.attributes.Email.endsWith("example.com")`\n- To combine conditions (for this example with `OR` operator, altertanitvely use `&&` for `AND` operation):\n `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
сгенерированный
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
Обычный файл
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;
|
||||
|
||||
Ссылка в новой задаче
Block a user