mirror of
https://github.com/ONLYOFFICE/server.git
synced 2026-02-10 18:05:07 +08:00
Merge branch 'release/v9.1.0' into fix/admin-panel-bugs-example
This commit is contained in:
@ -1 +1,2 @@
|
||||
REACT_APP_BACKEND_URL=http://localhost:9000
|
||||
REACT_APP_DOCSERVICE_URL=http://localhost:8000
|
||||
@ -3,3 +3,5 @@
|
||||
|
||||
# Backend URL for API calls
|
||||
REACT_APP_BACKEND_URL=http://localhost:9000
|
||||
# Docservice Backend URL(only for dev mode)
|
||||
REACT_APP_DOCSERVICE_URL=http://localhost:8000
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
const BACKEND_URL = process.env.REACT_APP_BACKEND_URL ?? '';
|
||||
const DOCSERVICE_URL = process.env.REACT_APP_DOCSERVICE_URL;
|
||||
const API_BASE_PATH = '/api/v1/admin';
|
||||
|
||||
export const fetchStatistics = async () => {
|
||||
@ -181,3 +182,27 @@ export const rotateWopiKeys = async () => {
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const checkHealth = async () => {
|
||||
// In development, use proxy path to avoid CORS issues
|
||||
const url = process.env.NODE_ENV === 'development'
|
||||
? '/healthcheck-api'
|
||||
: `/healthcheck`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('DocService is not responding properly');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import SaveButton from '../SaveButton/SaveButton';
|
||||
import styles from './FixedSaveButton.module.scss';
|
||||
|
||||
function FixedSaveButton({onClick, disabled, children = 'Save Changes'}) {
|
||||
function FixedSaveButton({onClick, disabled, children = 'Save Changes', disableResult = false}) {
|
||||
return (
|
||||
<div className={styles.fixedSaveContainer}>
|
||||
<div className={styles.saveButtonWrapper}>
|
||||
<SaveButton onClick={onClick} disabled={disabled}>
|
||||
<SaveButton onClick={onClick} disabled={disabled} disableResult={disableResult}>
|
||||
{children}
|
||||
</SaveButton>
|
||||
</div>
|
||||
|
||||
@ -4,7 +4,7 @@ import Spinner from '../../assets/Spinner.svg';
|
||||
import Success from '../../assets/Success.svg';
|
||||
import Fail from '../../assets/Fail.svg';
|
||||
|
||||
function SaveButton({onClick, children = 'Save Changes', disabled = false}) {
|
||||
function SaveButton({onClick, children = 'Save Changes', disabled = false, disableResult = false}) {
|
||||
const [state, setState] = useState('idle'); // 'idle', 'loading', 'success', 'error'
|
||||
|
||||
// Reset to idle after showing success/error for 3 seconds
|
||||
@ -23,10 +23,18 @@ function SaveButton({onClick, children = 'Save Changes', disabled = false}) {
|
||||
setState('loading');
|
||||
try {
|
||||
await onClick();
|
||||
setState('success');
|
||||
if (!disableResult) {
|
||||
setState('success');
|
||||
} else {
|
||||
setState('idle');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error);
|
||||
setState('error');
|
||||
if (!disableResult) {
|
||||
setState('error');
|
||||
} else {
|
||||
setState('idle');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import WOPISettings from '../pages/WOPISettings/WOPISettings';
|
||||
import Expiration from '../pages/Expiration/Expiration';
|
||||
import SecuritySettings from '../pages/SecuritySettings/SecuritySettings';
|
||||
import EmailConfig from '../pages/EmailConfig/EmailConfig';
|
||||
import NotificationRules from '../pages/NotificationRules/NotificationRules';
|
||||
import EmailConfig from '../pages/NotitifcationConfig/NotificationConfig';
|
||||
import FileLimits from '../pages/FileLimits/FileLimits';
|
||||
import RequestFiltering from '../pages/RequestFiltering/RequestFiltering';
|
||||
import LoggerConfig from '../pages/LoggerConfig/LoggerConfig';
|
||||
import Statistics from '../pages/Statistics';
|
||||
import ChangePassword from '../pages/ChangePassword/ChangePassword';
|
||||
import Example from '../pages/Example/Example';
|
||||
import HealthCheck from '../pages/HealthCheck/HealthCheck';
|
||||
|
||||
export const menuItems = [
|
||||
{key: 'statistics', label: 'Statistics', path: '/statistics', component: Statistics},
|
||||
@ -18,8 +18,8 @@ export const menuItems = [
|
||||
{key: 'expiration', label: 'Expiration', path: '/expiration', component: Expiration},
|
||||
{key: 'request-filtering', label: 'Request Filtering', path: '/request-filtering', component: RequestFiltering},
|
||||
{key: 'wopi-settings', label: 'WOPI Settings', path: '/wopi-settings', component: WOPISettings},
|
||||
{key: 'email-config', label: 'Email Config', path: '/email-config', component: EmailConfig},
|
||||
{key: 'notifications', label: 'Notifications', path: '/notifications', component: EmailConfig},
|
||||
{key: 'logger-config', label: 'Logger Config', path: '/logger-config', component: LoggerConfig},
|
||||
{key: 'notification-rules', label: 'Notification Rules', path: '/notification-rules', component: NotificationRules},
|
||||
{key: 'healthcheck', label: 'Health Check', path: '/healthcheck', component: HealthCheck},
|
||||
{key: 'change-password', label: 'Change Password', path: '/change-password', component: ChangePassword}
|
||||
];
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {useState, useRef} from 'react';
|
||||
import {useState} from 'react';
|
||||
import {changePassword} from '../../api';
|
||||
import PageHeader from '../../components/PageHeader/PageHeader';
|
||||
import PageDescription from '../../components/PageDescription/PageDescription';
|
||||
@ -12,7 +12,6 @@ function ChangePassword() {
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [passwordSuccess, setPasswordSuccess] = useState(false);
|
||||
const saveButtonRef = useRef();
|
||||
|
||||
const handlePasswordChange = async () => {
|
||||
setPasswordError('');
|
||||
@ -90,7 +89,7 @@ function ChangePassword() {
|
||||
description='Re-enter your new password'
|
||||
/>
|
||||
|
||||
<FixedSaveButton ref={saveButtonRef} onClick={handlePasswordChange} />
|
||||
<FixedSaveButton onClick={handlePasswordChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
|
||||
.section {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
padding: 32px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
|
||||
71
AdminPanel/client/src/pages/HealthCheck/HealthCheck.js
Normal file
71
AdminPanel/client/src/pages/HealthCheck/HealthCheck.js
Normal file
@ -0,0 +1,71 @@
|
||||
import {useState, useEffect} from 'react';
|
||||
import {checkHealth} from '../../api';
|
||||
import PageHeader from '../../components/PageHeader/PageHeader';
|
||||
import PageDescription from '../../components/PageDescription/PageDescription';
|
||||
import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
|
||||
import styles from './HealthCheck.module.scss';
|
||||
|
||||
function HealthCheck() {
|
||||
const [healthStatus, setHealthStatus] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchHealthStatus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const status = await checkHealth();
|
||||
setHealthStatus(status);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setHealthStatus(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchHealthStatus();
|
||||
}, []);
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (loading) return '#666';
|
||||
if (error) return '#dc3545';
|
||||
return '#28a745';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${styles.healthCheck} ${styles.pageWithFixedSave}`}>
|
||||
<PageHeader>Health Check</PageHeader>
|
||||
<PageDescription>Monitor the status of DocService backend</PageDescription>
|
||||
|
||||
<div className={styles.statusCard}>
|
||||
<div className={styles.statusHeader}>
|
||||
<div className={styles.statusIndicator} style={{backgroundColor: getStatusColor()}} />
|
||||
<h3 className={styles.statusTitle}>DocService Status</h3>
|
||||
</div>
|
||||
|
||||
<div className={styles.statusContent}>
|
||||
{error && (
|
||||
<div className={styles.error}>
|
||||
<h4>{error}</h4>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{healthStatus && (
|
||||
<div className={styles.success}>
|
||||
<h4>Service is healthy</h4>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<FixedSaveButton onClick={fetchHealthStatus} disabled={loading} disableResult={true}>
|
||||
{loading ? 'Checking...' : 'Refresh'}
|
||||
</FixedSaveButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HealthCheck;
|
||||
@ -0,0 +1,96 @@
|
||||
.healthCheck {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pageWithFixedSave {
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.statusCard {
|
||||
background: white;
|
||||
border: 1px solid #e2e2e2;
|
||||
border-radius: 8px;
|
||||
padding: 32px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.statusHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.statusIndicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statusTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #f3f3f3;
|
||||
border-top: 2px solid #007bff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc3545;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #28a745;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.statusDetails {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
overflow-x: auto;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,11 @@
|
||||
.loggerConfig {
|
||||
padding: 24px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
|
||||
&.pageWithFixedSave {
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.configSection {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
padding: 32px;
|
||||
border: 1px solid #e0e0e0;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@ -1,211 +0,0 @@
|
||||
import {useState, useRef} 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 = () => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize settings from config when component loads (only once)
|
||||
if (config && !hasInitialized.current) {
|
||||
resetToGlobalConfig();
|
||||
hasInitialized.current = true;
|
||||
}
|
||||
|
||||
// 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;
|
||||
@ -1,46 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@ -10,12 +10,13 @@ import Tabs from '../../components/Tabs/Tabs';
|
||||
import Input from '../../components/Input/Input';
|
||||
import Checkbox from '../../components/Checkbox/Checkbox';
|
||||
import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
|
||||
import styles from './EmailConfig.module.scss';
|
||||
import styles from './NotificationConfig.module.scss';
|
||||
|
||||
const emailConfigTabs = [
|
||||
{key: 'notifications', label: 'Notification Rules'},
|
||||
{key: 'smtp-server', label: 'SMTP Server'},
|
||||
{key: 'defaults', label: 'Default Emails'},
|
||||
{key: 'security', label: 'Security'},
|
||||
{key: 'defaults', label: 'Default Emails'}
|
||||
];
|
||||
|
||||
function EmailConfig() {
|
||||
@ -23,7 +24,7 @@ function EmailConfig() {
|
||||
const config = useSelector(selectConfig);
|
||||
const {validateField, getFieldError, hasValidationErrors, clearFieldError} = useFieldValidation();
|
||||
|
||||
const [activeTab, setActiveTab] = useState('smtp-server');
|
||||
const [activeTab, setActiveTab] = useState('notifications');
|
||||
|
||||
// Local state for form fields
|
||||
const [localSettings, setLocalSettings] = useState({
|
||||
@ -34,7 +35,15 @@ function EmailConfig() {
|
||||
disableFileAccess: false,
|
||||
disableUrlAccess: false,
|
||||
defaultFromEmail: '',
|
||||
defaultToEmail: ''
|
||||
defaultToEmail: '',
|
||||
licenseExpirationWarningEnable: false,
|
||||
licenseExpirationWarningRepeatInterval: '',
|
||||
licenseExpirationErrorEnable: false,
|
||||
licenseExpirationErrorRepeatInterval: '',
|
||||
licenseLimitEditEnable: false,
|
||||
licenseLimitEditRepeatInterval: '',
|
||||
licenseLimitLiveViewerEnable: false,
|
||||
licenseLimitLiveViewerRepeatInterval: ''
|
||||
});
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const hasInitialized = useRef(false);
|
||||
@ -48,7 +57,15 @@ function EmailConfig() {
|
||||
disableFileAccess: 'email.connectionConfiguration.disableFileAccess',
|
||||
disableUrlAccess: 'email.connectionConfiguration.disableUrlAccess',
|
||||
defaultFromEmail: 'email.contactDefaults.from',
|
||||
defaultToEmail: 'email.contactDefaults.to'
|
||||
defaultToEmail: 'email.contactDefaults.to',
|
||||
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
|
||||
@ -249,6 +266,102 @@ function EmailConfig() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'notifications':
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@ -256,8 +369,8 @@ function EmailConfig() {
|
||||
|
||||
return (
|
||||
<div className={`${styles.emailConfig} ${styles.pageWithFixedSave}`}>
|
||||
<PageHeader>Email Configuration</PageHeader>
|
||||
<PageDescription>Configure SMTP server settings, security options, and default email addresses</PageDescription>
|
||||
<PageHeader>Notifications</PageHeader>
|
||||
<PageDescription>Configure SMTP server settings, security options, default email addresses, and notification rules</PageDescription>
|
||||
|
||||
<Tabs tabs={emailConfigTabs} activeTab={activeTab} onTabChange={handleTabChange}>
|
||||
{renderTabContent()}
|
||||
@ -8,20 +8,25 @@
|
||||
}
|
||||
|
||||
.settingsSection {
|
||||
margin-bottom: 32px;
|
||||
padding: 32px;
|
||||
background: #fff;
|
||||
background: white;
|
||||
border: 1px solid #e2e2e2;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 32px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sectionDescription {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.formRow {
|
||||
@ -33,7 +33,16 @@ module.exports = (env, argv) => {
|
||||
},
|
||||
port: 3000,
|
||||
open: true,
|
||||
historyApiFallback: true
|
||||
historyApiFallback: true,
|
||||
proxy: {
|
||||
'/healthcheck-api': {
|
||||
target: process.env.REACT_APP_DOCSERVICE_URL,
|
||||
changeOrigin: true,
|
||||
pathRewrite: {
|
||||
'^/healthcheck-api': '/healthcheck'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
plugins: [
|
||||
@ -55,7 +64,8 @@ module.exports = (env, argv) => {
|
||||
]
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.REACT_APP_BACKEND_URL': JSON.stringify(process.env.REACT_APP_BACKEND_URL)
|
||||
'process.env.REACT_APP_BACKEND_URL': JSON.stringify(process.env.REACT_APP_BACKEND_URL),
|
||||
'process.env.REACT_APP_DOCSERVICE_URL': JSON.stringify(process.env.REACT_APP_DOCSERVICE_URL)
|
||||
})
|
||||
],
|
||||
|
||||
|
||||
10
AdminPanel/server/sources/bootstrap.js
vendored
10
AdminPanel/server/sources/bootstrap.js
vendored
@ -34,6 +34,7 @@
|
||||
|
||||
const crypto = require('crypto');
|
||||
const runtimeConfigManager = require('../../../Common/sources/runtimeConfigManager');
|
||||
const passwordManager = require('./passwordManager');
|
||||
|
||||
const BOOTSTRAP_TOKEN_TTL = 1 * 60 * 60 * 1000; // 1 hour
|
||||
const BOOTSTRAP_CODE_LENGTH = 12; // 12 chars = ~62 bits entropy (4.7 quintillion combinations)
|
||||
@ -129,11 +130,18 @@ async function verifyBootstrapToken(ctx, providedCode) {
|
||||
}
|
||||
|
||||
// Check if setup already completed
|
||||
// Invalid or old format is treated as no password set
|
||||
const config = await runtimeConfigManager.getConfig(ctx);
|
||||
if (config?.adminPanel?.passwordHash) {
|
||||
const passwordHash = config?.adminPanel?.passwordHash;
|
||||
|
||||
if (passwordManager.isValidPasswordHash(passwordHash)) {
|
||||
ctx.logger.warn('Bootstrap code rejected: setup already completed');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (passwordHash && !passwordManager.isValidPasswordHash(passwordHash)) {
|
||||
ctx.logger.info('Invalid password hash format detected - allowing bootstrap for re-setup');
|
||||
}
|
||||
|
||||
// Stateless verification: check if code matches current or previous time window
|
||||
// This allows codes from any pod in cluster, as long as they have same secret
|
||||
|
||||
@ -119,8 +119,46 @@ async function verifyPassword(password, hash) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if password hash is valid (proper MCF format)
|
||||
* @param {string} hash - Password hash to validate
|
||||
* @returns {boolean} True if hash is in valid MCF format
|
||||
*/
|
||||
function isValidPasswordHash(hash) {
|
||||
if (!hash || typeof hash !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must start with correct MCF prefix
|
||||
if (!hash.startsWith('$pbkdf2-sha256$')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must have correct structure: $pbkdf2-sha256$iterations$salt$hash
|
||||
const parts = hash.split('$');
|
||||
if (parts.length !== 5) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [, , iterationsStr, saltBase64, hashBase64] = parts;
|
||||
|
||||
// Validate iterations is a number
|
||||
const iterations = parseInt(iterationsStr, 10);
|
||||
if (!iterations || iterations < 1000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate salt and hash are present and reasonable length
|
||||
if (!saltBase64 || saltBase64.length < 10 || !hashBase64 || hashBase64.length < 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if AdminPanel setup is required (no password configured or invalid format)
|
||||
* Invalid or old format is treated as no password set
|
||||
* @param {import('./operationContext').Context} ctx - Operation context
|
||||
* @returns {Promise<boolean>} True if setup is required
|
||||
*/
|
||||
@ -128,15 +166,11 @@ async function isSetupRequired(ctx) {
|
||||
const config = await runtimeConfigManager.getConfig(ctx);
|
||||
const passwordHash = config?.adminPanel?.passwordHash;
|
||||
|
||||
// No password hash - setup required
|
||||
if (!passwordHash) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if hash is in MCF format (new format)
|
||||
// Old format (salt:hash) is considered invalid - requires re-setup
|
||||
if (!passwordHash.startsWith('$pbkdf2-sha256$')) {
|
||||
ctx.logger.warn('Password hash in old format detected - setup required');
|
||||
// No password hash or invalid format - setup required
|
||||
if (!isValidPasswordHash(passwordHash)) {
|
||||
if (passwordHash) {
|
||||
ctx.logger.warn('Invalid password hash format detected - setup required');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -160,6 +194,7 @@ async function saveAdminPassword(ctx, password) {
|
||||
|
||||
/**
|
||||
* Verify admin password against stored hash
|
||||
* Invalid or old format is treated as no password set - returns false
|
||||
* @param {import('./operationContext').Context} ctx - Operation context
|
||||
* @param {string} password - Plain text password to verify
|
||||
* @returns {Promise<boolean>} True if password matches stored hash
|
||||
@ -167,15 +202,22 @@ async function saveAdminPassword(ctx, password) {
|
||||
async function verifyAdminPassword(ctx, password) {
|
||||
const config = await runtimeConfigManager.getConfig(ctx);
|
||||
const hash = config?.adminPanel?.passwordHash;
|
||||
if (!hash) {
|
||||
|
||||
// No hash or invalid format - treat as no password set
|
||||
if (!isValidPasswordHash(hash)) {
|
||||
if (hash) {
|
||||
ctx.logger.warn('Invalid password hash format detected - authentication rejected, re-setup required');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return verifyPassword(password, hash);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
isValidPasswordHash,
|
||||
isSetupRequired,
|
||||
saveAdminPassword,
|
||||
verifyAdminPassword,
|
||||
|
||||
@ -8,7 +8,6 @@ const passwordManager = require('../../passwordManager');
|
||||
const bootstrap = require('../../bootstrap');
|
||||
|
||||
const adminPanelJwtSecret = config.get('adminPanel.jwtSecret');
|
||||
const isDevelopment = process.env.NODE_ENV.startsWith('development-');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@ -18,12 +17,13 @@ router.use(cookieParser());
|
||||
/**
|
||||
* Create session cookie with standard options
|
||||
* @param {import('express').Response} res - Express response
|
||||
* @param {import('express').Request} req - Express request
|
||||
* @param {string} token - JWT token
|
||||
*/
|
||||
function setAuthCookie(res, token) {
|
||||
function setAuthCookie(res, req, token) {
|
||||
res.cookie('accessToken', token, {
|
||||
httpOnly: true,
|
||||
secure: !isDevelopment,
|
||||
secure: req.secure,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 1000,
|
||||
path: '/'
|
||||
@ -128,7 +128,7 @@ router.post('/setup', async (req, res) => {
|
||||
await bootstrap.invalidateBootstrapToken(ctx);
|
||||
|
||||
const token = jwt.sign({tenant: 'localhost', isAdmin: true}, adminPanelJwtSecret, {expiresIn: '1h'});
|
||||
setAuthCookie(res, token);
|
||||
setAuthCookie(res, req, token);
|
||||
|
||||
ctx.logger.info('AdminPanel setup completed successfully');
|
||||
res.json({message: 'Setup completed successfully'});
|
||||
@ -204,7 +204,7 @@ router.post('/login', async (req, res) => {
|
||||
}
|
||||
|
||||
const token = jwt.sign({tenant: 'localhost', isAdmin: true}, adminPanelJwtSecret, {expiresIn: '1h'});
|
||||
setAuthCookie(res, token);
|
||||
setAuthCookie(res, req, token);
|
||||
|
||||
ctx.logger.info('AdminPanel login successful');
|
||||
res.json({tenant: 'localhost', isAdmin: true});
|
||||
|
||||
@ -57,6 +57,9 @@ const port = config.get('adminPanel.port');
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
|
||||
// Trust first proxy for X-Forwarded-* headers (nginx, load balancer)
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
const server = http.createServer(app);
|
||||
|
||||
// Initialize license on startup
|
||||
|
||||
Reference in New Issue
Block a user