Merge pull request 'feature/admin-panel-features' (#62) from feature/admin-panel-features into release/v9.1.0

Reviewed-on: https://git.onlyoffice.com/ONLYOFFICE/server/pulls/62
This commit is contained in:
Sergey Konovalov
2025-09-19 23:24:38 +00:00
38 changed files with 938 additions and 234 deletions

View File

@ -1 +1 @@
REACT_APP_BACKEND_URL=http://localhost:9000
REACT_APP_BACKEND_URL=http://localhost:9000

View File

@ -1 +1,5 @@
REACT_APP_BACKEND_URL=http://localhost:9000
# Admin Panel Environment Variables
# Copy this file to .env for local development
# Backend URL for API calls
REACT_APP_BACKEND_URL=http://localhost:9000

View File

@ -2768,6 +2768,12 @@
"is-obj": "^2.0.0"
}
},
"dotenv": {
"version": "17.2.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz",
"integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==",
"dev": true
},
"dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

@ -3,7 +3,7 @@
"version": "1.3.0",
"private": true,
"scripts": {
"start": "set \"REACT_APP_BACKEND_URL=http://localhost:9000\" && webpack serve --mode=development",
"start": "webpack serve --mode=development",
"build": "webpack --mode=production"
},
"dependencies": {
@ -29,6 +29,7 @@
"babel-loader": "8.2.0",
"copy-webpack-plugin": "11.0.0",
"css-loader": "^6.2.0",
"dotenv": "^17.2.2",
"file-loader": "^6.2.0",
"html-webpack-plugin": "5.5.0",
"sass": "^1.77.0",

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="images/favicon.ico" />
<link rel="icon" href="./images/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Document Server Admin Panel" />

View File

@ -74,3 +74,13 @@ body::-webkit-scrollbar {
body::-webkit-scrollbar-thumb {
background: #efefef;
}
/* Spinner animation */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@ -1,29 +1,58 @@
import {Provider} from 'react-redux';
import {Routes, Route, Navigate} from 'react-router-dom';
import {Routes, Route, Navigate, BrowserRouter} from 'react-router-dom';
import './App.css';
import {store} from './store';
import AuthWrapper from './components/AuthWrapper/AuthWrapper';
import ConfigLoader from './components/ConfigLoader/ConfigLoader';
import Menu from './components/Menu/Menu';
import {menuItems} from './config/menuItems';
/**
* Simple basename computation from URL path.
* Basename is everything before the last path segment.
* Examples:
* - '/statistics' -> basename ''
* - '/admin/' -> basename '/admin'
* - '/admin/statistics' -> basename '/admin'
* - '/admin/su/statistics' -> basename '/admin/su'
* @returns {string} basename
*/
const getBasename = () => {
const path = window.location.pathname || '/';
if (path === '/') return '';
// Treat '/prefix/' as a directory prefix
if (path.endsWith('/')) return path.slice(0, -1);
// Remove trailing slash (keep root '/') for consistent parsing
const normalized = path;
const lastSlash = normalized.lastIndexOf('/');
// If no parent directory, there is no basename
if (lastSlash <= 0) return '';
return normalized.slice(0, lastSlash);
};
function App() {
const basename = getBasename();
return (
<Provider store={store}>
<div className='app'>
<AuthWrapper>
<div className='appLayout'>
<Menu />
<div className='mainContent'>
<Routes>
<Route path='/' element={<Navigate to='/statistics' replace />} />
{menuItems.map(item => (
<Route key={item.key} path={item.path} element={<item.component />} />
))}
</Routes>
<BrowserRouter basename={basename}>
<div className='app'>
<AuthWrapper>
<div className='appLayout'>
<Menu />
<div className='mainContent'>
<ConfigLoader>
<Routes>
<Route path='/' element={<Navigate to='/statistics' replace />} />
{menuItems.map(item => (
<Route key={item.key} path={item.path} element={<item.component />} />
))}
</Routes>
</ConfigLoader>
</div>
</div>
</div>
</AuthWrapper>
</div>
</AuthWrapper>
</div>
</BrowserRouter>
</Provider>
);
}

View File

@ -1,7 +1,8 @@
const BACKEND_URL = process.env.REACT_APP_BACKEND_URL ?? '';
const API_BASE_PATH = '/api/v1/admin';
export const fetchStatistics = async () => {
const response = await fetch(`${BACKEND_URL}/api/v1/admin/stat`);
const response = await fetch(`${BACKEND_URL}${API_BASE_PATH}/stat`);
if (!response.ok) {
throw new Error('Failed to fetch statistics');
}
@ -9,7 +10,7 @@ export const fetchStatistics = async () => {
};
export const fetchConfiguration = async () => {
const response = await fetch(`${BACKEND_URL}/api/v1/admin/config`, {
const response = await fetch(`${BACKEND_URL}${API_BASE_PATH}/config`, {
credentials: 'include'
});
if (!response.ok) {
@ -19,7 +20,7 @@ export const fetchConfiguration = async () => {
};
export const fetchConfigurationSchema = async () => {
const response = await fetch(`${BACKEND_URL}/api/v1/admin/config/schema`, {
const response = await fetch(`${BACKEND_URL}${API_BASE_PATH}/config/schema`, {
credentials: 'include'
});
if (!response.ok) {
@ -29,7 +30,7 @@ export const fetchConfigurationSchema = async () => {
};
export const updateConfiguration = async configData => {
const response = await fetch(`${BACKEND_URL}/api/v1/admin/config`, {
const response = await fetch(`${BACKEND_URL}${API_BASE_PATH}/config`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
@ -49,17 +50,12 @@ export const updateConfiguration = async configData => {
}
}
// Try to parse as JSON, fallback to text if it's not JSON
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return response.json();
} else {
return response.text();
}
// Return the new config from the server
return response.json();
};
export const fetchCurrentUser = async () => {
const response = await fetch(`${BACKEND_URL}/api/v1/admin/me`, {
const response = await fetch(`${BACKEND_URL}${API_BASE_PATH}/me`, {
method: 'GET',
credentials: 'include' // Include cookies in the request
});
@ -75,7 +71,7 @@ export const fetchCurrentUser = async () => {
};
export const login = async ({tenantName, secret}) => {
const response = await fetch(`${BACKEND_URL}/api/v1/admin/login`, {
const response = await fetch(`${BACKEND_URL}${API_BASE_PATH}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@ -95,7 +91,7 @@ export const login = async ({tenantName, secret}) => {
};
export const logout = async () => {
const response = await fetch(`${BACKEND_URL}/api/v1/admin/logout`, {
const response = await fetch(`${BACKEND_URL}${API_BASE_PATH}/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@ -109,3 +105,20 @@ export const logout = async () => {
return response.json();
};
export const rotateWopiKeys = async () => {
const response = await fetch(`${BACKEND_URL}${API_BASE_PATH}/wopi/rotate-keys`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to rotate WOPI keys');
}
return response.json();
};

View File

@ -0,0 +1,86 @@
import {useEffect} from 'react';
import {useSelector, useDispatch} from 'react-redux';
import {
selectConfig,
selectConfigLoading,
selectConfigError,
selectSchema,
selectSchemaLoading,
selectSchemaError,
fetchConfig,
fetchSchema
} from '../../store/slices/configSlice';
const ConfigLoader = ({children}) => {
const dispatch = useDispatch();
const config = useSelector(selectConfig);
const configLoading = useSelector(selectConfigLoading);
const configError = useSelector(selectConfigError);
const schema = useSelector(selectSchema);
const schemaLoading = useSelector(selectSchemaLoading);
const schemaError = useSelector(selectSchemaError);
const loading = configLoading || schemaLoading;
const error = configError || schemaError;
useEffect(() => {
// Fetch config if not loaded
if (!config && !configLoading && !configError) {
dispatch(fetchConfig());
}
// Fetch schema if not loaded (only once per session)
if (!schema && !schemaLoading && !schemaError) {
dispatch(fetchSchema());
}
}, [config, configLoading, configError, schema, schemaLoading, schemaError, dispatch]);
if (loading) {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
flexDirection: 'column',
gap: '16px'
}}
>
<svg width='50' height='50' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg' style={{animation: 'spin 1s linear infinite'}}>
<path
d='M9.06812 15.75C5.29857 15.75 2.25568 12.735 2.25568 9C2.25568 5.265 5.29857 2.25 9.06812 2.25C10.8812 2.25 12.5247 2.97759 13.7397 4.12194C13.8255 4.20274 13.9152 4.2797 14.0198 4.33409C14.3161 4.48823 14.9843 4.74308 15.487 4.245C15.9865 3.75001 15.7356 3.09308 15.5798 2.79677C15.5233 2.6894 15.4438 2.59682 15.3556 2.51353C13.7181 0.967092 11.5151 0 9.06812 0C4.05719 0 0 4.035 0 9C0 13.965 4.05719 18 9.06812 18C13.0816 18 16.4798 15.4184 17.6694 11.8342C17.8962 11.1509 17.3444 10.5 16.6244 10.5C16.0825 10.5 15.6221 10.8784 15.4283 11.3844C14.4527 13.9315 11.9806 15.75 9.06812 15.75Z'
fill='#333'
/>
</svg>
<p>Loading configuration...</p>
</div>
);
}
if (error) {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
flexDirection: 'column',
gap: '16px'
}}
>
<p style={{color: 'red'}}>Error loading configuration: {error}</p>
<button onClick={() => dispatch(fetchConfig())}>Retry</button>
</div>
);
}
if (!config || !schema) {
return null;
}
return children;
};
export default ConfigLoader;

View File

@ -0,0 +1,16 @@
import SaveButton from '../SaveButton/SaveButton';
import styles from './FixedSaveButton.module.scss';
function FixedSaveButton({onClick, disabled, children = 'Save Changes'}) {
return (
<div className={styles.fixedSaveContainer}>
<div className={styles.saveButtonWrapper}>
<SaveButton onClick={onClick} disabled={disabled}>
{children}
</SaveButton>
</div>
</div>
);
}
export default FixedSaveButton;

View File

@ -0,0 +1,26 @@
.fixedSaveContainer {
position: fixed;
bottom: 0;
left: 256px;
right: 0;
background: #ffffff;
border-top: 1px solid #e2e2e2;
// box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
z-index: 1000;
padding: 16px 0;
width: calc(100% - 256px);
// background-color: #fafafa;
}
.saveButtonWrapper {
// width: calc(100% - 256px);
margin: 0 auto;
padding: 0 24px;
display: flex;
justify-content: flex-start;
}
// Add bottom padding to pages to prevent content from being hidden behind fixed button
.pageWithFixedSave {
padding-bottom: 40px; // Adjust based on button height + padding
}

View File

@ -1,6 +1,7 @@
import {useSelector} from 'react-redux';
import {useSelector, useDispatch} from 'react-redux';
import {useLocation, useNavigate} from 'react-router-dom';
import {selectIsAuthenticated} from '../../store/slices/userSlice';
import {clearConfig} from '../../store/slices/configSlice';
import {logout} from '../../api';
import MenuItem from './MenuItem/MenuItem';
import AppMenuLogo from '../../assets/AppMenuLogo.svg';
@ -10,6 +11,7 @@ import styles from './Menu.module.scss';
function Menu() {
const location = useLocation();
const navigate = useNavigate();
const dispatch = useDispatch();
const isAuthenticated = useSelector(selectIsAuthenticated);
const handleLogout = async () => {
@ -25,11 +27,13 @@ function Menu() {
};
const handleMenuItemClick = item => {
// Clear config to force reload when switching pages
dispatch(clearConfig());
navigate(item.path);
};
const isActiveItem = path => {
return location.pathname === path;
return location.pathname.endsWith(path);
};
return (

View File

@ -1,7 +1,8 @@
import {useState, useEffect, useCallback} from 'react';
import {useSelector} from 'react-redux';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import {fetchConfigurationSchema} from '../api';
import {selectSchema, selectSchemaLoading, selectSchemaError} from '../store/slices/configSlice';
// Cron expression with 6 space-separated fields (server-compatible)
const CRON6_REGEX = /^\s*\S+(?:\s+\S+){5}\s*$/;
@ -12,19 +13,15 @@ const CRON6_REGEX = /^\s*\S+(?:\s+\S+){5}\s*$/;
*/
export const useFieldValidation = () => {
const [validator, setValidator] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [fieldErrors, setFieldErrors] = useState({});
// Initialize validator with schema from backend
const schema = useSelector(selectSchema);
const isLoading = useSelector(selectSchemaLoading);
const error = useSelector(selectSchemaError);
useEffect(() => {
const initializeValidator = async () => {
if (schema && !validator) {
try {
setIsLoading(true);
setError(null);
const schema = await fetchConfigurationSchema();
// Build AJV validator with custom and standard formats
const ajv = new Ajv({allErrors: true, strict: false});
addFormats(ajv); // Add standard formats including email
@ -34,14 +31,9 @@ export const useFieldValidation = () => {
setValidator(() => validateFn);
} catch (err) {
console.error('Failed to initialize field validator:', err);
setError(err.message);
} finally {
setIsLoading(false);
}
};
initializeValidator();
}, []);
}
}, [schema, validator]);
/**
* Validates a single field value against the schema

View File

@ -1,6 +1,5 @@
import {StrictMode} from 'react';
import ReactDOM from 'react-dom/client';
import {BrowserRouter} from 'react-router-dom';
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
import App from './App';
@ -18,9 +17,7 @@ const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
<App />
</QueryClientProvider>
</StrictMode>
);

View File

@ -1,6 +1,6 @@
import {useState, useEffect} from 'react';
import {useState, useRef} from 'react';
import {useSelector, useDispatch} from 'react-redux';
import {fetchConfig, saveConfig, selectConfig, selectConfigLoading} from '../../store/slices/configSlice';
import {saveConfig, selectConfig} from '../../store/slices/configSlice';
import {getNestedValue} from '../../utils/getNestedValue';
import {mergeNestedObjects} from '../../utils/mergeNestedObjects';
import {useFieldValidation} from '../../hooks/useFieldValidation';
@ -9,7 +9,7 @@ import PageDescription from '../../components/PageDescription/PageDescription';
import Tabs from '../../components/Tabs/Tabs';
import Input from '../../components/Input/Input';
import Checkbox from '../../components/Checkbox/Checkbox';
import SaveButton from '../../components/SaveButton/SaveButton';
import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
import styles from './EmailConfig.module.scss';
const emailConfigTabs = [
@ -21,8 +21,7 @@ const emailConfigTabs = [
function EmailConfig() {
const dispatch = useDispatch();
const config = useSelector(selectConfig);
const loading = useSelector(selectConfigLoading);
const {validateField, getFieldError, hasValidationErrors} = useFieldValidation();
const {validateField, getFieldError, hasValidationErrors, clearFieldError} = useFieldValidation();
const [activeTab, setActiveTab] = useState('smtp-server');
@ -38,6 +37,8 @@ function EmailConfig() {
defaultToEmail: ''
});
const [hasChanges, setHasChanges] = useState(false);
const hasInitialized = useRef(false);
// Configuration paths
const CONFIG_PATHS = {
smtpHost: 'email.smtpServerConfiguration.host',
@ -50,11 +51,9 @@ function EmailConfig() {
defaultToEmail: 'email.contactDefaults.to'
};
// Load config data when component mounts
useEffect(() => {
if (!config) {
dispatch(fetchConfig());
} else {
// 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], '');
@ -62,8 +61,24 @@ function EmailConfig() {
});
setLocalSettings(settings);
setHasChanges(false);
// Clear validation errors for all fields
Object.values(CONFIG_PATHS).forEach(path => {
clearFieldError(path);
});
}
}, [dispatch, config]);
};
// Initialize settings from config when component loads (only once)
if (config && !hasInitialized.current) {
resetToGlobalConfig();
hasInitialized.current = true;
}
// Handle tab change and reset state
const handleTabChange = newTab => {
setActiveTab(newTab);
resetToGlobalConfig();
};
// Handle field changes
const handleFieldChange = (field, value) => {
@ -239,24 +254,18 @@ function EmailConfig() {
}
};
if (loading) {
return <div className={styles.loading}>Loading email configuration...</div>;
}
return (
<div className={styles.emailConfig}>
<div className={`${styles.emailConfig} ${styles.pageWithFixedSave}`}>
<PageHeader>Email Configuration</PageHeader>
<PageDescription>Configure SMTP server settings, security options, and default email addresses</PageDescription>
<Tabs tabs={emailConfigTabs} activeTab={activeTab} onTabChange={setActiveTab}>
<Tabs tabs={emailConfigTabs} activeTab={activeTab} onTabChange={handleTabChange}>
{renderTabContent()}
</Tabs>
<div className={styles.actions}>
<SaveButton onClick={handleSave} disabled={!hasChanges || hasValidationErrors()}>
Save Changes
</SaveButton>
</div>
<FixedSaveButton onClick={handleSave} disabled={!hasChanges || hasValidationErrors()}>
Save Changes
</FixedSaveButton>
</div>
);
}

View File

@ -43,3 +43,7 @@
display: flex;
justify-content: flex-start;
}
.pageWithFixedSave {
padding-bottom: 40px;
}

View File

@ -1,6 +1,6 @@
import {useState, useEffect} from 'react';
import {useState, useRef} from 'react';
import {useSelector, useDispatch} from 'react-redux';
import {fetchConfig, saveConfig, selectConfig, selectConfigLoading} from '../../store/slices/configSlice';
import {saveConfig, selectConfig} from '../../store/slices/configSlice';
import {getNestedValue} from '../../utils/getNestedValue';
import {mergeNestedObjects} from '../../utils/mergeNestedObjects';
import {useFieldValidation} from '../../hooks/useFieldValidation';
@ -8,7 +8,7 @@ import PageHeader from '../../components/PageHeader/PageHeader';
import PageDescription from '../../components/PageDescription/PageDescription';
import Tabs from '../../components/Tabs/Tabs';
import Input from '../../components/Input/Input';
import SaveButton from '../../components/SaveButton/SaveButton';
import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
import styles from './Expiration.module.scss';
const expirationTabs = [
@ -19,8 +19,7 @@ const expirationTabs = [
function Expiration() {
const dispatch = useDispatch();
const config = useSelector(selectConfig);
const loading = useSelector(selectConfigLoading);
const {validateField, getFieldError, hasValidationErrors} = useFieldValidation();
const {validateField, getFieldError, hasValidationErrors, clearFieldError} = useFieldValidation();
const [activeTab, setActiveTab] = useState('garbage-collection');
@ -34,6 +33,7 @@ function Expiration() {
sessionabsolute: ''
});
const [hasChanges, setHasChanges] = useState(false);
const hasInitialized = useRef(false);
// Configuration paths
const CONFIG_PATHS = {
@ -45,11 +45,9 @@ function Expiration() {
sessionabsolute: 'services.CoAuthoring.expire.sessionabsolute'
};
// Load config data when component mounts
useEffect(() => {
if (!config) {
dispatch(fetchConfig());
} else {
// 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], '');
@ -57,8 +55,24 @@ function Expiration() {
});
setLocalSettings(settings);
setHasChanges(false);
// Clear validation errors for all fields
Object.values(CONFIG_PATHS).forEach(path => {
clearFieldError(path);
});
}
}, [dispatch, config]);
};
// Handle tab change and reset state
const handleTabChange = newTab => {
setActiveTab(newTab);
resetToGlobalConfig();
};
// Initialize settings from config when component loads (only once)
if (config && !hasInitialized.current) {
resetToGlobalConfig();
hasInitialized.current = true;
}
// Handle field changes
const handleFieldChange = (field, value) => {
@ -201,24 +215,18 @@ function Expiration() {
}
};
if (loading) {
return <div className={styles.loading}>Loading expiration settings...</div>;
}
return (
<div className={styles.expiration}>
<div className={`${styles.expiration} ${styles.pageWithFixedSave}`}>
<PageHeader>Expiration Settings</PageHeader>
<PageDescription>Configure file cleanup schedules, session timeouts, and garbage collection settings</PageDescription>
<Tabs tabs={expirationTabs} activeTab={activeTab} onTabChange={setActiveTab}>
<Tabs tabs={expirationTabs} activeTab={activeTab} onTabChange={handleTabChange}>
{renderTabContent()}
</Tabs>
<div className={styles.actions}>
<SaveButton onClick={handleSave} disabled={!hasChanges || hasValidationErrors()}>
Save Changes
</SaveButton>
</div>
<FixedSaveButton onClick={handleSave} disabled={!hasChanges || hasValidationErrors()}>
Save Changes
</FixedSaveButton>
</div>
);
}

View File

@ -43,3 +43,7 @@
display: flex;
justify-content: flex-start;
}
.pageWithFixedSave {
padding-bottom: 40px;
}

View File

@ -1,19 +1,18 @@
import {useState, useEffect} from 'react';
import {useSelector, useDispatch} from 'react-redux';
import {fetchConfig, saveConfig, selectConfig, selectConfigLoading} from '../../store/slices/configSlice';
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 SaveButton from '../../components/SaveButton/SaveButton';
import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
import styles from './FileLimits.module.scss';
function FileLimits() {
const dispatch = useDispatch();
const config = useSelector(selectConfig);
const loading = useSelector(selectConfigLoading);
const {validateField, getFieldError, hasValidationErrors} = useFieldValidation();
// Local state for form fields
@ -37,13 +36,11 @@ function FileLimits() {
// Load config data when component mounts
useEffect(() => {
if (!config) {
dispatch(fetchConfig());
} else {
if (config) {
const settings = {};
// Get max download bytes
settings.maxDownloadBytes = getNestedValue(config, CONFIG_PATHS.maxDownloadBytes, '');
settings.maxDownloadBytes = getNestedValue(config, 'FileConverter.converter.maxDownloadBytes', '');
// Get input limits - need to handle array structure
const inputLimits = getNestedValue(config, 'FileConverter.converter.inputLimits', []);
@ -85,7 +82,7 @@ function FileLimits() {
let originalValue;
if (key === 'maxDownloadBytes') {
originalValue = getNestedValue(config, CONFIG_PATHS.maxDownloadBytes, '');
originalValue = getNestedValue(config, 'FileConverter.converter.maxDownloadBytes', '');
} else {
// Handle input limits array structure for comparison
const inputLimits = getNestedValue(config, 'FileConverter.converter.inputLimits', []);
@ -166,12 +163,8 @@ function FileLimits() {
setHasChanges(false);
};
if (loading) {
return <div className={styles.loading}>Loading file limits settings...</div>;
}
return (
<div className={styles.fileLimits}>
<div className={`${styles.fileLimits} ${styles.pageWithFixedSave}`}>
<PageHeader>File Size Limits</PageHeader>
<PageDescription>Configure maximum file sizes and download limits for document processing</PageDescription>
@ -237,11 +230,9 @@ function FileLimits() {
</div>
</div>
<div className={styles.actions}>
<SaveButton onClick={handleSave} disabled={!hasChanges || hasValidationErrors()}>
Save Changes
</SaveButton>
</div>
<FixedSaveButton onClick={handleSave} disabled={!hasChanges || hasValidationErrors()}>
Save Changes
</FixedSaveButton>
</div>
);
}

View File

@ -43,3 +43,7 @@
display: flex;
justify-content: flex-start;
}
.pageWithFixedSave {
padding-bottom: 40px;
}

View File

@ -1,18 +1,18 @@
import {useState, useEffect} from 'react';
import {useState, useRef} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {fetchConfig, saveConfig} from '../../store/slices/configSlice';
import {saveConfig, selectConfig} from '../../store/slices/configSlice';
import {getNestedValue} from '../../utils/getNestedValue';
import {mergeNestedObjects} from '../../utils/mergeNestedObjects';
import {useFieldValidation} from '../../hooks/useFieldValidation';
import Checkbox from '../../components/Checkbox/Checkbox';
import SaveButton from '../../components/SaveButton/SaveButton';
import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
import PageHeader from '../../components/PageHeader/PageHeader';
import PageDescription from '../../components/PageDescription/PageDescription';
import styles from './RequestFiltering.module.scss';
function RequestFiltering() {
const dispatch = useDispatch();
const {config, loading} = useSelector(state => state.config);
const config = useSelector(selectConfig);
const {validateField, getFieldError, hasValidationErrors} = useFieldValidation();
const [localSettings, setLocalSettings] = useState({
@ -27,8 +27,8 @@ function RequestFiltering() {
allowMetaIPAddress: 'request-filtering-agent.allowMetaIPAddress'
};
// Load initial values from config
useEffect(() => {
const hasInitialized = useRef(false);
const resetToGlobalConfig = () => {
if (config) {
const newSettings = {};
Object.keys(CONFIG_PATHS).forEach(key => {
@ -37,12 +37,12 @@ function RequestFiltering() {
});
setLocalSettings(newSettings);
}
}, [config]);
// Load config on component mount
useEffect(() => {
dispatch(fetchConfig());
}, [dispatch]);
};
// Load initial values from config
if (config && !hasInitialized.current) {
resetToGlobalConfig();
hasInitialized.current = true;
}
// Handle field changes
const handleFieldChange = (field, value) => {
@ -82,12 +82,8 @@ function RequestFiltering() {
setHasChanges(false);
};
if (loading) {
return <div className={styles.loading}>Loading request filtering settings...</div>;
}
return (
<div className={styles.requestFiltering}>
<div className={`${styles.requestFiltering} ${styles.pageWithFixedSave}`}>
<PageHeader>Request Filtering</PageHeader>
<PageDescription>
Configure request filtering settings to control which IP addresses are allowed to make requests to the server.
@ -118,11 +114,9 @@ function RequestFiltering() {
</div>
</div>
<div className={styles.actions}>
<SaveButton onClick={handleSave} disabled={!hasChanges || hasValidationErrors()}>
Save Changes
</SaveButton>
</div>
<FixedSaveButton onClick={handleSave} disabled={!hasChanges || hasValidationErrors()}>
Save Changes
</FixedSaveButton>
</div>
);
}

View File

@ -67,3 +67,7 @@
display: flex;
justify-content: flex-start;
}
.pageWithFixedSave {
padding-bottom: 40px;
}

View File

@ -1,6 +1,6 @@
import {useState, useEffect} from 'react';
import {useState, useRef} from 'react';
import {useSelector, useDispatch} from 'react-redux';
import {fetchConfig, saveConfig, selectConfig, selectConfigLoading} from '../../store/slices/configSlice';
import {saveConfig, selectConfig} from '../../store/slices/configSlice';
import {getNestedValue} from '../../utils/getNestedValue';
import {mergeNestedObjects} from '../../utils/mergeNestedObjects';
import {useFieldValidation} from '../../hooks/useFieldValidation';
@ -8,7 +8,7 @@ import PageHeader from '../../components/PageHeader/PageHeader';
import PageDescription from '../../components/PageDescription/PageDescription';
import Tabs from '../../components/Tabs/Tabs';
import AccessRules from '../../components/AccessRules/AccessRules';
import SaveButton from '../../components/SaveButton/SaveButton';
import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
import styles from './SecuritySettings.module.scss';
const securityTabs = [{key: 'ip-filtering', label: 'IP Filtering'}];
@ -16,30 +16,39 @@ const securityTabs = [{key: 'ip-filtering', label: 'IP Filtering'}];
function SecuritySettings() {
const dispatch = useDispatch();
const config = useSelector(selectConfig);
const loading = useSelector(selectConfigLoading);
const {validateField, getFieldError, hasValidationErrors} = useFieldValidation();
const {validateField, getFieldError, hasValidationErrors, clearFieldError} = useFieldValidation();
const [activeTab, setActiveTab] = useState('ip-filtering');
const [localRules, setLocalRules] = useState([]);
const [hasChanges, setHasChanges] = useState(false);
useEffect(() => {
if (!config) {
dispatch(fetchConfig());
} else {
// Get IP filtering rules from actual config
// Reset state and errors to global config
const resetToGlobalConfig = () => {
if (config) {
const ipFilterRules = getNestedValue(config, 'services.CoAuthoring.ipfilter.rules', []);
// Convert from backend format to UI format
const uiRules = ipFilterRules.map(rule => ({
type: rule.allowed ? 'Allow' : 'Deny',
value: rule.address
}));
setLocalRules(uiRules);
setHasChanges(false);
// Clear validation errors
clearFieldError('services.CoAuthoring.ipfilter.rules');
}
}, [dispatch, config]);
};
// Handle tab change and reset state
const handleTabChange = newTab => {
setActiveTab(newTab);
resetToGlobalConfig();
};
const hasInitialized = useRef(false);
if (config && !hasInitialized.current) {
resetToGlobalConfig();
hasInitialized.current = true;
}
// Handle rules changes
const handleRulesChange = newRules => {
@ -92,24 +101,18 @@ function SecuritySettings() {
}
};
if (loading) {
return <div className={styles.loading}>Loading security settings...</div>;
}
return (
<div className={styles.securitySettings}>
<div className={`${styles.securitySettings} ${styles.pageWithFixedSave}`}>
<PageHeader>Security Settings</PageHeader>
<PageDescription>Configure IP filtering, authentication, and security policies</PageDescription>
<Tabs tabs={securityTabs} activeTab={activeTab} onTabChange={setActiveTab}>
<Tabs tabs={securityTabs} activeTab={activeTab} onTabChange={handleTabChange}>
{renderTabContent()}
</Tabs>
<div className={styles.actions}>
<SaveButton onClick={handleSave} disabled={!hasChanges || hasValidationErrors()}>
Save Changes
</SaveButton>
</div>
<FixedSaveButton onClick={handleSave} disabled={!hasChanges || hasValidationErrors()}>
Save Changes
</FixedSaveButton>
</div>
);
}

View File

@ -2,6 +2,10 @@
padding: 0;
}
.pageWithFixedSave {
padding-bottom: 40px;
}
.loading {
display: flex;
justify-content: center;

View File

@ -1,72 +1,124 @@
import {useEffect, useState} from 'react';
import {useState, useRef} from 'react';
import {useSelector, useDispatch} from 'react-redux';
import {fetchConfig, saveConfig, selectConfig, selectConfigLoading, selectSchema} from '../../store/slices/configSlice';
import {saveConfig, selectConfig, rotateWopiKeysAction} from '../../store/slices/configSlice';
import {getNestedValue} from '../../utils/getNestedValue';
import {mergeNestedObjects} from '../../utils/mergeNestedObjects';
import {useFieldValidation} from '../../hooks/useFieldValidation';
import {maskKey} from '../../utils/maskKey';
import PageHeader from '../../components/PageHeader/PageHeader';
import PageDescription from '../../components/PageDescription/PageDescription';
import ToggleSwitch from '../../components/ToggleSwitch/ToggleSwitch';
import SaveButton from '../../components/SaveButton/SaveButton';
import Input from '../../components/Input/Input';
import Checkbox from '../../components/Checkbox/Checkbox';
import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
import styles from './WOPISettings.module.scss';
function WOPISettings() {
const dispatch = useDispatch();
const config = useSelector(selectConfig);
const schema = useSelector(selectSchema);
const loading = useSelector(selectConfigLoading);
const {validateField, hasValidationErrors} = useFieldValidation();
// Local state for WOPI enable setting
// Local state for WOPI settings
const [localWopiEnabled, setLocalWopiEnabled] = useState(false);
const [localRotateKeys, setLocalRotateKeys] = useState(false);
const [localRefreshLockInterval, setLocalRefreshLockInterval] = useState('');
const [hasChanges, setHasChanges] = useState(false);
const hasInitialized = useRef(false);
// Get the actual config value
// Get the actual config values
const configWopiEnabled = getNestedValue(config, 'wopi.enable', false);
const wopiPublicKey = getNestedValue(config, 'wopi.publicKey', '');
const configRefreshLockInterval = getNestedValue(config, 'wopi.refreshLockInterval', '10m');
// Initialize local state when config loads
useEffect(() => {
const resetToGlobalConfig = () => {
if (config) {
setLocalWopiEnabled(configWopiEnabled);
setLocalRotateKeys(false);
setLocalRefreshLockInterval(configRefreshLockInterval);
setHasChanges(false);
validateField('wopi.enable', configWopiEnabled);
validateField('wopi.refreshLockInterval', configRefreshLockInterval);
}
}, [config, configWopiEnabled]);
};
useEffect(() => {
if (!config || !schema) {
dispatch(fetchConfig());
}
}, [dispatch, config, schema]);
// Initialize settings from config when component loads (only once)
if (config && !hasInitialized.current) {
resetToGlobalConfig();
hasInitialized.current = true;
}
const handleWopiEnabledChange = enabled => {
setLocalWopiEnabled(enabled);
setHasChanges(enabled !== configWopiEnabled);
// If WOPI is disabled, uncheck rotate keys
if (!enabled) {
setLocalRotateKeys(false);
}
setHasChanges(enabled !== configWopiEnabled || localRotateKeys || localRefreshLockInterval !== configRefreshLockInterval);
// Validate the boolean field
validateField('wopi.enable', enabled);
};
const handleRotateKeysChange = checked => {
setLocalRotateKeys(checked);
setHasChanges(localWopiEnabled !== configWopiEnabled || checked || localRefreshLockInterval !== configRefreshLockInterval);
};
const handleRefreshLockIntervalChange = value => {
setLocalRefreshLockInterval(value);
setHasChanges(localWopiEnabled !== configWopiEnabled || localRotateKeys || value !== configRefreshLockInterval);
validateField('wopi.refreshLockInterval', value);
};
const handleSave = async () => {
if (!hasChanges) return;
try {
const updatedConfig = mergeNestedObjects([{'wopi.enable': localWopiEnabled}]);
await dispatch(saveConfig(updatedConfig)).unwrap();
const enableChanged = localWopiEnabled !== configWopiEnabled;
const rotateRequested = localRotateKeys;
const refreshLockIntervalChanged = localRefreshLockInterval !== configRefreshLockInterval;
// Build config update object
const configUpdates = {};
if (enableChanged) {
configUpdates['wopi.enable'] = localWopiEnabled;
}
if (refreshLockIntervalChanged) {
configUpdates['wopi.refreshLockInterval'] = localRefreshLockInterval;
}
// If only rotate requested, just rotate keys
if (!enableChanged && !refreshLockIntervalChanged && rotateRequested) {
await dispatch(rotateWopiKeysAction()).unwrap();
}
// If config changes (enable or refreshLockInterval) but no rotate
else if ((enableChanged || refreshLockIntervalChanged) && !rotateRequested) {
const updatedConfig = mergeNestedObjects([configUpdates]);
await dispatch(saveConfig(updatedConfig)).unwrap();
}
// If both config changes and rotate requested, make two requests
else if ((enableChanged || refreshLockIntervalChanged) && rotateRequested) {
// First update the config settings
const updatedConfig = mergeNestedObjects([configUpdates]);
await dispatch(saveConfig(updatedConfig)).unwrap();
// Then rotate keys
await dispatch(rotateWopiKeysAction()).unwrap();
}
setHasChanges(false);
setLocalRotateKeys(false);
} catch (error) {
console.error('Failed to save WOPI settings:', error);
// Revert local state on error
setLocalWopiEnabled(configWopiEnabled);
setLocalRotateKeys(false);
setLocalRefreshLockInterval(configRefreshLockInterval);
setHasChanges(false);
}
};
if (loading) {
return <div className={styles.loading}>Loading WOPI settings...</div>;
}
return (
<div className={styles.wopiSettings}>
<div className={`${styles.wopiSettings} ${styles.pageWithFixedSave}`}>
<PageHeader>WOPI Settings</PageHeader>
<PageDescription>Configure WOPI (Web Application Open Platform Interface) support for document editing</PageDescription>
@ -74,11 +126,54 @@ function WOPISettings() {
<ToggleSwitch label='WOPI' checked={localWopiEnabled} onChange={handleWopiEnabledChange} />
</div>
<div className={styles.actions}>
<SaveButton onClick={handleSave} disabled={!hasChanges || hasValidationErrors()}>
Save Changes
</SaveButton>
</div>
{localWopiEnabled && (
<>
<div className={styles.settingsSection}>
<div className={styles.sectionTitle}>Lock Settings</div>
<div className={styles.sectionDescription}>Configure document lock refresh interval for WOPI sessions.</div>
<div className={styles.formRow}>
<Input
label='Refresh Lock Interval'
value={localRefreshLockInterval}
onChange={handleRefreshLockIntervalChange}
placeholder='10m'
width='200px'
description="Time interval for refreshing document locks (e.g., '10m', '1h', '30s')"
/>
</div>
</div>
<div className={styles.settingsSection}>
<div className={styles.sectionTitle}>Key Management</div>
<div className={styles.sectionDescription}>
Rotate WOPI encryption keys. Current keys will be moved to "Old" and new keys will be generated.
</div>
<div className={styles.formRow}>
<Input
label='Current Public Key'
value={maskKey(wopiPublicKey)}
disabled
placeholder='No key generated'
width='400px'
style={{fontFamily: 'Courier New, monospace'}}
/>
</div>
<div className={styles.formRow}>
<Checkbox
label='Rotate Keys'
checked={localRotateKeys}
onChange={handleRotateKeysChange}
disabled={!localWopiEnabled}
description="Generate new encryption keys. Current keys will be moved to 'Old'."
/>
</div>
</div>
</>
)}
<FixedSaveButton onClick={handleSave} disabled={!hasChanges || hasValidationErrors()}>
Save Changes
</FixedSaveButton>
</div>
);
}

View File

@ -2,6 +2,10 @@
padding: 0;
}
.pageWithFixedSave {
padding-bottom: 80px;
}
.loading {
display: flex;
justify-content: center;
@ -19,6 +23,41 @@
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;
}
// Ensure SaveButton aligns properly in form row
:global(.saveButton) {
margin-top: 0;
}
// Override Input component styles for disabled key display
:global(.input[disabled]) {
background-color: #f8f9fa !important;
color: #666666 !important;
cursor: not-allowed !important;
opacity: 0.8;
}
.actions {
display: flex;
justify-content: flex-start;

View File

@ -1,10 +1,19 @@
import {createSlice, createAsyncThunk} from '@reduxjs/toolkit';
import {fetchConfiguration, fetchConfigurationSchema, updateConfiguration} from '../../api';
import {fetchConfiguration, fetchConfigurationSchema, updateConfiguration, rotateWopiKeys} from '../../api';
export const fetchConfig = createAsyncThunk('config/fetchConfig', async (_, {rejectWithValue}) => {
try {
const [config, schema] = await Promise.all([fetchConfiguration(), fetchConfigurationSchema()]);
return {config, schema};
const config = await fetchConfiguration();
return {config};
} catch (error) {
return rejectWithValue(error.message);
}
});
export const fetchSchema = createAsyncThunk('config/fetchSchema', async (_, {rejectWithValue}) => {
try {
const schema = await fetchConfigurationSchema();
return {schema};
} catch (error) {
return rejectWithValue(error.message);
}
@ -12,8 +21,17 @@ export const fetchConfig = createAsyncThunk('config/fetchConfig', async (_, {rej
export const saveConfig = createAsyncThunk('config/saveConfig', async (configData, {rejectWithValue}) => {
try {
await updateConfiguration(configData);
return configData;
const newConfig = await updateConfiguration(configData);
return newConfig;
} catch (error) {
return rejectWithValue(error);
}
});
export const rotateWopiKeysAction = createAsyncThunk('config/rotateWopiKeys', async (_, {rejectWithValue}) => {
try {
const newConfig = await rotateWopiKeys();
return newConfig;
} catch (error) {
return rejectWithValue(error);
}
@ -23,8 +41,10 @@ const initialState = {
config: null,
schema: null,
loading: false,
schemaLoading: false,
saving: false,
error: null
error: null,
schemaError: null
};
const configSlice = createSlice({
@ -37,6 +57,11 @@ const configSlice = createSlice({
state.config = {...state.config, ...action.payload};
}
},
clearConfig: state => {
state.config = null;
state.loading = false;
state.error = null;
},
clearError: state => {
state.error = null;
}
@ -51,13 +76,26 @@ const configSlice = createSlice({
.addCase(fetchConfig.fulfilled, (state, action) => {
state.loading = false;
state.config = action.payload.config;
state.schema = action.payload.schema;
state.error = null;
})
.addCase(fetchConfig.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
})
// Fetch schema cases
.addCase(fetchSchema.pending, state => {
state.schemaLoading = true;
state.schemaError = null;
})
.addCase(fetchSchema.fulfilled, (state, action) => {
state.schemaLoading = false;
state.schema = action.payload.schema;
state.schemaError = null;
})
.addCase(fetchSchema.rejected, (state, action) => {
state.schemaLoading = false;
state.schemaError = action.payload;
})
// Save config cases
.addCase(saveConfig.pending, state => {
state.saving = true;
@ -65,26 +103,39 @@ const configSlice = createSlice({
})
.addCase(saveConfig.fulfilled, (state, action) => {
state.saving = false;
// Update the global config with the saved changes
if (state.config) {
state.config = {...state.config, ...action.payload};
}
// Update the global config with the complete new config from server
state.config = action.payload;
state.error = null;
})
.addCase(saveConfig.rejected, (state, action) => {
state.saving = false;
state.error = action.payload;
})
.addCase(rotateWopiKeysAction.pending, state => {
state.saving = true;
state.error = null;
})
.addCase(rotateWopiKeysAction.fulfilled, (state, action) => {
state.saving = false;
state.config = action.payload;
state.error = null;
})
.addCase(rotateWopiKeysAction.rejected, (state, action) => {
state.saving = false;
state.error = action.payload;
});
}
});
export const {updateLocalConfig, clearError} = configSlice.actions;
export const {updateLocalConfig, clearConfig, clearError} = configSlice.actions;
// Selectors
export const selectConfig = state => state.config.config;
export const selectSchema = state => state.config.schema;
export const selectConfigLoading = state => state.config.loading;
export const selectSchemaLoading = state => state.config.schemaLoading;
export const selectConfigSaving = state => state.config.saving;
export const selectConfigError = state => state.config.error;
export const selectSchemaError = state => state.config.schemaError;
export default configSlice.reducer;

View File

@ -0,0 +1,20 @@
/**
* Masks a key string to show only first 5 and last 10 characters
* Format: ABCDE...FGHIJKLMNO
* @param {string} key - The key string to mask
* @returns {string} - The masked key string
*/
export const maskKey = key => {
if (!key || typeof key !== 'string') {
return '';
}
if (key.length <= 15) {
return key;
}
const firstPart = key.substring(0, 5);
const lastPart = key.substring(key.length - 10);
return `${firstPart}...${lastPart}`;
};

View File

@ -2,6 +2,15 @@ const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const webpack = require('webpack');
const dotenv = require('dotenv');
// Load environment variables from .env files
// Priority: .env.local > .env.development/.env.production > .env
const envFiles = ['.env.local', process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development', '.env'];
envFiles.forEach(file => {
dotenv.config({path: file});
});
module.exports = {
entry: './src/index.js',
@ -15,7 +24,7 @@ module.exports = {
devServer: {
static: {
directory: path.join(__dirname, 'build'),
publicPath: '/'
publicPath: ''
},
port: 3000,
open: true,
@ -30,11 +39,18 @@ module.exports = {
patterns: [
{
context: path.resolve(__dirname, 'public'),
from: 'images/*.*'
from: 'images/*.*',
to: 'images/[name][ext]'
},
{
context: path.resolve(__dirname, 'src/assets'),
from: '*.svg',
to: 'static/[name][ext]'
},
{
context: path.resolve(__dirname),
from: 'config.json'
from: 'config.json',
to: 'config.json'
}
]
}),

View File

@ -0,0 +1,36 @@
'use strict';
const config = require('config');
const jwt = require('jsonwebtoken');
const operationContext = require('../../../../Common/sources/operationContext');
const adminPanelJwtSecret = config.get('adminPanel.jwtSecret');
/**
* JWT Authentication Middleware
* Validates JWT token from cookies and initializes operation context
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next function
*/
const validateJWT = async (req, res, next) => {
const ctx = new operationContext.Context();
try {
const token = req.cookies.accessToken;
if (!token) {
return res.status(401).json({error: 'Unauthorized - No token provided'});
}
const decoded = jwt.verify(token, adminPanelJwtSecret);
ctx.init(decoded.tenant);
await ctx.initTenantCache();
req.user = decoded;
req.ctx = ctx;
return next();
} catch {
return res.status(401).json({error: 'Unauthorized'});
}
};
module.exports = {
validateJWT
};

View File

@ -3,13 +3,11 @@ const config = require('config');
const express = require('express');
const bodyParser = require('body-parser');
const tenantManager = require('../../../../../Common/sources/tenantManager');
const operationContext = require('../../../../../Common/sources/operationContext');
const runtimeConfigManager = require('../../../../../Common/sources/runtimeConfigManager');
const utils = require('../../../../../Common/sources/utils');
const {getScopedConfig, validateScoped, getScopedSchema} = require('./config.service');
const jwt = require('jsonwebtoken');
const {validateJWT} = require('../../middleware/auth');
const cookieParser = require('cookie-parser');
const adminPanelJwtSecret = config.get('adminPanel.jwtSecret');
const router = express.Router();
router.use(cookieParser());
@ -22,24 +20,6 @@ const rawFileParser = bodyParser.raw({
}
});
const validateJWT = async (req, res, next) => {
const ctx = new operationContext.Context();
try {
const token = req.cookies.accessToken;
if (!token) {
return res.status(401).json({error: 'Unauthorized - No token provided'});
}
const decoded = jwt.verify(token, adminPanelJwtSecret);
ctx.init(decoded.tenant);
await ctx.initTenantCache();
req.user = decoded;
req.ctx = ctx;
return next();
} catch {
return res.status(401).json({error: 'Unauthorized'});
}
};
router.get('/', validateJWT, async (req, res) => {
const ctx = req.ctx;
try {
@ -89,7 +69,10 @@ router.patch('/', validateJWT, rawFileParser, async (req, res) => {
} else {
await runtimeConfigManager.saveConfig(ctx, newConfig);
}
res.sendStatus(200);
await ctx.initTenantCache();
const filteredConfig = getScopedConfig(ctx);
res.status(200).json(filteredConfig);
} catch (error) {
ctx.logger.error('Configuration save error: %s', error.stack);
res.status(500).json({error: 'Internal server error', details: error.message});

View File

@ -0,0 +1,204 @@
/*
* (c) Copyright Ascensio System SIA 2010-2024
*
* This program is a free software product. You can redistribute it and/or
* modify it under the terms of the GNU Affero General Public License (AGPL)
* version 3 as published by the Free Software Foundation. In accordance with
* Section 7(a) of the GNU AGPL its Section 15 shall be amended to the effect
* that Ascensio System SIA expressly excludes the warranty of non-infringement
* of any third-party rights.
*
* This program is distributed WITHOUT ANY WARRANTY; without even the implied
* warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. For
* details, see the GNU AGPL at: http://www.gnu.org/licenses/agpl-3.0.html
*
* You can contact Ascensio System SIA at 20A-6 Ernesta Birznieka-Upish
* street, Riga, Latvia, EU, LV-1050.
*
* The interactive user interfaces in modified source and object code versions
* of the Program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU AGPL version 3.
*
* Pursuant to Section 7(b) of the License you must retain the original Product
* logo when distributing the program. Pursuant to Section 7(e) we decline to
* grant you any rights under trademark law for use of our trademarks.
*
* All the Product's GUI elements, including illustrations and icon sets, as
* well as technical writing content are licensed under the terms of the
* Creative Commons Attribution-ShareAlike 4.0 International. See the License
* terms at http://creativecommons.org/licenses/by-sa/4.0/legalcode
*
*/
'use strict';
const express = require('express');
const crypto = require('crypto');
const utils = require('../../../../../Common/sources/utils');
const runtimeConfigManager = require('../../../../../Common/sources/runtimeConfigManager');
const tenantManager = require('../../../../../Common/sources/tenantManager');
const {validateJWT} = require('../../middleware/auth');
const {getScopedConfig} = require('../config/config.service');
const cookieParser = require('cookie-parser');
const router = express.Router();
router.use(cookieParser());
/**
* Decode a base64url string into a Buffer (RFC 7515)
* @param {string} b64url base64url-encoded string (no padding)
* @returns {Buffer} decoded bytes
*/
function base64UrlToBuffer(b64url) {
const b64 = b64url
.replace(/-/g, '+')
.replace(/_/g, '/')
.padEnd(Math.ceil(b64url.length / 4) * 4, '=');
return Buffer.from(b64, 'base64');
}
/**
* Convert a big-endian Buffer into a safe JavaScript Number.
* Note: Only for small integers (like RSA public exponent). For large values use BigInt.
* @param {Buffer} buf big-endian buffer (<= 6 bytes recommended)
* @returns {number} numeric value
*/
function bufferBEToNumber(buf) {
let n = 0;
for (const byte of buf.values()) {
n = (n << 8) | byte;
}
return n >>> 0;
}
/**
* Build a Microsoft PUBLICKEYBLOB from modulus and exponent.
* Layout:
* BLOBHEADER (8 bytes):
* bType=0x06 (PUBLICKEYBLOB), bVersion=0x02, reserved=0x0000, aiKeyAlg=0x0000A400 (CALG_RSA_KEYX)
* RSAPUBKEY (12 bytes):
* magic='RSA1' (0x31415352 LE), bitlen=modBits (LE), pubexp (LE)
* modulus bytes (little-endian)
* @param {Buffer} modulusBE Modulus big-endian, length = keySizeBytes
* @param {number} exponent Public exponent (decimal)
* @returns {Buffer} PUBLICKEYBLOB bytes
*/
function makeMsPublicKeyBlob(modulusBE, exponent) {
const keySizeBytes = modulusBE.length;
const header = Buffer.alloc(8);
// BLOBHEADER
header.writeUInt8(0x06, 0); // PUBLICKEYBLOB
header.writeUInt8(0x02, 1); // version
header.writeUInt16LE(0, 2); // reserved
header.writeUInt32LE(0x0000a400, 4); // CALG_RSA_KEYX
const rsapub = Buffer.alloc(12);
// 'RSA1' magic LE
rsapub.writeUInt32LE(0x31415352, 0);
rsapub.writeUInt32LE(keySizeBytes * 8, 4); // bit length
rsapub.writeUInt32LE(exponent >>> 0, 8); // exponent (fits in 32-bit)
// modulus little-endian
const modulusLE = Buffer.from(modulusBE);
modulusLE.reverse();
return Buffer.concat([header, rsapub, modulusLE]);
}
/**
* Generates WOPI private/public key pair and extracts modulus/exponent using Microsoft PUBLICKEYBLOB format.
* Uses JWK export for robust modulus/exponent retrieval across Node versions.
* @returns {Object} WOPI configuration object
*/
function generateWopiKeys() {
// Generate RSA private key (2048 bits)
const {privateKey, publicKey} = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
}
});
// Extract modulus (n) and exponent (e) via JWK for compatibility
const publicKeyObj = crypto.createPublicKey(publicKey);
/** @type {{kty:string,n:string,e:string}} */
const jwk = publicKeyObj.export({format: 'jwk'});
const modulusBE = base64UrlToBuffer(jwk.n); // big-endian bytes
const exponent = bufferBEToNumber(base64UrlToBuffer(jwk.e));
// Create MS PUBLICKEYBLOB format (matches bash script behavior)
const publicKeyBlob = makeMsPublicKeyBlob(modulusBE, exponent);
// Convert modulus to base64 (same as bash script: xxd -r -p | openssl base64 -A)
const modulus = modulusBE.toString('base64');
// Convert keys to base64 for storage
const publicKeyBase64 = publicKeyBlob.toString('base64');
return {
publicKey: publicKeyBase64,
modulus,
exponent,
privateKey
};
}
/**
* Rotates WOPI keys - moves current keys to Old and generates new ones.
*/
router.post('/rotate-keys', validateJWT, express.json(), async (req, res) => {
const ctx = req.ctx;
try {
ctx.logger.info('WOPI key rotation start');
const currentConfig = ctx.getFullCfg();
const wopiConfig = utils.getImpl(currentConfig, 'wopi') || {};
const newWopiConfig = generateWopiKeys();
const hasEmptyKeys = !wopiConfig.publicKey && !wopiConfig.modulus && !wopiConfig.privateKey;
const configUpdate = {
wopi: {
publicKeyOld: hasEmptyKeys ? newWopiConfig.publicKey : wopiConfig.publicKey,
modulusOld: hasEmptyKeys ? newWopiConfig.modulus : wopiConfig.modulus,
exponentOld: hasEmptyKeys ? newWopiConfig.exponent : wopiConfig.exponent,
privateKeyOld: hasEmptyKeys ? newWopiConfig.privateKey : wopiConfig.privateKey,
publicKey: newWopiConfig.publicKey,
modulus: newWopiConfig.modulus,
exponent: newWopiConfig.exponent,
privateKey: newWopiConfig.privateKey
}
};
const newConfig = utils.deepMergeObjects(currentConfig, configUpdate);
if (tenantManager.isMultitenantMode(ctx) && !tenantManager.isDefaultTenant(ctx)) {
await tenantManager.setTenantConfig(ctx, newConfig);
} else {
await runtimeConfigManager.saveConfig(ctx, newConfig);
}
await ctx.initTenantCache();
const filteredConfig = getScopedConfig(ctx);
res.status(200).json(filteredConfig);
} catch (error) {
ctx.logger.error('WOPI key rotation error: %s', error.stack);
res.status(500).json({
success: false,
error: 'Failed to rotate WOPI keys',
details: error.message
});
} finally {
ctx.logger.info('WOPI key rotation end');
}
});
// Export router and helper for reuse in tests or other modules
module.exports = router;
module.exports.generateWopiKeys = generateWopiKeys;

View File

@ -47,6 +47,7 @@ const infoRouter = require('../../../DocService/sources/routes/info');
const configRouter = require('./routes/config/router');
const adminpanelRouter = require('./routes/adminpanel/router');
const wopiRouter = require('./routes/wopi/router');
const port = config.get('adminPanel.port');
@ -81,6 +82,7 @@ const corsWithCredentials = cors({
operationContext.global.logger.warn('AdminPanel server starting...');
app.use('/api/v1/admin/config', corsWithCredentials, utils.checkClientIp, configRouter);
app.use('/api/v1/admin/wopi', corsWithCredentials, utils.checkClientIp, wopiRouter);
app.use('/api/v1/admin', corsWithCredentials, utils.checkClientIp, adminpanelRouter);
app.get('/api/v1/admin/stat', corsWithCredentials, utils.checkClientIp, infoRouter.licenseInfo);

View File

@ -14,7 +14,7 @@
"models": [],
"providers": {},
"version": 3,
"timeout": "30s",
"timeout": "5m",
"allowedCorsOrigins": ["https://onlyoffice.github.io", "https://onlyoffice-plugins.github.io"],
"proxy": "",
"pluginDir": "../branding/info/ai"

View File

@ -167,7 +167,13 @@
"additionalProperties": false,
"x-scope": ["admin", "tenant"],
"properties": {
"enable": {"type": "boolean"}
"enable": {"type": "boolean"},
"publicKey": {"type": "string"},
"refreshLockInterval": {
"type": "string",
"pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$",
"description": "Refresh lock interval in time format (e.g., '10m', '1h', '30s')"
}
}
},
"email": {

View File

@ -521,9 +521,10 @@ async function postRequestPromise(ctx, uri, postData, postDataStream, postDataSi
* @param {object} opt_timeout - Optional timeout configuration.
* @param {number} opt_limit - Optional limit on the size of the response.
* @param {boolean} opt_filterPrivate - Optional flag to filter private requests.
* @param {Object} [opt_axiosConfig={}] - Optional additional axios configuration options.
* @returns {Promise<{response: axios.AxiosResponse, stream: SizeLimitStream}>} - A promise that resolves to an object containing the raw Axios response and a SizeLimitStream.
*/
async function httpRequest(ctx, method, uri, opt_headers, opt_body, opt_timeout, opt_limit, opt_filterPrivate) {
async function httpRequest(ctx, method, uri, opt_headers, opt_body, opt_timeout, opt_limit, opt_filterPrivate, opt_axiosConfig = {}) {
const tenTenantRequestDefaults = ctx.getCfg('services.CoAuthoring.requestDefaults', cfgRequestDefaults);
uri = URI.serialize(URI.parse(uri));
const options = config.util.cloneDeep(tenTenantRequestDefaults);
@ -548,6 +549,7 @@ async function httpRequest(ctx, method, uri, opt_headers, opt_body, opt_timeout,
const axiosConfig = {
...options,
...opt_axiosConfig,
url: uri,
method,
headers: requestHeaders,

View File

@ -300,7 +300,10 @@ async function proxyRequest(req, res) {
requestParams.body, // Request body
requestParams.timeout, // Timeout configuration
requestParams.limit, // Size limit
requestParams.isInJwtToken // Filter private requests
requestParams.isInJwtToken, // Filter private requests
{
decompress: false
}
);
// Set the response headers to match the target response

View File

@ -170,6 +170,44 @@ docsCoServer.install(server, app, () => {
);
});
// Proxy AdminPanel endpoints for testing
if (process.env.NODE_ENV.startsWith('development-')) {
/**
* Simple proxy to localhost:9000 for testing AdminPanel routes
* @param {string} pathPrefix - Path to prepend or empty string to strip mount path
*/
const proxyToAdmin =
(pathPrefix = '') =>
(req, res) => {
const targetPath = pathPrefix + req.url;
const options = {
hostname: 'localhost',
port: 9000,
path: targetPath,
method: req.method,
headers: {...req.headers, host: 'localhost:9000'}
};
const proxyReq = http.request(options, proxyRes => {
res.status(proxyRes.statusCode);
Object.entries(proxyRes.headers).forEach(([key, value]) => res.setHeader(key, value));
proxyRes.pipe(res);
});
proxyReq.on('error', () => res.sendStatus(502));
req.pipe(proxyReq);
};
app.use('/api/v1/admin', proxyToAdmin('/api/v1/admin'));
app.all('/admin', (req, res, next) => {
if (req.path === '/admin' && !req.path.endsWith('/')) {
return res.redirect(302, '/admin/');
}
next();
});
app.use('/admin', proxyToAdmin());
}
app.get('/index.html', (req, res) => {
return co(function* () {
const ctx = new operationContext.Context();