Merge branch 'release/v9.1.0' into fix/admin-panel-bugs-example

This commit is contained in:
PauI Ostrovckij
2025-10-03 16:33:59 +03:00
20 changed files with 431 additions and 311 deletions

View File

@ -1 +1,2 @@
REACT_APP_BACKEND_URL=http://localhost:9000
REACT_APP_DOCSERVICE_URL=http://localhost:8000

View File

@ -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

View File

@ -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;
}
};

View File

@ -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>

View File

@ -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');
}
}
};

View File

@ -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}
];

View File

@ -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>

View File

@ -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 {

View 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;

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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()}

View File

@ -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 {

View File

@ -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)
})
],

View File

@ -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

View File

@ -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,

View File

@ -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});

View File

@ -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