mirror of
https://github.com/ONLYOFFICE/server.git
synced 2026-04-07 14:04:35 +08:00
[feature] Add notifications rules; Fix bug 77096
This commit is contained in:
@ -2,6 +2,7 @@ import WOPISettings from '../pages/WOPISettings/WOPISettings';
|
|||||||
import Expiration from '../pages/Expiration/Expiration';
|
import Expiration from '../pages/Expiration/Expiration';
|
||||||
import SecuritySettings from '../pages/SecuritySettings/SecuritySettings';
|
import SecuritySettings from '../pages/SecuritySettings/SecuritySettings';
|
||||||
import EmailConfig from '../pages/EmailConfig/EmailConfig';
|
import EmailConfig from '../pages/EmailConfig/EmailConfig';
|
||||||
|
import NotificationRules from '../pages/NotificationRules/NotificationRules';
|
||||||
import FileLimits from '../pages/FileLimits/FileLimits';
|
import FileLimits from '../pages/FileLimits/FileLimits';
|
||||||
import RequestFiltering from '../pages/RequestFiltering/RequestFiltering';
|
import RequestFiltering from '../pages/RequestFiltering/RequestFiltering';
|
||||||
import LoggerConfig from '../pages/LoggerConfig/LoggerConfig';
|
import LoggerConfig from '../pages/LoggerConfig/LoggerConfig';
|
||||||
@ -21,6 +22,7 @@ export const menuItems = [
|
|||||||
{key: 'wopi-settings', label: 'WOPI Settings', path: '/wopi-settings', component: WOPISettings},
|
{key: 'wopi-settings', label: 'WOPI Settings', path: '/wopi-settings', component: WOPISettings},
|
||||||
{key: 'email-config', label: 'Email Config', path: '/email-config', component: EmailConfig},
|
{key: 'email-config', label: 'Email Config', path: '/email-config', component: EmailConfig},
|
||||||
{key: 'logger-config', label: 'Logger Config', path: '/logger-config', component: LoggerConfig},
|
{key: 'logger-config', label: 'Logger Config', path: '/logger-config', component: LoggerConfig},
|
||||||
|
{key: 'notification-rules', label: 'Notification Rules', path: '/notification-rules', component: NotificationRules},
|
||||||
{key: 'health-check', label: 'Health Check', path: '/health-check', component: createMockComponent('Health Check')},
|
{key: 'health-check', label: 'Health Check', path: '/health-check', component: createMockComponent('Health Check')},
|
||||||
{key: 'config-check', label: 'Config Check', path: '/config-check', component: createMockComponent('Config Check')},
|
{key: 'config-check', label: 'Config Check', path: '/config-check', component: createMockComponent('Config Check')},
|
||||||
{key: 'database-info', label: 'Database Info', path: '/database-info', component: createMockComponent('Database Info')},
|
{key: 'database-info', label: 'Database Info', path: '/database-info', component: createMockComponent('Database Info')},
|
||||||
|
|||||||
@ -0,0 +1,213 @@
|
|||||||
|
import {useState, useRef, useEffect, useCallback} from 'react';
|
||||||
|
import {useSelector, useDispatch} from 'react-redux';
|
||||||
|
import {saveConfig, selectConfig} from '../../store/slices/configSlice';
|
||||||
|
import {getNestedValue} from '../../utils/getNestedValue';
|
||||||
|
import {mergeNestedObjects} from '../../utils/mergeNestedObjects';
|
||||||
|
import {useFieldValidation} from '../../hooks/useFieldValidation';
|
||||||
|
import PageHeader from '../../components/PageHeader/PageHeader';
|
||||||
|
import PageDescription from '../../components/PageDescription/PageDescription';
|
||||||
|
import Input from '../../components/Input/Input';
|
||||||
|
import Checkbox from '../../components/Checkbox/Checkbox';
|
||||||
|
import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
|
||||||
|
import styles from './NotificationRules.module.scss';
|
||||||
|
|
||||||
|
function NotificationRules() {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const config = useSelector(selectConfig);
|
||||||
|
const {validateField, getFieldError, hasValidationErrors, clearFieldError} = useFieldValidation();
|
||||||
|
|
||||||
|
// Local state for form fields
|
||||||
|
const [localSettings, setLocalSettings] = useState({});
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
const hasInitialized = useRef(false);
|
||||||
|
|
||||||
|
// Configuration paths
|
||||||
|
const CONFIG_PATHS = {
|
||||||
|
licenseExpirationWarningEnable: 'notification.rules.licenseExpirationWarning.enable',
|
||||||
|
licenseExpirationWarningRepeatInterval: 'notification.rules.licenseExpirationWarning.policies.repeatInterval',
|
||||||
|
licenseExpirationErrorEnable: 'notification.rules.licenseExpirationError.enable',
|
||||||
|
licenseExpirationErrorRepeatInterval: 'notification.rules.licenseExpirationError.policies.repeatInterval',
|
||||||
|
licenseLimitEditEnable: 'notification.rules.licenseLimitEdit.enable',
|
||||||
|
licenseLimitEditRepeatInterval: 'notification.rules.licenseLimitEdit.policies.repeatInterval',
|
||||||
|
licenseLimitLiveViewerEnable: 'notification.rules.licenseLimitLiveViewer.enable',
|
||||||
|
licenseLimitLiveViewerRepeatInterval: 'notification.rules.licenseLimitLiveViewer.policies.repeatInterval'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset state and errors to global config
|
||||||
|
const resetToGlobalConfig = useCallback(() => {
|
||||||
|
if (config) {
|
||||||
|
const settings = {};
|
||||||
|
Object.keys(CONFIG_PATHS).forEach(key => {
|
||||||
|
const value = getNestedValue(config, CONFIG_PATHS[key], '');
|
||||||
|
settings[key] = value;
|
||||||
|
});
|
||||||
|
setLocalSettings(settings);
|
||||||
|
setHasChanges(false);
|
||||||
|
// Clear validation errors for all fields
|
||||||
|
Object.values(CONFIG_PATHS).forEach(path => {
|
||||||
|
clearFieldError(path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [config, clearFieldError]);
|
||||||
|
|
||||||
|
// Initialize settings from config when component loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (config && !hasInitialized.current) {
|
||||||
|
resetToGlobalConfig();
|
||||||
|
hasInitialized.current = true;
|
||||||
|
}
|
||||||
|
}, [config, resetToGlobalConfig]);
|
||||||
|
|
||||||
|
// Handle field changes
|
||||||
|
const handleFieldChange = (field, value) => {
|
||||||
|
setLocalSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Validate fields with schema validation
|
||||||
|
if (CONFIG_PATHS[field]) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
validateField(CONFIG_PATHS[field], value);
|
||||||
|
} else if (typeof value === 'boolean') {
|
||||||
|
validateField(CONFIG_PATHS[field], value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are changes
|
||||||
|
const hasFieldChanges = Object.keys(CONFIG_PATHS).some(key => {
|
||||||
|
const currentValue = key === field ? value : localSettings[key];
|
||||||
|
const originalFieldValue = getNestedValue(config, CONFIG_PATHS[key], '');
|
||||||
|
|
||||||
|
// Handle different data types properly
|
||||||
|
if (typeof originalFieldValue === 'boolean') {
|
||||||
|
return currentValue !== originalFieldValue;
|
||||||
|
}
|
||||||
|
return currentValue.toString() !== originalFieldValue.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
setHasChanges(hasFieldChanges);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle save
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!hasChanges) return;
|
||||||
|
|
||||||
|
// Create config update object
|
||||||
|
const configUpdate = {};
|
||||||
|
Object.keys(CONFIG_PATHS).forEach(key => {
|
||||||
|
const path = CONFIG_PATHS[key];
|
||||||
|
const value = localSettings[key];
|
||||||
|
configUpdate[path] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedConfig = mergeNestedObjects([configUpdate]);
|
||||||
|
await dispatch(saveConfig(mergedConfig)).unwrap();
|
||||||
|
setHasChanges(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.notificationRules} ${styles.pageWithFixedSave}`}>
|
||||||
|
<PageHeader>Notification Rules</PageHeader>
|
||||||
|
<PageDescription>Configure email notification rules for license expiration and limit warnings</PageDescription>
|
||||||
|
|
||||||
|
<div className={styles.settingsSection}>
|
||||||
|
<div className={styles.sectionTitle}>License Expiration Warning</div>
|
||||||
|
<div className={styles.sectionDescription}>Configure email notifications when the license is about to expire</div>
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<Checkbox
|
||||||
|
label='Enable'
|
||||||
|
checked={localSettings.licenseExpirationWarningEnable || false}
|
||||||
|
onChange={value => handleFieldChange('licenseExpirationWarningEnable', value)}
|
||||||
|
error={getFieldError(CONFIG_PATHS.licenseExpirationWarningEnable)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<Input
|
||||||
|
label='Repeat Interval:'
|
||||||
|
value={localSettings.licenseExpirationWarningRepeatInterval || ''}
|
||||||
|
onChange={value => handleFieldChange('licenseExpirationWarningRepeatInterval', value)}
|
||||||
|
placeholder='1d'
|
||||||
|
description='How often to repeat the warning (e.g., 1d, 1h, 30m)'
|
||||||
|
error={getFieldError(CONFIG_PATHS.licenseExpirationWarningRepeatInterval)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.settingsSection}>
|
||||||
|
<div className={styles.sectionTitle}>License Expiration Error</div>
|
||||||
|
<div className={styles.sectionDescription}>Configure email notifications when the license has expired</div>
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<Checkbox
|
||||||
|
label='Enable'
|
||||||
|
checked={localSettings.licenseExpirationErrorEnable || false}
|
||||||
|
onChange={value => handleFieldChange('licenseExpirationErrorEnable', value)}
|
||||||
|
error={getFieldError(CONFIG_PATHS.licenseExpirationErrorEnable)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<Input
|
||||||
|
label='Repeat Interval:'
|
||||||
|
value={localSettings.licenseExpirationErrorRepeatInterval || ''}
|
||||||
|
onChange={value => handleFieldChange('licenseExpirationErrorRepeatInterval', value)}
|
||||||
|
placeholder='1d'
|
||||||
|
description='How often to repeat the error notification (e.g., 1d, 1h, 30m)'
|
||||||
|
error={getFieldError(CONFIG_PATHS.licenseExpirationErrorRepeatInterval)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.settingsSection}>
|
||||||
|
<div className={styles.sectionTitle}>License Limit Edit</div>
|
||||||
|
<div className={styles.sectionDescription}>Configure email notifications when the edit limit is reached</div>
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<Checkbox
|
||||||
|
label='Enable'
|
||||||
|
checked={localSettings.licenseLimitEditEnable || false}
|
||||||
|
onChange={value => handleFieldChange('licenseLimitEditEnable', value)}
|
||||||
|
error={getFieldError(CONFIG_PATHS.licenseLimitEditEnable)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<Input
|
||||||
|
label='Repeat Interval:'
|
||||||
|
value={localSettings.licenseLimitEditRepeatInterval || ''}
|
||||||
|
onChange={value => handleFieldChange('licenseLimitEditRepeatInterval', value)}
|
||||||
|
placeholder='1h'
|
||||||
|
description='How often to repeat the limit warning (e.g., 1d, 1h, 30m)'
|
||||||
|
error={getFieldError(CONFIG_PATHS.licenseLimitEditRepeatInterval)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.settingsSection}>
|
||||||
|
<div className={styles.sectionTitle}>License Limit Live Viewer</div>
|
||||||
|
<div className={styles.sectionDescription}>Configure email notifications when the live viewer limit is reached</div>
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<Checkbox
|
||||||
|
label='Enable'
|
||||||
|
checked={localSettings.licenseLimitLiveViewerEnable || false}
|
||||||
|
onChange={value => handleFieldChange('licenseLimitLiveViewerEnable', value)}
|
||||||
|
error={getFieldError(CONFIG_PATHS.licenseLimitLiveViewerEnable)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<Input
|
||||||
|
label='Repeat Interval:'
|
||||||
|
value={localSettings.licenseLimitLiveViewerRepeatInterval || ''}
|
||||||
|
onChange={value => handleFieldChange('licenseLimitLiveViewerRepeatInterval', value)}
|
||||||
|
placeholder='1h'
|
||||||
|
description='How often to repeat the limit warning (e.g., 1d, 1h, 30m)'
|
||||||
|
error={getFieldError(CONFIG_PATHS.licenseLimitLiveViewerRepeatInterval)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FixedSaveButton onClick={handleSave} disabled={!hasChanges || hasValidationErrors()}>
|
||||||
|
Save Changes
|
||||||
|
</FixedSaveButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationRules;
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
.notificationRules {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageWithFixedSave {
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 200px;
|
||||||
|
font-family: 'Open Sans', sans-serif;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsSection {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e2e2e2;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 32px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionDescription {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666666;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formRow {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pageWithFixedSave {
|
.pageWithFixedSave {
|
||||||
padding-bottom: 80px;
|
padding-bottom: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
|
|||||||
@ -244,6 +244,104 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"notification": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"x-scope": ["admin", "tenant"],
|
||||||
|
"properties": {
|
||||||
|
"rules": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"x-scope": ["admin", "tenant"],
|
||||||
|
"properties": {
|
||||||
|
"licenseExpirationWarning": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"x-scope": ["admin", "tenant"],
|
||||||
|
"properties": {
|
||||||
|
"enable": {"type": "boolean", "x-scope": ["admin", "tenant"]},
|
||||||
|
"policies": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"x-scope": ["admin", "tenant"],
|
||||||
|
"properties": {
|
||||||
|
"repeatInterval": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$",
|
||||||
|
"description": "Repeat interval in time format (e.g., '1d', '1h', '30m')",
|
||||||
|
"x-scope": ["admin", "tenant"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"licenseExpirationError": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"x-scope": ["admin", "tenant"],
|
||||||
|
"properties": {
|
||||||
|
"enable": {"type": "boolean", "x-scope": ["admin", "tenant"]},
|
||||||
|
"policies": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"x-scope": ["admin", "tenant"],
|
||||||
|
"properties": {
|
||||||
|
"repeatInterval": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$",
|
||||||
|
"description": "Repeat interval in time format (e.g., '1d', '1h', '30m')",
|
||||||
|
"x-scope": ["admin", "tenant"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"licenseLimitEdit": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"x-scope": ["admin", "tenant"],
|
||||||
|
"properties": {
|
||||||
|
"enable": {"type": "boolean", "x-scope": ["admin", "tenant"]},
|
||||||
|
"policies": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"x-scope": ["admin", "tenant"],
|
||||||
|
"properties": {
|
||||||
|
"repeatInterval": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$",
|
||||||
|
"description": "Repeat interval in time format (e.g., '1d', '1h', '30m')",
|
||||||
|
"x-scope": ["admin", "tenant"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"licenseLimitLiveViewer": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"x-scope": ["admin", "tenant"],
|
||||||
|
"properties": {
|
||||||
|
"enable": {"type": "boolean", "x-scope": ["admin", "tenant"]},
|
||||||
|
"policies": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"x-scope": ["admin", "tenant"],
|
||||||
|
"properties": {
|
||||||
|
"repeatInterval": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$",
|
||||||
|
"description": "Repeat interval in time format (e.g., '1d', '1h', '30m')",
|
||||||
|
"x-scope": ["admin", "tenant"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user