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,5 @@
# Admin Panel Environment Variables
# Copy this file to .env for local development
# Backend URL for API calls
REACT_APP_BACKEND_URL=http://localhost:9000 REACT_APP_BACKEND_URL=http://localhost:9000

View File

@ -2768,6 +2768,12 @@
"is-obj": "^2.0.0" "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": { "dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

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

View File

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <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="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta name="description" content="Document Server Admin Panel" /> <meta name="description" content="Document Server Admin Panel" />

View File

@ -74,3 +74,13 @@ body::-webkit-scrollbar {
body::-webkit-scrollbar-thumb { body::-webkit-scrollbar-thumb {
background: #efefef; 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 {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 './App.css';
import {store} from './store'; import {store} from './store';
import AuthWrapper from './components/AuthWrapper/AuthWrapper'; import AuthWrapper from './components/AuthWrapper/AuthWrapper';
import ConfigLoader from './components/ConfigLoader/ConfigLoader';
import Menu from './components/Menu/Menu'; import Menu from './components/Menu/Menu';
import {menuItems} from './config/menuItems'; 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() { function App() {
const basename = getBasename();
return ( return (
<Provider store={store}> <Provider store={store}>
<div className='app'> <BrowserRouter basename={basename}>
<AuthWrapper> <div className='app'>
<div className='appLayout'> <AuthWrapper>
<Menu /> <div className='appLayout'>
<div className='mainContent'> <Menu />
<Routes> <div className='mainContent'>
<Route path='/' element={<Navigate to='/statistics' replace />} /> <ConfigLoader>
{menuItems.map(item => ( <Routes>
<Route key={item.key} path={item.path} element={<item.component />} /> <Route path='/' element={<Navigate to='/statistics' replace />} />
))} {menuItems.map(item => (
</Routes> <Route key={item.key} path={item.path} element={<item.component />} />
))}
</Routes>
</ConfigLoader>
</div>
</div> </div>
</div> </AuthWrapper>
</AuthWrapper> </div>
</div> </BrowserRouter>
</Provider> </Provider>
); );
} }

View File

@ -1,7 +1,8 @@
const BACKEND_URL = process.env.REACT_APP_BACKEND_URL ?? ''; const BACKEND_URL = process.env.REACT_APP_BACKEND_URL ?? '';
const API_BASE_PATH = '/api/v1/admin';
export const fetchStatistics = async () => { 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) { if (!response.ok) {
throw new Error('Failed to fetch statistics'); throw new Error('Failed to fetch statistics');
} }
@ -9,7 +10,7 @@ export const fetchStatistics = async () => {
}; };
export const fetchConfiguration = 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' credentials: 'include'
}); });
if (!response.ok) { if (!response.ok) {
@ -19,7 +20,7 @@ export const fetchConfiguration = async () => {
}; };
export const fetchConfigurationSchema = 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' credentials: 'include'
}); });
if (!response.ok) { if (!response.ok) {
@ -29,7 +30,7 @@ export const fetchConfigurationSchema = async () => {
}; };
export const updateConfiguration = async configData => { 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', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json' '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 // Return the new config from the server
const contentType = response.headers.get('content-type'); return response.json();
if (contentType && contentType.includes('application/json')) {
return response.json();
} else {
return response.text();
}
}; };
export const fetchCurrentUser = async () => { 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', method: 'GET',
credentials: 'include' // Include cookies in the request credentials: 'include' // Include cookies in the request
}); });
@ -75,7 +71,7 @@ export const fetchCurrentUser = async () => {
}; };
export const login = async ({tenantName, secret}) => { 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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@ -95,7 +91,7 @@ export const login = async ({tenantName, secret}) => {
}; };
export const logout = async () => { 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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@ -109,3 +105,20 @@ export const logout = async () => {
return response.json(); 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 {useLocation, useNavigate} from 'react-router-dom';
import {selectIsAuthenticated} from '../../store/slices/userSlice'; import {selectIsAuthenticated} from '../../store/slices/userSlice';
import {clearConfig} from '../../store/slices/configSlice';
import {logout} from '../../api'; import {logout} from '../../api';
import MenuItem from './MenuItem/MenuItem'; import MenuItem from './MenuItem/MenuItem';
import AppMenuLogo from '../../assets/AppMenuLogo.svg'; import AppMenuLogo from '../../assets/AppMenuLogo.svg';
@ -10,6 +11,7 @@ import styles from './Menu.module.scss';
function Menu() { function Menu() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useDispatch();
const isAuthenticated = useSelector(selectIsAuthenticated); const isAuthenticated = useSelector(selectIsAuthenticated);
const handleLogout = async () => { const handleLogout = async () => {
@ -25,11 +27,13 @@ function Menu() {
}; };
const handleMenuItemClick = item => { const handleMenuItemClick = item => {
// Clear config to force reload when switching pages
dispatch(clearConfig());
navigate(item.path); navigate(item.path);
}; };
const isActiveItem = path => { const isActiveItem = path => {
return location.pathname === path; return location.pathname.endsWith(path);
}; };
return ( return (

View File

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

View File

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

View File

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

View File

@ -43,3 +43,7 @@
display: flex; display: flex;
justify-content: flex-start; 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 {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 {getNestedValue} from '../../utils/getNestedValue';
import {mergeNestedObjects} from '../../utils/mergeNestedObjects'; import {mergeNestedObjects} from '../../utils/mergeNestedObjects';
import {useFieldValidation} from '../../hooks/useFieldValidation'; import {useFieldValidation} from '../../hooks/useFieldValidation';
@ -8,7 +8,7 @@ import PageHeader from '../../components/PageHeader/PageHeader';
import PageDescription from '../../components/PageDescription/PageDescription'; import PageDescription from '../../components/PageDescription/PageDescription';
import Tabs from '../../components/Tabs/Tabs'; import Tabs from '../../components/Tabs/Tabs';
import Input from '../../components/Input/Input'; import Input from '../../components/Input/Input';
import SaveButton from '../../components/SaveButton/SaveButton'; import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
import styles from './Expiration.module.scss'; import styles from './Expiration.module.scss';
const expirationTabs = [ const expirationTabs = [
@ -19,8 +19,7 @@ const expirationTabs = [
function Expiration() { function Expiration() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const config = useSelector(selectConfig); const config = useSelector(selectConfig);
const loading = useSelector(selectConfigLoading); const {validateField, getFieldError, hasValidationErrors, clearFieldError} = useFieldValidation();
const {validateField, getFieldError, hasValidationErrors} = useFieldValidation();
const [activeTab, setActiveTab] = useState('garbage-collection'); const [activeTab, setActiveTab] = useState('garbage-collection');
@ -34,6 +33,7 @@ function Expiration() {
sessionabsolute: '' sessionabsolute: ''
}); });
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);
const hasInitialized = useRef(false);
// Configuration paths // Configuration paths
const CONFIG_PATHS = { const CONFIG_PATHS = {
@ -45,11 +45,9 @@ function Expiration() {
sessionabsolute: 'services.CoAuthoring.expire.sessionabsolute' sessionabsolute: 'services.CoAuthoring.expire.sessionabsolute'
}; };
// Load config data when component mounts // Reset state and errors to global config
useEffect(() => { const resetToGlobalConfig = () => {
if (!config) { if (config) {
dispatch(fetchConfig());
} else {
const settings = {}; const settings = {};
Object.keys(CONFIG_PATHS).forEach(key => { Object.keys(CONFIG_PATHS).forEach(key => {
const value = getNestedValue(config, CONFIG_PATHS[key], ''); const value = getNestedValue(config, CONFIG_PATHS[key], '');
@ -57,8 +55,24 @@ function Expiration() {
}); });
setLocalSettings(settings); setLocalSettings(settings);
setHasChanges(false); 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 // Handle field changes
const handleFieldChange = (field, value) => { const handleFieldChange = (field, value) => {
@ -201,24 +215,18 @@ function Expiration() {
} }
}; };
if (loading) {
return <div className={styles.loading}>Loading expiration settings...</div>;
}
return ( return (
<div className={styles.expiration}> <div className={`${styles.expiration} ${styles.pageWithFixedSave}`}>
<PageHeader>Expiration Settings</PageHeader> <PageHeader>Expiration Settings</PageHeader>
<PageDescription>Configure file cleanup schedules, session timeouts, and garbage collection settings</PageDescription> <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()} {renderTabContent()}
</Tabs> </Tabs>
<div className={styles.actions}> <FixedSaveButton onClick={handleSave} disabled={!hasChanges || hasValidationErrors()}>
<SaveButton onClick={handleSave} disabled={!hasChanges || hasValidationErrors()}> Save Changes
Save Changes </FixedSaveButton>
</SaveButton>
</div>
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,10 @@
padding: 0; padding: 0;
} }
.pageWithFixedSave {
padding-bottom: 40px;
}
.loading { .loading {
display: flex; display: flex;
justify-content: center; 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 {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 {getNestedValue} from '../../utils/getNestedValue';
import {mergeNestedObjects} from '../../utils/mergeNestedObjects'; import {mergeNestedObjects} from '../../utils/mergeNestedObjects';
import {useFieldValidation} from '../../hooks/useFieldValidation'; import {useFieldValidation} from '../../hooks/useFieldValidation';
import {maskKey} from '../../utils/maskKey';
import PageHeader from '../../components/PageHeader/PageHeader'; import PageHeader from '../../components/PageHeader/PageHeader';
import PageDescription from '../../components/PageDescription/PageDescription'; import PageDescription from '../../components/PageDescription/PageDescription';
import ToggleSwitch from '../../components/ToggleSwitch/ToggleSwitch'; 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'; import styles from './WOPISettings.module.scss';
function WOPISettings() { function WOPISettings() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const config = useSelector(selectConfig); const config = useSelector(selectConfig);
const schema = useSelector(selectSchema);
const loading = useSelector(selectConfigLoading);
const {validateField, hasValidationErrors} = useFieldValidation(); const {validateField, hasValidationErrors} = useFieldValidation();
// Local state for WOPI enable setting // Local state for WOPI settings
const [localWopiEnabled, setLocalWopiEnabled] = useState(false); const [localWopiEnabled, setLocalWopiEnabled] = useState(false);
const [localRotateKeys, setLocalRotateKeys] = useState(false);
const [localRefreshLockInterval, setLocalRefreshLockInterval] = useState('');
const [hasChanges, setHasChanges] = useState(false); 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 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 const resetToGlobalConfig = () => {
useEffect(() => {
if (config) { if (config) {
setLocalWopiEnabled(configWopiEnabled); setLocalWopiEnabled(configWopiEnabled);
setLocalRotateKeys(false);
setLocalRefreshLockInterval(configRefreshLockInterval);
setHasChanges(false); setHasChanges(false);
validateField('wopi.enable', configWopiEnabled);
validateField('wopi.refreshLockInterval', configRefreshLockInterval);
} }
}, [config, configWopiEnabled]); };
useEffect(() => { // Initialize settings from config when component loads (only once)
if (!config || !schema) { if (config && !hasInitialized.current) {
dispatch(fetchConfig()); resetToGlobalConfig();
} hasInitialized.current = true;
}, [dispatch, config, schema]); }
const handleWopiEnabledChange = enabled => { const handleWopiEnabledChange = enabled => {
setLocalWopiEnabled(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 // Validate the boolean field
validateField('wopi.enable', enabled); 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 () => { const handleSave = async () => {
if (!hasChanges) return; if (!hasChanges) return;
try { try {
const updatedConfig = mergeNestedObjects([{'wopi.enable': localWopiEnabled}]); const enableChanged = localWopiEnabled !== configWopiEnabled;
await dispatch(saveConfig(updatedConfig)).unwrap(); 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); setHasChanges(false);
setLocalRotateKeys(false);
} catch (error) { } catch (error) {
console.error('Failed to save WOPI settings:', error); console.error('Failed to save WOPI settings:', error);
// Revert local state on error // Revert local state on error
setLocalWopiEnabled(configWopiEnabled); setLocalWopiEnabled(configWopiEnabled);
setLocalRotateKeys(false);
setLocalRefreshLockInterval(configRefreshLockInterval);
setHasChanges(false); setHasChanges(false);
} }
}; };
if (loading) {
return <div className={styles.loading}>Loading WOPI settings...</div>;
}
return ( return (
<div className={styles.wopiSettings}> <div className={`${styles.wopiSettings} ${styles.pageWithFixedSave}`}>
<PageHeader>WOPI Settings</PageHeader> <PageHeader>WOPI Settings</PageHeader>
<PageDescription>Configure WOPI (Web Application Open Platform Interface) support for document editing</PageDescription> <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} /> <ToggleSwitch label='WOPI' checked={localWopiEnabled} onChange={handleWopiEnabledChange} />
</div> </div>
<div className={styles.actions}> {localWopiEnabled && (
<SaveButton onClick={handleSave} disabled={!hasChanges || hasValidationErrors()}> <>
Save Changes <div className={styles.settingsSection}>
</SaveButton> <div className={styles.sectionTitle}>Lock Settings</div>
</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> </div>
); );
} }

View File

@ -2,6 +2,10 @@
padding: 0; padding: 0;
} }
.pageWithFixedSave {
padding-bottom: 80px;
}
.loading { .loading {
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -19,6 +23,41 @@
margin-bottom: 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;
}
// 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 { .actions {
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;

View File

@ -1,10 +1,19 @@
import {createSlice, createAsyncThunk} from '@reduxjs/toolkit'; 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}) => { export const fetchConfig = createAsyncThunk('config/fetchConfig', async (_, {rejectWithValue}) => {
try { try {
const [config, schema] = await Promise.all([fetchConfiguration(), fetchConfigurationSchema()]); const config = await fetchConfiguration();
return {config, schema}; 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) { } catch (error) {
return rejectWithValue(error.message); 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}) => { export const saveConfig = createAsyncThunk('config/saveConfig', async (configData, {rejectWithValue}) => {
try { try {
await updateConfiguration(configData); const newConfig = await updateConfiguration(configData);
return 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) { } catch (error) {
return rejectWithValue(error); return rejectWithValue(error);
} }
@ -23,8 +41,10 @@ const initialState = {
config: null, config: null,
schema: null, schema: null,
loading: false, loading: false,
schemaLoading: false,
saving: false, saving: false,
error: null error: null,
schemaError: null
}; };
const configSlice = createSlice({ const configSlice = createSlice({
@ -37,6 +57,11 @@ const configSlice = createSlice({
state.config = {...state.config, ...action.payload}; state.config = {...state.config, ...action.payload};
} }
}, },
clearConfig: state => {
state.config = null;
state.loading = false;
state.error = null;
},
clearError: state => { clearError: state => {
state.error = null; state.error = null;
} }
@ -51,13 +76,26 @@ const configSlice = createSlice({
.addCase(fetchConfig.fulfilled, (state, action) => { .addCase(fetchConfig.fulfilled, (state, action) => {
state.loading = false; state.loading = false;
state.config = action.payload.config; state.config = action.payload.config;
state.schema = action.payload.schema;
state.error = null; state.error = null;
}) })
.addCase(fetchConfig.rejected, (state, action) => { .addCase(fetchConfig.rejected, (state, action) => {
state.loading = false; state.loading = false;
state.error = action.payload; 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 // Save config cases
.addCase(saveConfig.pending, state => { .addCase(saveConfig.pending, state => {
state.saving = true; state.saving = true;
@ -65,26 +103,39 @@ const configSlice = createSlice({
}) })
.addCase(saveConfig.fulfilled, (state, action) => { .addCase(saveConfig.fulfilled, (state, action) => {
state.saving = false; state.saving = false;
// Update the global config with the saved changes // Update the global config with the complete new config from server
if (state.config) { state.config = action.payload;
state.config = {...state.config, ...action.payload};
}
state.error = null; state.error = null;
}) })
.addCase(saveConfig.rejected, (state, action) => { .addCase(saveConfig.rejected, (state, action) => {
state.saving = false; state.saving = false;
state.error = action.payload; 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 // Selectors
export const selectConfig = state => state.config.config; export const selectConfig = state => state.config.config;
export const selectSchema = state => state.config.schema; export const selectSchema = state => state.config.schema;
export const selectConfigLoading = state => state.config.loading; export const selectConfigLoading = state => state.config.loading;
export const selectSchemaLoading = state => state.config.schemaLoading;
export const selectConfigSaving = state => state.config.saving; export const selectConfigSaving = state => state.config.saving;
export const selectConfigError = state => state.config.error; export const selectConfigError = state => state.config.error;
export const selectSchemaError = state => state.config.schemaError;
export default configSlice.reducer; 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 HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin'); const CopyPlugin = require('copy-webpack-plugin');
const webpack = require('webpack'); 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 = { module.exports = {
entry: './src/index.js', entry: './src/index.js',
@ -15,7 +24,7 @@ module.exports = {
devServer: { devServer: {
static: { static: {
directory: path.join(__dirname, 'build'), directory: path.join(__dirname, 'build'),
publicPath: '/' publicPath: ''
}, },
port: 3000, port: 3000,
open: true, open: true,
@ -30,11 +39,18 @@ module.exports = {
patterns: [ patterns: [
{ {
context: path.resolve(__dirname, 'public'), 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), 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 express = require('express');
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
const tenantManager = require('../../../../../Common/sources/tenantManager'); const tenantManager = require('../../../../../Common/sources/tenantManager');
const operationContext = require('../../../../../Common/sources/operationContext');
const runtimeConfigManager = require('../../../../../Common/sources/runtimeConfigManager'); const runtimeConfigManager = require('../../../../../Common/sources/runtimeConfigManager');
const utils = require('../../../../../Common/sources/utils'); const utils = require('../../../../../Common/sources/utils');
const {getScopedConfig, validateScoped, getScopedSchema} = require('./config.service'); const {getScopedConfig, validateScoped, getScopedSchema} = require('./config.service');
const jwt = require('jsonwebtoken'); const {validateJWT} = require('../../middleware/auth');
const cookieParser = require('cookie-parser'); const cookieParser = require('cookie-parser');
const adminPanelJwtSecret = config.get('adminPanel.jwtSecret');
const router = express.Router(); const router = express.Router();
router.use(cookieParser()); 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) => { router.get('/', validateJWT, async (req, res) => {
const ctx = req.ctx; const ctx = req.ctx;
try { try {
@ -89,7 +69,10 @@ router.patch('/', validateJWT, rawFileParser, async (req, res) => {
} else { } else {
await runtimeConfigManager.saveConfig(ctx, newConfig); await runtimeConfigManager.saveConfig(ctx, newConfig);
} }
res.sendStatus(200);
await ctx.initTenantCache();
const filteredConfig = getScopedConfig(ctx);
res.status(200).json(filteredConfig);
} catch (error) { } catch (error) {
ctx.logger.error('Configuration save error: %s', error.stack); ctx.logger.error('Configuration save error: %s', error.stack);
res.status(500).json({error: 'Internal server error', details: error.message}); 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 configRouter = require('./routes/config/router');
const adminpanelRouter = require('./routes/adminpanel/router'); const adminpanelRouter = require('./routes/adminpanel/router');
const wopiRouter = require('./routes/wopi/router');
const port = config.get('adminPanel.port'); const port = config.get('adminPanel.port');
@ -81,6 +82,7 @@ const corsWithCredentials = cors({
operationContext.global.logger.warn('AdminPanel server starting...'); operationContext.global.logger.warn('AdminPanel server starting...');
app.use('/api/v1/admin/config', corsWithCredentials, utils.checkClientIp, configRouter); 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.use('/api/v1/admin', corsWithCredentials, utils.checkClientIp, adminpanelRouter);
app.get('/api/v1/admin/stat', corsWithCredentials, utils.checkClientIp, infoRouter.licenseInfo); app.get('/api/v1/admin/stat', corsWithCredentials, utils.checkClientIp, infoRouter.licenseInfo);

View File

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

View File

@ -167,7 +167,13 @@
"additionalProperties": false, "additionalProperties": false,
"x-scope": ["admin", "tenant"], "x-scope": ["admin", "tenant"],
"properties": { "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": { "email": {

View File

@ -521,9 +521,10 @@ async function postRequestPromise(ctx, uri, postData, postDataStream, postDataSi
* @param {object} opt_timeout - Optional timeout configuration. * @param {object} opt_timeout - Optional timeout configuration.
* @param {number} opt_limit - Optional limit on the size of the response. * @param {number} opt_limit - Optional limit on the size of the response.
* @param {boolean} opt_filterPrivate - Optional flag to filter private requests. * @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. * @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); const tenTenantRequestDefaults = ctx.getCfg('services.CoAuthoring.requestDefaults', cfgRequestDefaults);
uri = URI.serialize(URI.parse(uri)); uri = URI.serialize(URI.parse(uri));
const options = config.util.cloneDeep(tenTenantRequestDefaults); const options = config.util.cloneDeep(tenTenantRequestDefaults);
@ -548,6 +549,7 @@ async function httpRequest(ctx, method, uri, opt_headers, opt_body, opt_timeout,
const axiosConfig = { const axiosConfig = {
...options, ...options,
...opt_axiosConfig,
url: uri, url: uri,
method, method,
headers: requestHeaders, headers: requestHeaders,

View File

@ -300,7 +300,10 @@ async function proxyRequest(req, res) {
requestParams.body, // Request body requestParams.body, // Request body
requestParams.timeout, // Timeout configuration requestParams.timeout, // Timeout configuration
requestParams.limit, // Size limit 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 // 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) => { app.get('/index.html', (req, res) => {
return co(function* () { return co(function* () {
const ctx = new operationContext.Context(); const ctx = new operationContext.Context();