diff --git a/AdminPanel/client/.env b/AdminPanel/client/.env index 56dee1ac..f0801fcd 100644 --- a/AdminPanel/client/.env +++ b/AdminPanel/client/.env @@ -1 +1 @@ -REACT_APP_BACKEND_URL=http://localhost:9000 \ No newline at end of file +REACT_APP_BACKEND_URL=http://localhost:9000 diff --git a/AdminPanel/client/.env.example b/AdminPanel/client/.env.example index 56dee1ac..6f7c3da9 100644 --- a/AdminPanel/client/.env.example +++ b/AdminPanel/client/.env.example @@ -1 +1,5 @@ -REACT_APP_BACKEND_URL=http://localhost:9000 \ No newline at end of file +# Admin Panel Environment Variables +# Copy this file to .env for local development + +# Backend URL for API calls +REACT_APP_BACKEND_URL=http://localhost:9000 diff --git a/AdminPanel/client/package-lock.json b/AdminPanel/client/package-lock.json index 8eed51d7..1ba9d979 100644 --- a/AdminPanel/client/package-lock.json +++ b/AdminPanel/client/package-lock.json @@ -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", diff --git a/AdminPanel/client/package.json b/AdminPanel/client/package.json index 6295dc9d..7884270b 100644 --- a/AdminPanel/client/package.json +++ b/AdminPanel/client/package.json @@ -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", diff --git a/AdminPanel/client/public/index.html b/AdminPanel/client/public/index.html index 81bb060f..9ae0a010 100644 --- a/AdminPanel/client/public/index.html +++ b/AdminPanel/client/public/index.html @@ -2,7 +2,7 @@ - + diff --git a/AdminPanel/client/src/App.css b/AdminPanel/client/src/App.css index 574210ba..8b4c671f 100644 --- a/AdminPanel/client/src/App.css +++ b/AdminPanel/client/src/App.css @@ -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); + } +} diff --git a/AdminPanel/client/src/App.js b/AdminPanel/client/src/App.js index 808cd1be..aa291604 100644 --- a/AdminPanel/client/src/App.js +++ b/AdminPanel/client/src/App.js @@ -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 ( -
- -
- -
- - } /> - {menuItems.map(item => ( - } /> - ))} - + +
+ +
+ +
+ + + } /> + {menuItems.map(item => ( + } /> + ))} + + +
-
- -
+ +
+ ); } diff --git a/AdminPanel/client/src/api/index.js b/AdminPanel/client/src/api/index.js index 8bc4ec27..38f26e6e 100644 --- a/AdminPanel/client/src/api/index.js +++ b/AdminPanel/client/src/api/index.js @@ -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(); +}; diff --git a/AdminPanel/client/src/components/ConfigLoader/ConfigLoader.js b/AdminPanel/client/src/components/ConfigLoader/ConfigLoader.js new file mode 100644 index 00000000..7f7fbb2d --- /dev/null +++ b/AdminPanel/client/src/components/ConfigLoader/ConfigLoader.js @@ -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 ( +
+ + + +

Loading configuration...

+
+ ); + } + + if (error) { + return ( +
+

Error loading configuration: {error}

+ +
+ ); + } + + if (!config || !schema) { + return null; + } + + return children; +}; + +export default ConfigLoader; diff --git a/AdminPanel/client/src/components/FixedSaveButton/FixedSaveButton.js b/AdminPanel/client/src/components/FixedSaveButton/FixedSaveButton.js new file mode 100644 index 00000000..50228e6a --- /dev/null +++ b/AdminPanel/client/src/components/FixedSaveButton/FixedSaveButton.js @@ -0,0 +1,16 @@ +import SaveButton from '../SaveButton/SaveButton'; +import styles from './FixedSaveButton.module.scss'; + +function FixedSaveButton({onClick, disabled, children = 'Save Changes'}) { + return ( +
+
+ + {children} + +
+
+ ); +} + +export default FixedSaveButton; diff --git a/AdminPanel/client/src/components/FixedSaveButton/FixedSaveButton.module.scss b/AdminPanel/client/src/components/FixedSaveButton/FixedSaveButton.module.scss new file mode 100644 index 00000000..5e3388d3 --- /dev/null +++ b/AdminPanel/client/src/components/FixedSaveButton/FixedSaveButton.module.scss @@ -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 +} diff --git a/AdminPanel/client/src/components/Menu/Menu.js b/AdminPanel/client/src/components/Menu/Menu.js index ba5cb97d..d4afc213 100644 --- a/AdminPanel/client/src/components/Menu/Menu.js +++ b/AdminPanel/client/src/components/Menu/Menu.js @@ -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 ( diff --git a/AdminPanel/client/src/hooks/useFieldValidation.js b/AdminPanel/client/src/hooks/useFieldValidation.js index 7df8e78a..43be1765 100644 --- a/AdminPanel/client/src/hooks/useFieldValidation.js +++ b/AdminPanel/client/src/hooks/useFieldValidation.js @@ -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 diff --git a/AdminPanel/client/src/index.js b/AdminPanel/client/src/index.js index 6f50428e..5fbe9e45 100644 --- a/AdminPanel/client/src/index.js +++ b/AdminPanel/client/src/index.js @@ -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( - - - + ); diff --git a/AdminPanel/client/src/pages/EmailConfig/EmailConfig.js b/AdminPanel/client/src/pages/EmailConfig/EmailConfig.js index 6c27ae09..99a7c9c6 100644 --- a/AdminPanel/client/src/pages/EmailConfig/EmailConfig.js +++ b/AdminPanel/client/src/pages/EmailConfig/EmailConfig.js @@ -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
Loading email configuration...
; - } - return ( -
+
Email Configuration Configure SMTP server settings, security options, and default email addresses - + {renderTabContent()} -
- - Save Changes - -
+ + Save Changes +
); } diff --git a/AdminPanel/client/src/pages/EmailConfig/EmailConfig.module.scss b/AdminPanel/client/src/pages/EmailConfig/EmailConfig.module.scss index d182c815..39741d5d 100644 --- a/AdminPanel/client/src/pages/EmailConfig/EmailConfig.module.scss +++ b/AdminPanel/client/src/pages/EmailConfig/EmailConfig.module.scss @@ -43,3 +43,7 @@ display: flex; justify-content: flex-start; } + +.pageWithFixedSave { + padding-bottom: 40px; +} diff --git a/AdminPanel/client/src/pages/Expiration/Expiration.js b/AdminPanel/client/src/pages/Expiration/Expiration.js index 8202b197..522ac93b 100644 --- a/AdminPanel/client/src/pages/Expiration/Expiration.js +++ b/AdminPanel/client/src/pages/Expiration/Expiration.js @@ -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
Loading expiration settings...
; - } - return ( -
+
Expiration Settings Configure file cleanup schedules, session timeouts, and garbage collection settings - + {renderTabContent()} -
- - Save Changes - -
+ + Save Changes +
); } diff --git a/AdminPanel/client/src/pages/Expiration/Expiration.module.scss b/AdminPanel/client/src/pages/Expiration/Expiration.module.scss index 693c8d1f..1c8d8042 100644 --- a/AdminPanel/client/src/pages/Expiration/Expiration.module.scss +++ b/AdminPanel/client/src/pages/Expiration/Expiration.module.scss @@ -43,3 +43,7 @@ display: flex; justify-content: flex-start; } + +.pageWithFixedSave { + padding-bottom: 40px; +} diff --git a/AdminPanel/client/src/pages/FileLimits/FileLimits.js b/AdminPanel/client/src/pages/FileLimits/FileLimits.js index 3bd2b23e..7c4fb525 100644 --- a/AdminPanel/client/src/pages/FileLimits/FileLimits.js +++ b/AdminPanel/client/src/pages/FileLimits/FileLimits.js @@ -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
Loading file limits settings...
; - } - return ( -
+
File Size Limits Configure maximum file sizes and download limits for document processing @@ -237,11 +230,9 @@ function FileLimits() {
-
- - Save Changes - -
+ + Save Changes +
); } diff --git a/AdminPanel/client/src/pages/FileLimits/FileLimits.module.scss b/AdminPanel/client/src/pages/FileLimits/FileLimits.module.scss index 35a97fb0..69380bd5 100644 --- a/AdminPanel/client/src/pages/FileLimits/FileLimits.module.scss +++ b/AdminPanel/client/src/pages/FileLimits/FileLimits.module.scss @@ -43,3 +43,7 @@ display: flex; justify-content: flex-start; } + +.pageWithFixedSave { + padding-bottom: 40px; +} diff --git a/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.js b/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.js index ff1e3f79..f9313aad 100644 --- a/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.js +++ b/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.js @@ -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
Loading request filtering settings...
; - } - return ( -
+
Request Filtering Configure request filtering settings to control which IP addresses are allowed to make requests to the server. @@ -118,11 +114,9 @@ function RequestFiltering() {
-
- - Save Changes - -
+ + Save Changes +
); } diff --git a/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.module.scss b/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.module.scss index 3dad8d43..9ac325b9 100644 --- a/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.module.scss +++ b/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.module.scss @@ -67,3 +67,7 @@ display: flex; justify-content: flex-start; } + +.pageWithFixedSave { + padding-bottom: 40px; +} diff --git a/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.js b/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.js index 2ebd7eac..0dcb893e 100644 --- a/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.js +++ b/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.js @@ -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
Loading security settings...
; - } - return ( -
+
Security Settings Configure IP filtering, authentication, and security policies - + {renderTabContent()} -
- - Save Changes - -
+ + Save Changes +
); } diff --git a/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.module.scss b/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.module.scss index 83f55c5c..a1dffe09 100644 --- a/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.module.scss +++ b/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.module.scss @@ -2,6 +2,10 @@ padding: 0; } +.pageWithFixedSave { + padding-bottom: 40px; +} + .loading { display: flex; justify-content: center; diff --git a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js index 57a6a2f3..70fca1b4 100644 --- a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js +++ b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js @@ -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
Loading WOPI settings...
; - } - return ( -
+
WOPI Settings Configure WOPI (Web Application Open Platform Interface) support for document editing @@ -74,11 +126,54 @@ function WOPISettings() {
-
- - Save Changes - -
+ {localWopiEnabled && ( + <> +
+
Lock Settings
+
Configure document lock refresh interval for WOPI sessions.
+
+ +
+
+ +
+
Key Management
+
+ Rotate WOPI encryption keys. Current keys will be moved to "Old" and new keys will be generated. +
+
+ +
+
+ +
+
+ + )} + + + Save Changes +
); } diff --git a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.module.scss b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.module.scss index 50a5ea70..20f12b08 100644 --- a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.module.scss +++ b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.module.scss @@ -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; diff --git a/AdminPanel/client/src/store/slices/configSlice.js b/AdminPanel/client/src/store/slices/configSlice.js index 2b463db1..a59935fa 100644 --- a/AdminPanel/client/src/store/slices/configSlice.js +++ b/AdminPanel/client/src/store/slices/configSlice.js @@ -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; diff --git a/AdminPanel/client/src/utils/maskKey.js b/AdminPanel/client/src/utils/maskKey.js new file mode 100644 index 00000000..5be9841e --- /dev/null +++ b/AdminPanel/client/src/utils/maskKey.js @@ -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}`; +}; diff --git a/AdminPanel/client/webpack.config.js b/AdminPanel/client/webpack.config.js index 33fa3870..739485dd 100644 --- a/AdminPanel/client/webpack.config.js +++ b/AdminPanel/client/webpack.config.js @@ -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' } ] }), diff --git a/AdminPanel/server/sources/middleware/auth.js b/AdminPanel/server/sources/middleware/auth.js new file mode 100644 index 00000000..202f2aa5 --- /dev/null +++ b/AdminPanel/server/sources/middleware/auth.js @@ -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 +}; diff --git a/AdminPanel/server/sources/routes/config/router.js b/AdminPanel/server/sources/routes/config/router.js index 9de7be70..255e85e2 100644 --- a/AdminPanel/server/sources/routes/config/router.js +++ b/AdminPanel/server/sources/routes/config/router.js @@ -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}); diff --git a/AdminPanel/server/sources/routes/wopi/router.js b/AdminPanel/server/sources/routes/wopi/router.js new file mode 100644 index 00000000..85e9b797 --- /dev/null +++ b/AdminPanel/server/sources/routes/wopi/router.js @@ -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; diff --git a/AdminPanel/server/sources/server.js b/AdminPanel/server/sources/server.js index 4ad8ef60..008d40df 100644 --- a/AdminPanel/server/sources/server.js +++ b/AdminPanel/server/sources/server.js @@ -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); diff --git a/Common/config/default.json b/Common/config/default.json index 9dd71463..f5df142b 100644 --- a/Common/config/default.json +++ b/Common/config/default.json @@ -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" diff --git a/Common/config/schemas/config.schema.json b/Common/config/schemas/config.schema.json index 71b8a0da..e14a2581 100644 --- a/Common/config/schemas/config.schema.json +++ b/Common/config/schemas/config.schema.json @@ -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": { diff --git a/Common/sources/utils.js b/Common/sources/utils.js index 4b793354..3c249e56 100644 --- a/Common/sources/utils.js +++ b/Common/sources/utils.js @@ -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, diff --git a/DocService/sources/ai/aiProxyHandler.js b/DocService/sources/ai/aiProxyHandler.js index cf40b3ee..ce25be66 100644 --- a/DocService/sources/ai/aiProxyHandler.js +++ b/DocService/sources/ai/aiProxyHandler.js @@ -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 diff --git a/DocService/sources/server.js b/DocService/sources/server.js index 81b75984..4bdbb4b4 100644 --- a/DocService/sources/server.js +++ b/DocService/sources/server.js @@ -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();