Merge pull request 'fix/bug-77637' (#90) from fix/bug-77637 into release/v9.2.0

Reviewed-on: https://git.onlyoffice.com/ONLYOFFICE/server/pulls/90
This commit is contained in:
Oleg Korshul
2025-11-12 19:24:20 +00:00
38 changed files with 536 additions and 160 deletions

View File

@ -75,6 +75,16 @@ body::-webkit-scrollbar-thumb {
background: #efefef;
}
a {
color: #ff6f3d;
text-decoration: underline;
font-weight: 400;
}
a:hover {
color: #e55a2b;
}
/* Spinner animation */
@keyframes spin {
from {
@ -84,3 +94,30 @@ body::-webkit-scrollbar-thumb {
transform: rotate(360deg);
}
}
/* Mobile adjustments */
@media (max-width: 767px) {
.mainContent {
padding: 21px;
padding-top: 72px; /* 56px header + 16px content padding */
/* Hide scrollbar UI on mobile while preserving scroll */
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.mainContent::-webkit-scrollbar {
width: 0;
height: 0;
display: none; /* Chrome, Safari */
}
.mobileMenuBackdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.25);
z-index: 1050;
}
}

View File

@ -1,4 +1,5 @@
import {Provider} from 'react-redux';
import {useState} from 'react';
import {Routes, Route, Navigate, BrowserRouter} from 'react-router-dom';
import './App.css';
import {store} from './store';
@ -6,18 +7,24 @@ import AuthWrapper from './components/AuthWrapper/AuthWrapper';
import ConfigLoader from './components/ConfigLoader/ConfigLoader';
import {useSchemaLoader} from './hooks/useSchemaLoader';
import Menu from './components/Menu/Menu';
import MobileHeader from './components/MobileHeader/MobileHeader';
import ScrollToTop from './components/ScrollToTop/ScrollToTop';
import {menuItems} from './config/menuItems';
import {getBasename} from './utils/paths';
function AppContent() {
useSchemaLoader();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
return (
<div className='app'>
<AuthWrapper>
<MobileHeader isOpen={isMobileMenuOpen} onMenuToggle={() => setIsMobileMenuOpen(prev => !prev)} />
<div className='appLayout'>
<Menu />
<Menu isOpen={isMobileMenuOpen} onClose={() => setIsMobileMenuOpen(false)} />
{isMobileMenuOpen ? <div className='mobileMenuBackdrop' onClick={() => setIsMobileMenuOpen(false)} aria-hidden='true'></div> : null}
<div className='mainContent'>
<ScrollToTop />
<ConfigLoader>
<Routes>
<Route path='/' element={<Navigate to='/statistics' replace />} />

View File

@ -45,7 +45,7 @@ function AccessRules({rules = [], onChange}) {
value={newRule.value}
onChange={value => setNewRule({...newRule, value})}
onKeyPress={handleKeyPress}
width='calc(100% - 32px)'
width='100%'
/>
</div>
<button className={styles.addButton} onClick={handleAddRule} disabled={!newRule.value.trim()}>

View File

@ -154,3 +154,55 @@
font-style: italic;
padding: 24px;
}
@media (max-width: 767px) {
.addRule {
flex-direction: column;
align-items: stretch;
}
.addRule :global(.selectWrapper) {
order: 2;
width: 100%;
}
.addRule :global(.select) {
width: 100%;
}
.inputWrapper {
order: 1;
}
.addButton {
order: 3;
width: 100%;
}
.rulesList {
gap: 8px;
}
.rule {
flex-direction: column;
align-items: stretch;
gap: 8px;
height: auto;
padding: 12px 0;
}
.ruleValue {
order: 1;
margin: 0;
}
.ruleType {
order: 2;
align-self: flex-start;
}
.removeButton {
order: 3;
width: 100%;
}
}

View File

@ -10,6 +10,15 @@
padding: 16px 0;
width: calc(100% - 256px);
// background-color: #fafafa;
@media (max-width: 767px) {
position: static;
left: auto;
right: auto;
width: auto;
border-top: none;
padding: 0;
}
}
.saveButtonWrapper {
@ -18,9 +27,24 @@
padding: 0 24px;
display: flex;
justify-content: flex-start;
@media (max-width: 767px) {
padding: 0;
flex-direction: column;
gap: 8px;
align-items: stretch;
}
}
// Add bottom padding to pages to prevent content from being hidden behind fixed button
.pageWithFixedSave {
padding-bottom: 40px; // Adjust based on button height + padding
}
/* Removed separate modifier; mobile behavior is now global via media query above */
.fixedSaveGroup {
@media (max-width: 767px) {
padding: 32px 0;
}
}

View File

@ -12,7 +12,7 @@ function FixedSaveButtonGroup({buttons = []}) {
if (!buttons || buttons.length === 0) return null;
return (
<div className={styles.fixedSaveContainer}>
<div className={`${styles.fixedSaveContainer} ${styles.fixedSaveGroup}`}>
<div
className={styles.saveButtonWrapper}
style={{

View File

@ -1,7 +1,7 @@
import styles from './Input.module.scss';
function Input({label, value, onChange, type = 'text', placeholder = '', error = null, description = null, width, ...props}) {
const inputStyle = width ? {width} : {};
const inputStyle = width ? {maxWidth: width} : {};
return (
<div className={styles.inputGroup}>

View File

@ -18,8 +18,10 @@
}
.input {
width: 294px;
width: 100%;
max-width: 294px;
padding: 12px 16px;
box-sizing: border-box;
border: 1px solid #e2e2e2;
border-radius: 4px;
font-family: 'Open Sans', sans-serif;
@ -61,3 +63,9 @@
color: #dc3545;
margin-top: 4px;
}
@media (max-width: 767px) {
.input {
max-width: 100%;
}
}

View File

@ -8,7 +8,7 @@ import {menuItems} from '../../config/menuItems';
import styles from './Menu.module.scss';
import FileIcon from '../../assets/File.svg';
function Menu() {
function Menu({isOpen, onClose}) {
const location = useLocation();
const navigate = useNavigate();
const dispatch = useDispatch();
@ -27,6 +27,9 @@ function Menu() {
// Clear config to force reload when switching pages
dispatch(clearConfig());
navigate(item.path);
if (onClose) {
onClose();
}
};
const isActiveItem = path => {
@ -34,16 +37,17 @@ function Menu() {
};
return (
<div className={styles.menu}>
<div className={styles['menu__content']}>
<div className={`${styles.menu} ${isOpen ? styles['menu--open'] : ''}`}>
<button className={styles['menu__closeButton']} onClick={onClose} aria-label='Close menu' />
<div className={styles['menu__header']}>
<div className={styles['menu__logoContainer']}>
<img src={AppMenuLogo} alt='ONLYOFFICE' className={styles['menu__logo']} />
</div>
<div className={styles['menu__title']}>DocServer Admin Panel</div>
<div className={styles['menu__separator']}></div>
</div>
<div className={styles['menu__content']}>
<div className={styles['menu__menuItems']}>
{menuItems.map(item => (
<MenuItem
@ -54,7 +58,16 @@ function Menu() {
icon={FileIcon}
/>
))}
<MenuItem label='Logout' isActive={false} onClick={handleLogout} />
<MenuItem
label='Logout'
isActive={false}
onClick={async () => {
if (onClose) {
onClose();
}
await handleLogout();
}}
/>
</div>
</div>
</div>

View File

@ -1,4 +1,5 @@
.menu {
position: relative;
width: 256px;
height: 100vh;
background: #f9f9f9;
@ -6,12 +7,47 @@
flex-direction: column;
overflow: hidden;
/* Mobile off-canvas behavior */
@media (max-width: 767px) {
position: fixed;
top: 0;
left: 0;
z-index: 1101; /* above backdrop, below header z-index 1100? keep slightly above */
transform: translateX(-100%);
transition: transform 0.3s ease;
box-shadow: none;
}
&--open {
@media (max-width: 767px) {
width: 240px;
transform: translateX(0);
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.04),
0 8px 24px rgba(0, 0, 0, 0.12);
.menu__header {
padding-left: 16px;
padding-top: 16px;
}
.menu__content {
padding-left: 16px;
}
}
}
&__header {
flex-shrink: 0;
padding-left: 32px;
padding-top: 32px;
padding-right: 16px;
background: #f9f9f9;
}
&__content {
display: flex;
flex-direction: column;
height: 100%;
flex: 1;
padding-left: 32px;
padding-top: 32px;
overflow-y: auto;
overflow-x: hidden;
@ -88,4 +124,46 @@
color: #666666;
}
}
&__closeButton {
display: none;
@media (max-width: 767px) {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
background: transparent;
border: none;
cursor: pointer;
padding: 0;
z-index: 10;
&::before,
&::after {
content: '';
position: absolute;
width: 20px;
height: 2px;
background: #333333;
}
&::before {
transform: rotate(45deg);
}
&::after {
transform: rotate(-45deg);
}
&:hover::before,
&:hover::after {
background: #666666;
}
}
}
}

View File

@ -0,0 +1,16 @@
import styles from './MobileHeader.module.scss';
function MobileHeader({onMenuToggle, isOpen}) {
return (
<div className={`${styles.mobileHeader} ${isOpen ? styles['mobileHeader--open'] : ''}`}>
<button className={styles.burger} onClick={onMenuToggle} aria-label='Menu' aria-expanded={isOpen}>
<span></span>
<span></span>
<span></span>
</button>
<div className={styles.title}>DocServer Admin Panel</div>
</div>
);
}
export default MobileHeader;

View File

@ -0,0 +1,56 @@
.mobileHeader {
display: none;
}
@media (max-width: 767px) {
.mobileHeader {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 56px;
background: #f9f9f9;
border-bottom: 1px solid #e2e2e2;
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
z-index: 1000;
}
.mobileHeader--open {
background: #ececec;
}
.burger {
width: 32px;
height: 32px;
display: inline-flex;
flex-direction: column;
gap: 4px;
align-items: center;
justify-content: center;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
}
.burger span {
display: block;
width: 22px;
height: 2px;
background: #333333;
margin: 0;
}
.title {
font-family: 'Open Sans', sans-serif;
font-weight: 600;
font-size: 14px;
line-height: 133%;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #333333;
}
}

View File

@ -3,7 +3,7 @@ import styles from './PasswordInput.module.scss';
function PasswordInput({label, value, onChange, placeholder = '', error = null, description = null, width, isValid = true, ...props}) {
const [showPassword, setShowPassword] = useState(false);
const inputStyle = width ? {width} : {};
const inputStyle = width ? {maxWidth: width} : {};
const togglePasswordVisibility = () => {
setShowPassword(!showPassword);

View File

@ -19,12 +19,14 @@
.inputContainer {
position: relative;
display: inline-block;
display: inline-block; /* match input width so eye stays inside */
}
.input {
width: 270px;
padding: 12px 40px 12px 16px; /* Keep original padding */
width: 100%;
max-width: 270px;
padding: 12px 45px 12px 16px; /* Keep original padding */
box-sizing: border-box;
border: 1px solid #e2e2e2;
border-radius: 4px;
font-family: 'Open Sans', sans-serif;

View File

@ -0,0 +1,17 @@
import {useEffect} from 'react';
import {useLocation} from 'react-router-dom';
export default function ScrollToTop() {
const location = useLocation();
useEffect(() => {
const scroller = document.querySelector('.mainContent');
if (scroller && typeof scroller.scrollTo === 'function') {
scroller.scrollTo({top: 0, left: 0, behavior: 'auto'});
} else {
window.scrollTo(0, 0);
}
}, [location.pathname]);
return null;
}

View File

@ -0,0 +1,17 @@
import styles from './Section.module.scss';
function Section({title, description, children, className = ''}) {
return (
<div className={`${styles.section} ${className}`}>
{(title || description) && (
<div className={styles.header}>
{title ? <div className={styles.title}>{title}</div> : null}
{description ? <div className={styles.description}>{description}</div> : null}
</div>
)}
<div className={styles.content}>{children}</div>
</div>
);
}
export default Section;

View File

@ -0,0 +1,38 @@
.section {
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 32px;
}
.header {
padding: 24px 32px 0 32px;
}
.title {
font-size: 18px;
font-weight: 600;
color: #333333;
margin: 0;
}
.description {
font-size: 14px;
color: #666666;
margin-top: 8px;
line-height: 1.5;
}
.content {
padding: 24px 32px;
}
@media (max-width: 767px) {
.header {
padding: 12px 16px 0 16px;
}
.content {
padding: 12px 16px;
}
}

View File

@ -24,13 +24,29 @@
color: #666666;
transition: color 0.2s ease;
@media (max-width: 767px) {
margin-right: 12px;
}
&:hover {
color: #333333;
}
&:focus,
&:active {
outline: none;
color: inherit; /* keep current color; active tab will override below */
}
&--active {
color: #ff6f3d;
&:hover,
&:focus,
&:active {
color: #ff6f3d; /* ensure orange while focused/pressed */
}
&::after {
content: '';
position: absolute;

View File

@ -350,6 +350,7 @@ a.aboutlink:active {
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
padding: 0;
}
.select2-dropdown,

View File

@ -6,6 +6,7 @@ import PasswordInput from '../../components/PasswordInput/PasswordInput';
import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
import PasswordInputWithRequirements from '../../components/PasswordInputWithRequirements/PasswordInputWithRequirements';
import {usePasswordValidation} from '../../utils/passwordValidation';
import Section from '../../components/Section/Section';
import styles from './ChangePassword.module.scss';
function ChangePassword() {
@ -56,7 +57,7 @@ function ChangePassword() {
<PageDescription>Update your admin panel password</PageDescription>
<div className={styles.content}>
<div className={styles.section}>
<Section>
{passwordSuccess && <div className={styles.successMessage}>Password changed successfully!</div>}
{passwordError && <div className={styles.errorMessage}>{passwordError}</div>}
@ -98,7 +99,7 @@ function ChangePassword() {
<FixedSaveButton onClick={handlePasswordChange} disabled={!canSubmit()} />
</div>
</div>
</Section>
</div>
</div>
);

View File

@ -9,6 +9,7 @@ import PageDescription from '../../components/PageDescription/PageDescription';
import Tabs from '../../components/Tabs/Tabs';
import Input from '../../components/Input/Input';
import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
import Section from '../../components/Section/Section';
import styles from './Expiration.module.scss';
const expirationTabs = [
@ -134,7 +135,7 @@ function Expiration() {
switch (activeTab) {
case 'garbage-collection':
return (
<div className={styles.tabPanel}>
<Section>
<div className={styles.formRow}>
<Input
label='Cache Cleanup Cron Expression'
@ -182,11 +183,11 @@ function Expiration() {
error={getFieldError(CONFIG_PATHS.filesremovedatonce)}
/>
</div>
</div>
</Section>
);
case 'session-management':
return (
<div className={styles.tabPanel}>
<Section>
<div className={styles.formRow}>
<Input
label='Session Idle Timeout'
@ -208,7 +209,7 @@ function Expiration() {
error={getFieldError(CONFIG_PATHS.sessionabsolute)}
/>
</div>
</div>
</Section>
);
default:
return null;

View File

@ -47,3 +47,9 @@
.pageWithFixedSave {
padding-bottom: 40px;
}
@media (max-width: 767px) {
.pageWithFixedSave {
padding-bottom: 0;
}
}

View File

@ -8,6 +8,7 @@ import PageHeader from '../../components/PageHeader/PageHeader';
import PageDescription from '../../components/PageDescription/PageDescription';
import Input from '../../components/Input/Input';
import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
import Section from '../../components/Section/Section';
import styles from './FileLimits.module.scss';
function FileLimits() {
@ -168,9 +169,7 @@ function FileLimits() {
<PageHeader>File Size Limits</PageHeader>
<PageDescription>Configure maximum file sizes and download limits for document processing</PageDescription>
<div className={styles.settingsSection}>
<div className={styles.sectionTitle}>Download Limits</div>
<Section title='Download Limits'>
<div className={styles.formRow}>
<Input
label='Max Download Bytes'
@ -183,12 +182,12 @@ function FileLimits() {
error={getFieldError(CONFIG_PATHS.maxDownloadBytes)}
/>
</div>
</div>
<div className={styles.settingsSection}>
<div className={styles.sectionTitle}>Input File Size Limits</div>
<div className={styles.sectionDescription}>Configure uncompressed size limits for different document types when processing ZIP archives</div>
</Section>
<Section
title='Input File Size Limits'
description='Configure uncompressed size limits for different document types when processing ZIP archives'
>
<div className={styles.formRow}>
<Input
label='Word Documents (DOCX, DOTX, DOCM, DOTM)'
@ -228,7 +227,7 @@ function FileLimits() {
description='Maximum uncompressed size for Visio document archives'
/>
</div>
</div>
</Section>
<FixedSaveButton onClick={handleSave} disabled={!hasChanges || hasValidationErrors()}>
Save Changes

View File

@ -132,37 +132,3 @@
transform: rotate(360deg);
}
}
// Responsive design
@media (max-width: 768px) {
.forgottenPage {
// padding: 15px;
.pageHeader {
flex-direction: column;
gap: 15px;
align-items: flex-start;
h1 {
font-size: 24px;
}
}
.filesList {
.fileRow {
flex-direction: column;
align-items: flex-start;
gap: 12px;
.fileName {
margin-right: 0;
margin-bottom: 8px;
}
.downloadBtn {
align-self: flex-end;
}
}
}
}
}

View File

@ -3,6 +3,7 @@ import {checkHealth} from '../../api';
import PageHeader from '../../components/PageHeader/PageHeader';
import PageDescription from '../../components/PageDescription/PageDescription';
import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
import Section from '../../components/Section/Section';
import styles from './HealthCheck.module.scss';
function HealthCheck() {
@ -39,7 +40,7 @@ function HealthCheck() {
<PageHeader>Health Check</PageHeader>
<PageDescription>Monitor the status of DocService backend</PageDescription>
<div className={styles.statusCard}>
<Section>
<div className={styles.statusHeader}>
<div className={styles.statusIndicator} style={{backgroundColor: getStatusColor()}} />
<h3 className={styles.statusTitle}>DocService Status</h3>
@ -58,7 +59,7 @@ function HealthCheck() {
</div>
)}
</div>
</div>
</Section>
<FixedSaveButton onClick={fetchHealthStatus} disabled={loading} disableResult={true}>
{loading ? 'Checking...' : 'Refresh'}

View File

@ -8,6 +8,7 @@ import PageHeader from '../../components/PageHeader/PageHeader';
import PageDescription from '../../components/PageDescription/PageDescription';
import Select from '../../components/Select/Select';
import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
import Section from '../../components/Section/Section';
import styles from './LoggerConfig.module.scss';
const LOG_LEVELS = [
@ -104,7 +105,7 @@ function LoggerConfig() {
<PageHeader>Logger Configuration</PageHeader>
<PageDescription>Configure the logging level for the application</PageDescription>
<div className={styles.configSection}>
<Section title='Logger Settings'>
<div className={styles.formRow}>
<div className={styles.fieldGroup}>
<label className={styles.label}>Log Level:</label>
@ -118,7 +119,7 @@ function LoggerConfig() {
{getFieldError(CONFIG_PATHS.logLevel) && <div className={styles.error}>{getFieldError(CONFIG_PATHS.logLevel)}</div>}
</div>
</div>
</div>
</Section>
<FixedSaveButton onClick={handleSave} disabled={!hasChanges || hasValidationErrors()}>
Save Changes

View File

@ -39,7 +39,19 @@ export default function Login() {
<div className={styles.loginCard}>
<h1 className={styles.title}>ONLYOFFICE Admin Panel</h1>
<p className={styles.subtitle}>Enter your password to access the admin panel</p>
<p className={styles.description}>The session is valid for 60 minutes.</p>
<div className={styles.descriptionContainer}>
<p className={styles.description}>The session is valid for 60 minutes.</p>
<p className={styles.description}>
Need to reset your password? See{' '}
<a
href='https://helpcenter.onlyoffice.com/docs/installation/docs-admin-panel.aspx#passwordresetrecovery_block'
target='_blank'
rel='noopener noreferrer'
>
password recovery documentation
</a>
</p>
</div>
<div className={styles.form}>
<div className={styles.inputGroup}>

View File

@ -31,10 +31,17 @@
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.descriptionContainer {
display: flex;
flex-direction: column;
gap: 16px;
margin: 0 0 32px 0;
}
.description {
color: #333;
font-size: 14px;
margin: 0 0 32px 0;
margin: 0;
line-height: 1.5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

View File

@ -10,6 +10,7 @@ import Tabs from '../../components/Tabs/Tabs';
import Input from '../../components/Input/Input';
import Checkbox from '../../components/Checkbox/Checkbox';
import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
import Section from '../../components/Section/Section';
import styles from './NotificationConfig.module.scss';
const emailConfigTabs = [
@ -159,7 +160,7 @@ function EmailConfig() {
switch (activeTab) {
case 'smtp-server':
return (
<div className={styles.tabPanel}>
<Section>
<div className={styles.formRow}>
<Input
label='SMTP Host:'
@ -207,11 +208,11 @@ function EmailConfig() {
error={getFieldError(CONFIG_PATHS.smtpPassword)}
/>
</div>
</div>
</Section>
);
case 'defaults':
return (
<div className={styles.tabPanel}>
<Section>
<div className={styles.formRow}>
<Input
label='Default From Email:'
@ -235,14 +236,12 @@ function EmailConfig() {
error={getFieldError(CONFIG_PATHS.defaultToEmail)}
/>
</div>
</div>
</Section>
);
case 'notifications':
return (
<>
<div className={styles.settingsSection}>
<div className={styles.sectionTitle}>License Expiration Warning</div>
<div className={styles.sectionDescription}>Configure email notifications when the license is about to expire</div>
<Section title='License Expiration Warning' description='Configure email notifications when the license is about to expire'>
<div className={styles.formRow}>
<Checkbox
label='Enable'
@ -261,11 +260,9 @@ function EmailConfig() {
error={getFieldError(CONFIG_PATHS.licenseExpirationWarningRepeatInterval)}
/>
</div>
</div>
</Section>
<div className={styles.settingsSection}>
<div className={styles.sectionTitle}>License Expiration Error</div>
<div className={styles.sectionDescription}>Configure email notifications when the license has expired</div>
<Section title='License Expiration Error' description='Configure email notifications when the license has expired'>
<div className={styles.formRow}>
<Checkbox
label='Enable'
@ -284,11 +281,9 @@ function EmailConfig() {
error={getFieldError(CONFIG_PATHS.licenseExpirationErrorRepeatInterval)}
/>
</div>
</div>
</Section>
<div className={styles.settingsSection}>
<div className={styles.sectionTitle}>License Limit Edit</div>
<div className={styles.sectionDescription}>Configure email notifications when the edit limit is reached</div>
<Section title='License Limit Edit' description='Configure email notifications when the edit limit is reached'>
<div className={styles.formRow}>
<Checkbox
label='Enable'
@ -307,11 +302,9 @@ function EmailConfig() {
error={getFieldError(CONFIG_PATHS.licenseLimitEditRepeatInterval)}
/>
</div>
</div>
</Section>
<div className={styles.settingsSection}>
<div className={styles.sectionTitle}>License Limit Live Viewer</div>
<div className={styles.sectionDescription}>Configure email notifications when the live viewer limit is reached</div>
<Section title='License Limit Live Viewer' description='Configure email notifications when the live viewer limit is reached'>
<div className={styles.formRow}>
<Checkbox
label='Enable'
@ -330,7 +323,7 @@ function EmailConfig() {
error={getFieldError(CONFIG_PATHS.licenseLimitLiveViewerRepeatInterval)}
/>
</div>
</div>
</Section>
</>
);
default:

View File

@ -8,6 +8,7 @@ import Checkbox from '../../components/Checkbox/Checkbox';
import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
import PageHeader from '../../components/PageHeader/PageHeader';
import PageDescription from '../../components/PageDescription/PageDescription';
import Section from '../../components/Section/Section';
import styles from './RequestFiltering.module.scss';
function RequestFiltering() {
@ -91,10 +92,7 @@ function RequestFiltering() {
Configure request filtering settings to control which IP addresses are allowed to make requests to the server.
</PageDescription>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>IP Address Filtering</h2>
<p className={styles.sectionDescription}>Control access based on IP address types to enhance security.</p>
<Section title='IP Address Filtering' description='Control access based on IP address types to enhance security.'>
<div className={styles.formRow}>
<Checkbox
label='Use IP filtering for requests'
@ -124,7 +122,7 @@ function RequestFiltering() {
error={getFieldError(CONFIG_PATHS.allowMetaIPAddress)}
/>
</div>
</div>
</Section>
<FixedSaveButton onClick={handleSave} disabled={!hasChanges || hasValidationErrors()}>
Save Changes

View File

@ -1,5 +1,6 @@
import {resetConfiguration} from '../../api';
import Button from '../../components/Button/Button';
import Section from '../../components/Section/Section';
import './Settings.scss';
const Settings = () => {
@ -17,18 +18,13 @@ const Settings = () => {
<h1>Settings</h1>
</div>
<div className='settings-content'>
<div className='settings-section'>
<div className='settings-item'>
<div className='settings-info'>
<h3>Reset Configuration</h3>
<p>This will reset all configuration settings to their default values. This action cannot be undone.</p>
</div>
<div className='settings-actions'>
<Button onClick={handleResetConfig}>Reset</Button>
</div>
</div>
</div>
<div className='settings-content' title='Settings'>
<Section
title='Reset Configuration'
description='This will reset all configuration settings to their default values. This action cannot be undone.'
>
<Button onClick={handleResetConfig}>Reset</Button>
</Section>
</div>
</div>
);

View File

@ -118,31 +118,3 @@
}
}
}
// Responsive design
@media (max-width: 768px) {
.settings-page {
padding: 15px;
.page-header h1 {
font-size: 24px;
}
.settings-content {
.settings-section {
.settings-item {
flex-direction: column;
gap: 15px;
.settings-actions {
width: 100%;
.reset-btn {
width: 100%;
}
}
}
}
}
}
}

View File

@ -4,6 +4,13 @@
gap: 24px;
}
@media (max-width: 767px) {
.topRow {
flex-direction: column;
gap: 12px;
}
}
.modeBar {
margin: 8px 0 16px;
font-size: 14px;

View File

@ -12,6 +12,7 @@ import Input from '../../components/Input/Input';
import Checkbox from '../../components/Checkbox/Checkbox';
import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
import Note from '../../components/Note/Note';
import Section from '../../components/Section/Section';
import styles from './WOPISettings.module.scss';
function WOPISettings() {
@ -123,32 +124,28 @@ function WOPISettings() {
<PageHeader>WOPI Settings</PageHeader>
<PageDescription>Configure WOPI (Web Application Open Platform Interface) support for document editing</PageDescription>
<div className={styles.settingsSection}>
<Section>
<ToggleSwitch label='WOPI' checked={localWopiEnabled} onChange={handleWopiEnabledChange} />
</div>
</Section>
{localWopiEnabled && (
<>
<div className={styles.settingsSection}>
<div className={styles.sectionTitle}>Lock Settings</div>
<div className={styles.sectionDescription}>Configure document lock refresh interval for WOPI sessions.</div>
<Section title='Lock Settings' description='Configure document lock refresh interval for WOPI sessions.'>
<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>
</Section>
<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>
<Section
title='Key Management'
description='Rotate WOPI encryption keys. Current keys will be moved to "Old" and new keys will be generated.'
>
<div className={styles.noteWrapper}>
<Note type='warning'>Do not rotate keys more than once per 24 hours; storage may not refresh in time and authentication can fail.</Note>
</div>
@ -158,7 +155,6 @@ function WOPISettings() {
value={maskKey(wopiPublicKey)}
disabled
placeholder='No key generated'
width='400px'
style={{fontFamily: 'Courier New, monospace'}}
/>
</div>
@ -171,7 +167,7 @@ function WOPISettings() {
description="Generate new encryption keys. Current keys will be moved to 'Old'."
/>
</div>
</div>
</Section>
</>
)}

View File

@ -42,11 +42,11 @@
}
.formRow {
margin-bottom: 16px;
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
}
// Ensure SaveButton aligns properly in form row

View File

@ -175,6 +175,7 @@ const lockDocumentsTimerId = {}; //to drop connection that can't unlockDocument
let pubsub;
let queue;
let shutdownFlag = false;
let preStopFlag = false;
const expDocumentsStep = gc.getCronStep(cfgExpDocumentsCron);
const MIN_SAVE_EXPIRATION = 60000;
@ -191,6 +192,10 @@ function getIsShutdown() {
return shutdownFlag;
}
function getIsPreStop() {
return preStopFlag;
}
function getEditorConfig(ctx) {
let tenEditor = ctx.getCfg('services.CoAuthoring.editor', cfgEditor);
tenEditor = JSON.parse(JSON.stringify(tenEditor));
@ -1534,7 +1539,7 @@ function* cleanDocumentOnExit(ctx, docId, deleteChanges, opt_userIndex) {
//clean redis (redisKeyPresenceSet and redisKeyPresenceHash removed with last element)
yield editorData.cleanDocumentOnExit(ctx, docId);
if (editorStatProxy?.deleteKey) {
if (preStopFlag && editorStatProxy?.deleteKey) {
yield editorStatProxy.deleteKey(docId);
}
//remove changes
@ -1772,6 +1777,7 @@ exports.hasEditors = hasEditors;
exports.getEditorsCountPromise = co.wrap(getEditorsCount);
exports.getCallback = getCallback;
exports.getIsShutdown = getIsShutdown;
exports.getIsPreStop = getIsPreStop;
exports.hasChanges = hasChanges;
exports.cleanDocumentOnExitPromise = co.wrap(cleanDocumentOnExit);
exports.cleanDocumentOnExitNoChangesPromise = co.wrap(cleanDocumentOnExitNoChanges);
@ -2178,7 +2184,7 @@ exports.install = function (server, app, callbackFunction) {
);
}
} else {
if (hvals?.length <= 0 && editorStatProxy?.deleteKey) {
if (preStopFlag && hvals?.length <= 0 && editorStatProxy?.deleteKey) {
yield editorStatProxy.deleteKey(docId);
}
}
@ -4214,9 +4220,12 @@ exports.install = function (server, app, callbackFunction) {
ctx.initFromConnection(conn);
//todo group by tenant
yield ctx.initTenantCache();
const tenExpSessionIdle = ms(ctx.getCfg('services.CoAuthoring.expire.sessionidle', cfgExpSessionIdle));
let tenExpSessionIdle = ms(ctx.getCfg('services.CoAuthoring.expire.sessionidle', cfgExpSessionIdle)) || 0;
const tenExpSessionAbsolute = ms(ctx.getCfg('services.CoAuthoring.expire.sessionabsolute', cfgExpSessionAbsolute));
const tenExpSessionCloseCommand = ms(ctx.getCfg('services.CoAuthoring.expire.sessionclosecommand', cfgExpSessionCloseCommand));
if (preStopFlag && (tenExpSessionIdle > 5 * 60 * 1000 || tenExpSessionIdle <= 0)) {
tenExpSessionIdle = 5 * 60 * 1000; //5 minutes
}
const maxMs = nowMs + Math.max(tenExpSessionCloseCommand, expDocumentsStep);
let tenant = tenants[ctx.tenant];
@ -4732,6 +4741,26 @@ exports.shutdown = function (req, res) {
}
});
};
exports.preStop = async function (req, res) {
let output = false;
const ctx = new operationContext.Context();
try {
ctx.initFromRequest(req);
await ctx.initTenantCache();
preStopFlag = req.method === 'PUT';
ctx.logger.info('preStop set flag', preStopFlag);
if (preStopFlag) {
await gc.checkFileExpire(0);
}
output = true;
} catch (err) {
ctx.logger.error('preStop error %s', err.stack);
} finally {
res.setHeader('Content-Type', 'text/plain');
res.send(output.toString());
ctx.logger.info('preStop end');
}
};
/**
* Get active connections array
* @returns {Array} Active connections

View File

@ -467,6 +467,9 @@ const cleanupCache = co.wrap(function* (ctx, docId) {
const removeRes = yield taskResult.remove(ctx, docId);
if (removeRes.affectedRows > 0) {
yield storage.deletePath(ctx, docId);
if (docsCoServer?.editorStatProxy?.deleteKey) {
yield docsCoServer.editorStatProxy.deleteKey(docId);
}
res = true;
}
ctx.logger.debug('cleanupCache docId=%s db.affectedRows=%d', docId, removeRes.affectedRows);
@ -479,6 +482,9 @@ const cleanupCacheIf = co.wrap(function* (ctx, mask) {
if (removeRes.affectedRows > 0) {
sqlBase.deleteChanges(ctx, mask.key, null);
yield storage.deletePath(ctx, mask.key);
if (docsCoServer?.editorStatProxy?.deleteKey) {
yield docsCoServer.editorStatProxy.deleteKey(mask.key);
}
res = true;
}
ctx.logger.debug('cleanupCacheIf db.affectedRows=%d', removeRes.affectedRows);
@ -1300,7 +1306,7 @@ const commandSfcCallback = co.wrap(function* (ctx, cmd, isSfcm, isEncrypted) {
//todo simultaneous opening
//clean redis (redisKeyPresenceSet and redisKeyPresenceHash removed with last element)
yield docsCoServer.editorData.cleanDocumentOnExit(ctx, docId);
if (docsCoServer?.editorStatProxy?.deleteKey) {
if (docsCoServer.getIsPreStop() && docsCoServer?.editorStatProxy?.deleteKey) {
yield docsCoServer.editorStatProxy.deleteKey(docId);
}
//to unlock wopi file

View File

@ -274,6 +274,9 @@ docsCoServer.install(server, app, () => {
app.use('/info', infoRouter(docsCoServer.getConnections));
app.put('/internal/cluster/inactive', utils.checkClientIp, docsCoServer.shutdown);
app.delete('/internal/cluster/inactive', utils.checkClientIp, docsCoServer.shutdown);
app.put('/internal/cluster/pre-stop', utils.checkClientIp, docsCoServer.preStop);
app.delete('/internal/cluster/pre-stop', utils.checkClientIp, docsCoServer.preStop);
app.get('/internal/connections/edit', docsCoServer.getEditorConnectionsCount);
function checkWopiEnable(req, res, next) {