Merge pull request 'fix/admin-panel-bugs' (#83) from fix/admin-panel-bugs into hotfix/v9.2.0

Reviewed-on: https://git.onlyoffice.com/ONLYOFFICE/server/pulls/83
This commit is contained in:
Sergey Konovalov
2025-10-25 08:32:12 +00:00
41 changed files with 940 additions and 445 deletions

View File

@ -1,4 +1,4 @@
module.exports = {
'*.js': ['eslint', 'prettier'],
'*.js': ['eslint', 'prettier --check'],
'*.{json,md,html,css,yml,yaml}': []
};

View File

@ -1,5 +1,5 @@
{
"name": "docscloud",
"name": "onlyoffice-adminpanel-client",
"version": "1.3.0",
"lockfileVersion": 1,
"requires": true,
@ -1901,6 +1901,11 @@
"require-from-string": "^2.0.2"
}
},
"ajv-errors": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz",
"integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ=="
},
"ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",

View File

@ -1,5 +1,5 @@
{
"name": "docscloud",
"name": "onlyoffice-adminpanel-client",
"version": "1.3.0",
"private": true,
"scripts": {
@ -10,6 +10,7 @@
"@reduxjs/toolkit": "^2.8.2",
"@tanstack/react-query": "^5.83.0",
"ajv": "^8.17.1",
"ajv-errors": "^3.0.0",
"ajv-formats": "^3.0.1",
"axios": "1.7.4",
"prop-types": "^15.8.1",

View File

@ -4,33 +4,42 @@ import './App.css';
import {store} from './store';
import AuthWrapper from './components/AuthWrapper/AuthWrapper';
import ConfigLoader from './components/ConfigLoader/ConfigLoader';
import {useSchemaLoader} from './hooks/useSchemaLoader';
import Menu from './components/Menu/Menu';
import {menuItems} from './config/menuItems';
import {getBasename} from './utils/paths';
function AppContent() {
useSchemaLoader();
return (
<div className='app'>
<AuthWrapper>
<div className='appLayout'>
<Menu />
<div className='mainContent'>
<ConfigLoader>
<Routes>
<Route path='/' element={<Navigate to='/statistics' replace />} />
<Route path='/index.html' element={<Navigate to='/statistics' replace />} />
{menuItems.map(item => (
<Route key={item.key} path={item.path} element={<item.component />} />
))}
</Routes>
</ConfigLoader>
</div>
</div>
</AuthWrapper>
</div>
);
}
function App() {
const basename = getBasename();
return (
<Provider store={store}>
<BrowserRouter basename={basename}>
<div className='app'>
<AuthWrapper>
<div className='appLayout'>
<Menu />
<div className='mainContent'>
<ConfigLoader>
<Routes>
<Route path='/' element={<Navigate to='/statistics' replace />} />
<Route path='/index.html' element={<Navigate to='/statistics' replace />} />
{menuItems.map(item => (
<Route key={item.key} path={item.path} element={<item.component />} />
))}
</Routes>
</ConfigLoader>
</div>
</div>
</AuthWrapper>
</div>
<AppContent />
</BrowserRouter>
</Provider>
);

View File

@ -23,7 +23,7 @@ const safeFetch = async (url, options = {}) => {
};
export const fetchStatistics = async () => {
const response = await safeFetch(`${API_BASE_PATH}/stat`);
const response = await safeFetch(`${API_BASE_PATH}/stat`, {credentials: 'include'});
if (!response.ok) throw new Error('Failed to fetch statistics');
return response.json();
};
@ -37,7 +37,6 @@ export const fetchConfiguration = async () => {
export const fetchConfigurationSchema = async () => {
const response = await safeFetch(`${API_BASE_PATH}/config/schema`, {credentials: 'include'});
if (response.status === 401) throw new Error('UNAUTHORIZED');
if (!response.ok) throw new Error('Failed to fetch configuration schema');
return response.json();
};
@ -50,6 +49,7 @@ export const updateConfiguration = async configData => {
body: JSON.stringify(configData)
});
if (!response.ok) {
if (response.status === 401) throw new Error('UNAUTHORIZED');
let errorMessage = 'Configuration update failed';
try {
const errorData = await response.json();
@ -64,10 +64,9 @@ export const updateConfiguration = async configData => {
export const fetchCurrentUser = async () => {
const response = await safeFetch(`${API_BASE_PATH}/me`, {credentials: 'include'});
if (!response.ok) throw new Error('Failed to fetch current user');
const data = await response.json();
if (data && data.authorized === false) {
throw new Error('Unauthorized');
throw new Error('UNAUTHORIZED');
}
return data;
};
@ -129,6 +128,7 @@ export const changePassword = async ({currentPassword, newPassword}) => {
body: JSON.stringify({currentPassword, newPassword})
});
if (!response.ok) {
if (response.status === 401) throw new Error('UNAUTHORIZED');
let errorMessage = 'Password change failed';
try {
const errorData = await response.json();
@ -158,6 +158,7 @@ export const rotateWopiKeys = async () => {
credentials: 'include'
});
if (!response.ok) {
if (response.status === 401) throw new Error('UNAUTHORIZED');
let errorMessage = 'Failed to rotate WOPI keys';
try {
const errorData = await response.json();
@ -178,13 +179,19 @@ export const checkHealth = async () => {
return true;
};
export const resetConfiguration = async () => {
export const resetConfiguration = async (paths = ['*']) => {
const pathsArray = Array.isArray(paths) ? paths : [paths];
const response = await safeFetch(`${API_BASE_PATH}/config/reset`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'include'
credentials: 'include',
body: JSON.stringify({paths: pathsArray})
});
if (!response.ok) throw new Error('Failed to reset configuration');
if (!response.ok) {
if (response.status === 401) throw new Error('UNAUTHORIZED');
throw new Error('Failed to reset configuration');
}
return response.json();
};

View File

@ -1,3 +1,3 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.2877 0.290737C12.6782 -0.0997279 13.3152 -0.0958616 13.7057 0.294643C14.096 0.685163 14.1 1.32214 13.7096 1.71261L8.42151 6.99972L13.7096 12.2878C14.0998 12.6784 14.0961 13.3154 13.7057 13.7058C13.3153 14.0962 12.6783 14.0999 12.2877 13.7097L6.99963 8.4216L1.71252 13.7097C1.32204 14.1 0.685046 14.0961 0.294556 13.7058C-0.0959208 13.3153 -0.0997296 12.6783 0.290649 12.2878L5.57874 6.99972L0.290649 1.71261C-0.0998747 1.32209 -0.0959682 0.685168 0.294556 0.294643C0.68508 -0.0958808 1.322 -0.0997871 1.71252 0.290737L6.99963 5.57882L12.2877 0.290737Z" fill="white"/>
<svg width="7" height="7" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.2877 0.290737C12.6782 -0.0997279 13.3152 -0.0958616 13.7057 0.294643C14.096 0.685163 14.1 1.32214 13.7096 1.71261L8.42151 6.99972L13.7096 12.2878C14.0998 12.6784 14.0961 13.3154 13.7057 13.7058C13.3153 14.0962 12.6783 14.0999 12.2877 13.7097L6.99963 8.4216L1.71252 13.7097C1.32204 14.1 0.685046 14.0961 0.294556 13.7058C-0.0959208 13.3153 -0.0997296 12.6783 0.290649 12.2878L5.57874 6.99972L0.290649 1.71261C-0.0998747 1.32209 -0.0959682 0.685168 0.294556 0.294643C0.68508 -0.0958808 1.322 -0.0997871 1.71252 0.290737L6.99963 5.57882L12.2877 0.290737Z" fill="#ff4444"/>
</svg>

Before

Width:  |  Height:  |  Size: 684 B

After

Width:  |  Height:  |  Size: 684 B

View File

@ -0,0 +1,3 @@
<svg width="7" height="7" viewBox="0 0 18 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.81342 10.8535L1.66675 6.70683L0.253418 8.12016L5.81342 13.6668L17.7468 1.7335L16.3334 0.333496L5.81342 10.8535Z" fill="#4caf50"/>
</svg>

After

Width:  |  Height:  |  Size: 243 B

View File

@ -1,6 +1,7 @@
import {useEffect, useState} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {fetchUser, selectUser, selectUserLoading, selectIsAuthenticated} from '../../store/slices/userSlice';
import {setPasswordSchema} from '../../store/slices/configSlice';
import {checkSetupRequired} from '../../api';
import Spinner from '../../assets/Spinner.svg';
import Login from '../../pages/Login/LoginPage';
@ -22,6 +23,11 @@ export default function AuthWrapper({children}) {
try {
const result = await checkSetupRequired();
setSetupRequired(result.setupRequired);
// Save minimal password schema to Redux for Setup page validation
if (result.passwordValidationSchema) {
dispatch(setPasswordSchema(result.passwordValidationSchema));
}
} catch (error) {
console.error('Error checking setup:', error);
setServerUnavailable(true);
@ -31,7 +37,7 @@ export default function AuthWrapper({children}) {
};
checkSetup();
}, []);
}, [dispatch]);
useEffect(() => {
if (!checkingSetup && !setupRequired && !serverUnavailable) {

View File

@ -1,10 +1,9 @@
import React, {useState, useEffect} from 'react';
import styles from './SaveButton.module.scss';
import React, {useState, useEffect, forwardRef} from 'react';
import styles from './Button.module.scss';
import Spinner from '../../assets/Spinner.svg';
import Success from '../../assets/Success.svg';
import Fail from '../../assets/Fail.svg';
function SaveButton({onClick, children = 'Save Changes', disabled = false, disableResult = false}) {
const Button = forwardRef(({onClick, children = 'Save Changes', disabled = false, disableResult = false, errorText = 'FAILED', className}, ref) => {
const [state, setState] = useState('idle'); // 'idle', 'loading', 'success', 'error'
// Reset to idle after showing success/error for 3 seconds
@ -29,7 +28,7 @@ function SaveButton({onClick, children = 'Save Changes', disabled = false, disab
setState('idle');
}
} catch (error) {
console.error('Save failed:', error);
console.error('Button action failed:', error);
if (!disableResult) {
setState('error');
} else {
@ -39,12 +38,13 @@ function SaveButton({onClick, children = 'Save Changes', disabled = false, disab
};
const getButtonClass = () => {
let className = styles.saveButton;
if (disabled && state === 'idle') className += ` ${styles['saveButton--disabled']}`;
if (state === 'loading') className += ` ${styles['saveButton--loading']}`;
if (state === 'success') className += ` ${styles['saveButton--success']}`;
if (state === 'error') className += ` ${styles['saveButton--error']}`;
return className;
let buttonClass = styles.button;
if (disabled && state === 'idle') buttonClass += ` ${styles['button--disabled']}`;
if (state === 'loading') buttonClass += ` ${styles['button--loading']}`;
if (state === 'success') buttonClass += ` ${styles['button--success']}`;
if (state === 'error') buttonClass += ` ${styles['button--error']}`;
if (className) buttonClass += ` ${className}`;
return buttonClass;
};
const getButtonContent = () => {
@ -54,18 +54,20 @@ function SaveButton({onClick, children = 'Save Changes', disabled = false, disab
case 'success':
return <img src={Success} alt='Success' className={styles.icon} />;
case 'error':
return <img src={Fail} alt='Error' className={styles.icon} />;
return errorText;
default:
return children;
}
};
return (
<button className={getButtonClass()} onClick={handleClick} disabled={disabled || state !== 'idle'}>
<button ref={ref} className={getButtonClass()} onClick={handleClick} disabled={disabled || state !== 'idle'}>
{getButtonContent()}
</button>
);
}
});
// Memoize the SaveButton to prevent unnecessary rerenders when props haven't changed
export default React.memo(SaveButton);
Button.displayName = 'Button';
// Memoize the Button to prevent unnecessary rerenders when props haven't changed
export default React.memo(Button);

View File

@ -1,4 +1,4 @@
.saveButton {
.button {
background: #ff6f3d;
width: 154px;
height: 48px;
@ -43,13 +43,13 @@
height: 20px;
// Spinning animation for the spinner
.saveButton--loading & {
.button--loading & {
animation: spin 1s linear infinite;
}
// Smaller size for success and fail icons
.saveButton--success &,
.saveButton--error & {
.button--success &,
.button--error & {
width: 14px;
height: 14px;
}

View File

@ -1,16 +1,8 @@
import {useEffect} from 'react';
import {useSelector, useDispatch} from 'react-redux';
import {
selectConfig,
selectConfigLoading,
selectConfigError,
selectSchema,
selectSchemaLoading,
selectSchemaError,
fetchConfig,
fetchSchema
} from '../../store/slices/configSlice';
import Button from '../LoginButton';
import {selectConfig, selectConfigLoading, selectConfigError, selectSchema, fetchConfig} from '../../store/slices/configSlice';
import {selectIsAuthenticated} from '../../store/slices/userSlice';
import Button from '../Button/Button';
const ConfigLoader = ({children}) => {
const dispatch = useDispatch();
@ -18,23 +10,16 @@ const ConfigLoader = ({children}) => {
const configLoading = useSelector(selectConfigLoading);
const configError = useSelector(selectConfigError);
const schema = useSelector(selectSchema);
const schemaLoading = useSelector(selectSchemaLoading);
const schemaError = useSelector(selectSchemaError);
const isAuthenticated = useSelector(selectIsAuthenticated);
const loading = configLoading || schemaLoading;
const error = configError || schemaError;
const loading = configLoading;
const error = configError;
useEffect(() => {
// Fetch config if not loaded
if (!config && !configLoading && !configError) {
if (isAuthenticated && !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]);
}, [config, configLoading, configError, isAuthenticated, dispatch]);
if (loading) {
return (

View File

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

View File

@ -1,4 +1,4 @@
import SaveButton from '../SaveButton/SaveButton';
import Button from '../Button/Button';
import styles from '../FixedSaveButton/FixedSaveButton.module.scss';
/**
@ -21,9 +21,9 @@ function FixedSaveButtonGroup({buttons = []}) {
}}
>
{buttons.map((button, index) => (
<SaveButton key={index} onClick={button.onClick} disabled={button.disabled || false} disableResult={true}>
<Button key={index} onClick={button.onClick} disabled={button.disabled || false} disableResult={true}>
{button.text}
</SaveButton>
</Button>
))}
</div>
</div>

View File

@ -1,72 +0,0 @@
import {useState, forwardRef} from 'react';
import styles from './styles.module.css';
import Spinner from '../../assets/Spinner.svg';
import Success from '../../assets/Success.svg';
const Button = forwardRef(({onClick, disabled, children, className, errorText = 'FAILED'}, ref) => {
const [state, setState] = useState('idle'); // 'idle', 'loading', 'success', 'error'
const [isProcessing, setIsProcessing] = useState(false);
const handleClick = async () => {
if (isProcessing) return;
setIsProcessing(true);
setState('loading');
try {
await onClick();
setState('success');
// Show success for 3 seconds
setTimeout(() => {
setState('idle');
setIsProcessing(false);
}, 1000);
} catch (_error) {
setState('error');
// Show error for 3 seconds
setTimeout(() => {
setState('idle');
setIsProcessing(false);
}, 1000);
}
};
const getButtonContent = () => {
switch (state) {
case 'loading':
return (
<>
<img src={Spinner} alt='Loading' className={styles.icon} />
</>
);
case 'success':
return (
<>
<img src={Success} alt='Success' className={styles.icon} />
</>
);
case 'error':
return errorText;
default:
return children;
}
};
const getButtonClassName = () => {
const baseClass = styles.button;
const stateClass = state !== 'idle' ? styles[state] : '';
return `${baseClass} ${stateClass} ${className || ''}`.trim();
};
return (
<button ref={ref} className={getButtonClassName()} onClick={handleClick} disabled={disabled || isProcessing}>
{getButtonContent()}
</button>
);
});
Button.displayName = 'Button';
export default Button;

View File

@ -1,53 +0,0 @@
.button {
background: rgb(255, 111, 61);
color: white;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
margin-top: 16px;
width: 160px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 46px;
}
.button:hover:not(:disabled) {
background: rgb(255, 149, 113);
}
.button:disabled {
background: #6c757d;
cursor: not-allowed;
}
.button.loading,
.button.success {
background-color: rgb(139, 184, 37);
}
.button.error {
background-color: rgb(187, 5, 5);
}
.icon {
width: 16px;
height: 16px;
}
.button.loading .icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@ -1,39 +0,0 @@
import styles from './styles.module.css';
export default function Input({
_label,
_description,
value,
onChange,
placeholder = '',
type = 'text',
error = null,
className = '',
onKeyDown = null,
min = null,
max = null
}) {
if (type === 'checkbox') {
return (
<input
type='checkbox'
checked={Boolean(value)}
onChange={e => onChange(e.target.checked)}
className={`${styles.input} ${error ? styles.inputError : ''} ${className}`}
/>
);
}
return (
<input
type={type}
value={value}
onChange={e => onChange(type === 'number' ? e.target.valueAsNumber : e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
className={`${styles.input} ${error ? styles.inputError : ''} ${className}`}
min={min}
max={max}
/>
);
}

View File

@ -1,55 +0,0 @@
.inputContainer {
display: flex;
flex-direction: column;
gap: 4px;
}
.label {
font-size: 12px;
font-weight: 500;
color: #333;
margin: 0;
}
.input {
padding: 11px 20px;
border: 1px solid rgb(170, 170, 170);
border-radius: 3px;
font-size: 12px;
transition: border-color 0.2s ease;
background-color: rgb(249, 249, 249);
}
.input:focus {
outline: none;
border-color: #000000;
}
/* Remove number input spinners */
.input[type='number']::-webkit-outer-spin-button,
.input[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.input[type='number'] {
-moz-appearance: textfield;
}
.inputError {
border-color: #dc3545;
}
.description {
font-size: 11px;
color: #666;
margin: 0;
line-height: 1.3;
}
.error {
font-size: 11px;
color: #dc3545;
margin: 0;
line-height: 1.3;
}

View File

@ -0,0 +1,56 @@
import {useState} from 'react';
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 togglePasswordVisibility = () => {
setShowPassword(!showPassword);
};
return (
<div className={styles.inputGroup}>
{label && <label className={styles.label}>{label}</label>}
{description && <p className={styles.description}>{description}</p>}
<div className={styles.inputContainer}>
<input
className={`${styles.input} ${error ? styles['input--error'] : ''} ${!isValid && value ? styles['input--invalid'] : ''}`}
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
spellCheck={false}
style={inputStyle}
{...props}
type={showPassword ? 'text' : 'password'}
/>
<button
type='button'
className={styles.toggleButton}
onClick={togglePasswordVisibility}
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? (
<svg width='20' height='20' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' className={styles.eyeIcon}>
<path
d='M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z'
fill='#666666'
/>
<path d='M2 2l20 20' stroke='#666666' strokeWidth='2' strokeLinecap='round' />
</svg>
) : (
<svg width='20' height='20' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' className={styles.eyeIcon}>
<path
d='M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z'
fill='#666666'
/>
</svg>
)}
</button>
</div>
{error && <span className={styles.error}>{error}</span>}
</div>
);
}
export default PasswordInput;

View File

@ -0,0 +1,104 @@
.label {
display: block;
font-family: 'Open Sans', sans-serif;
font-weight: 600;
font-size: 14px;
line-height: 150%;
color: #333333;
margin-bottom: 6px;
}
.description {
font-family: 'Open Sans', sans-serif;
font-weight: 400;
font-size: 13px;
line-height: 150%;
color: #666666;
margin: 0 0 8px 0;
}
.inputContainer {
position: relative;
display: inline-block;
}
.input {
width: 270px;
padding: 12px 40px 12px 16px; /* Keep original padding */
border: 1px solid #e2e2e2;
border-radius: 4px;
font-family: 'Open Sans', sans-serif;
font-weight: 400;
font-size: 16px;
line-height: 150%;
color: #808080;
background: #f1f1f1;
vertical-align: middle;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
color 0.2s ease;
&:focus {
outline: none;
border: 1px solid #cccccc;
background: #f1f1f1;
color: #444444;
}
&::placeholder {
color: #999999;
}
&--error {
border-color: #dc3545;
&:focus {
border-color: #dc3545;
}
}
&--invalid {
border-color: #cb0000;
&:focus {
border-color: #cb0000;
}
}
}
.toggleButton {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 2px;
&:hover {
background: transparent;
}
&:focus {
outline: none;
background: transparent;
}
}
.eyeIcon {
transition: opacity 0.2s ease;
}
.error {
display: block;
font-family: 'Open Sans', sans-serif;
font-size: 12px;
color: #dc3545;
margin-top: 4px;
}

View File

@ -0,0 +1,44 @@
import {useState} from 'react';
import PasswordInput from '../PasswordInput/PasswordInput';
import PasswordRequirements from '../PasswordRequirements/PasswordRequirements';
import {usePasswordValidation} from '../../utils/passwordValidation';
/**
* Password input component with requirements display on focus
* Matches DocSpace PasswordInput standard behavior
*/
function PasswordInputWithRequirements({label, value, onChange, placeholder, description, error, ...props}) {
const [isFocused, setIsFocused] = useState(false);
const {isValid} = usePasswordValidation(value);
const handleFocus = () => {
setIsFocused(true);
};
const handleBlur = () => {
setIsFocused(false);
};
return (
<div>
<div style={{position: 'relative'}}>
<PasswordInput
label={label}
type='password'
value={value}
onChange={onChange}
placeholder={placeholder}
description={description}
error={error}
isValid={isValid}
onFocus={handleFocus}
onBlur={handleBlur}
{...props}
/>
<PasswordRequirements password={value} isVisible={isFocused} />
</div>
</div>
);
}
export default PasswordInputWithRequirements;

View File

@ -0,0 +1,85 @@
import SuccessGreenIcon from '../../assets/SuccessGreen.svg';
import FailRedIcon from '../../assets/FailRed.svg';
import styles from './PasswordRequirements.module.scss';
import {useSelector} from 'react-redux';
import {selectSchema, selectPasswordSchema} from '../../store/slices/configSlice';
import {useMemo} from 'react';
import {usePasswordValidation} from '../../utils/passwordValidation';
/**
* Component that displays password requirements with progress bar
* Matches DocSpace PasswordInput standard
* @param {string} password - Password to validate
* @param {boolean} isVisible - Whether to show the requirements (e.g., on focus)
*/
function PasswordRequirements({password, isVisible = false}) {
const fullSchema = useSelector(selectSchema);
const passwordSchema = useSelector(selectPasswordSchema);
const {invalidRules, isValid} = usePasswordValidation(password);
// Use fullSchema (admin panel) or passwordSchema (Setup page)
const schema = fullSchema || passwordSchema;
const passwordValidation = schema?.properties?.adminPanel?.properties?.passwordValidation;
const requirements = useMemo(() => {
if (!passwordValidation?.properties) {
return [];
}
const rules = [
{key: 'minLength', format: 'passlength'},
{key: 'hasDigit', format: 'passdigit'},
{key: 'hasUppercase', format: 'passupper'},
{key: 'hasSpecialChar', format: 'passspecial'}
];
const invalidRulesSet = new Set(invalidRules);
return rules.map(rule => {
const property = passwordValidation.properties[rule.key];
const isValid = !invalidRulesSet.has(rule.key);
return {
text: property?.description,
isValid
};
});
}, [passwordValidation, invalidRules]);
const validRequirements = requirements.filter(req => req.isValid).length;
const totalRequirements = requirements.length;
const progress = totalRequirements > 0 ? (validRequirements / totalRequirements) * 100 : 0;
const shouldShow = isVisible;
// Don't show if schema is not loaded yet
if (!schema || !passwordValidation?.properties) {
return null;
}
if (!shouldShow) {
return null;
}
return (
<div className={styles.requirementsContainer}>
<div className={styles.progressBar}>
<div className={`${styles.progressFill} ${isValid ? styles.progressComplete : ''}`} style={{width: `${progress}%`}} />
</div>
<div className={styles.requirementsTitle}>Password must:</div>
<ul className={styles.requirementsList}>
{requirements.map((requirement, index) => (
<li key={index} className={`${styles.requirementItem} ${requirement.isValid ? styles.valid : styles.invalid}`}>
<span className={styles.bullet}>
{requirement.isValid ? <img src={SuccessGreenIcon} alt='Success' /> : <img src={FailRedIcon} alt='Fail' />}
</span>
<span className={styles.text}>{requirement.text}</span>
</li>
))}
</ul>
</div>
);
}
export default PasswordRequirements;

View File

@ -0,0 +1,90 @@
.requirementsContainer {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
margin-top: 4px;
padding: 12px;
background: #ffffff;
border-radius: 6px;
border: 1px solid #e0e0e0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 0;
max-width: 240px;
text-align: left;
}
.progressBar {
width: 100%;
height: 3px;
background-color: #f0f0f0;
border-radius: 2px;
margin-bottom: 8px;
overflow: hidden;
}
.progressFill {
height: 100%;
background-color: #ff4444;
border-radius: 2px;
transition: all 0.3s ease;
}
.progressComplete {
background-color: #4caf50;
}
.requirementsTitle {
font-size: 10px;
font-weight: 500;
color: #666;
margin-bottom: 6px;
text-align: left;
}
.requirementsList {
margin: 0;
padding: 0;
list-style: none;
text-align: left;
}
.requirementItem {
display: flex;
align-items: center;
margin-bottom: 4px;
font-size: 10px;
line-height: 1.4;
}
.requirementItem:last-child {
margin-bottom: 0;
}
.bullet {
margin-right: 6px;
width: 12px;
height: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.bullet img {
width: 100%;
height: 100%;
}
.text {
flex: 1;
text-align: left;
}
.valid {
color: #4caf50;
}
.invalid {
color: #ff4444;
}

View File

@ -2,38 +2,44 @@ import {useState, useEffect, useCallback} from 'react';
import {useSelector} from 'react-redux';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
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*$/;
import addErrors from 'ajv-errors';
import {selectSchema, selectPasswordSchema, selectSchemaLoading, selectSchemaError} from '../store/slices/configSlice';
/**
* Hook for field validation using backend schema
* Uses passwordSchema (minimal) for Setup page, or schema (full) for admin panel
* @returns {Object} { validateField, getFieldError, isLoading, error }
*/
export const useFieldValidation = () => {
const [validator, setValidator] = useState(null);
const [fieldErrors, setFieldErrors] = useState({});
const [cachedSchema, setCachedSchema] = useState(null);
const schema = useSelector(selectSchema);
const fullSchema = useSelector(selectSchema);
const passwordSchema = useSelector(selectPasswordSchema);
const isLoading = useSelector(selectSchemaLoading);
const error = useSelector(selectSchemaError);
// Prefer fullSchema (admin panel) over passwordSchema (Setup page)
const schema = fullSchema || passwordSchema;
useEffect(() => {
if (schema && !validator) {
// Only recreate validator if schema actually changed
if (schema && schema !== cachedSchema) {
try {
// Build AJV validator with custom and standard formats
const ajv = new Ajv({allErrors: true, strict: false});
addFormats(ajv); // Add standard formats including email
ajv.addFormat('cron6', CRON6_REGEX); // Add custom cron6 format
addErrors(ajv);
const validateFn = ajv.compile(schema);
setValidator(() => validateFn);
setCachedSchema(schema);
} catch (err) {
console.error('Failed to initialize field validator:', err);
}
}
}, [schema, validator]);
}, [schema, cachedSchema]);
/**
* Validates a single field value against the schema

View File

@ -0,0 +1,29 @@
import {useEffect} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {selectSchema, selectSchemaLoading, selectSchemaError, fetchSchema} from '../store/slices/configSlice';
import {selectIsAuthenticated} from '../store/slices/userSlice';
/**
* Hook to load schema for authenticated users
* Fetches schema immediately when the hook is first used
*/
export const useSchemaLoader = () => {
const dispatch = useDispatch();
const schema = useSelector(selectSchema);
const schemaLoading = useSelector(selectSchemaLoading);
const schemaError = useSelector(selectSchemaError);
const isAuthenticated = useSelector(selectIsAuthenticated);
useEffect(() => {
// Load schema only for authenticated users
if (isAuthenticated && !schema && !schemaLoading && !schemaError) {
dispatch(fetchSchema());
}
}, [isAuthenticated, schema, schemaLoading, schemaError, dispatch]);
return {
schema,
schemaLoading,
schemaError
};
};

View File

@ -1,11 +1,13 @@
import {useState, useEffect, useCallback} from 'react';
import {useSelector, useDispatch} from 'react-redux';
import {selectConfig, saveConfig} from '../../../store/slices/configSlice';
import {selectConfig, saveConfig, resetConfig} from '../../../store/slices/configSlice';
import {
registerShowWindowCallback,
registerCloseWindowCallback,
registerSaveCallback,
registerLoadInternalProvidersCallback,
registerResetAiSettingsCallback,
registerResetAiTasksCallback,
initAISettings
} from '../js/plugins-sdk';
@ -59,6 +61,37 @@ const useAiPlugin = statisticsData => {
};
}, [config?.aiSettings]);
const handleResetAiSettings = useCallback(async () => {
const confirmed = window.confirm('Are you sure you want to reset all AI settings? This action cannot be undone.');
if (!confirmed) return;
try {
localStorage.removeItem('onlyoffice_ai_actions_key');
localStorage.removeItem('onlyoffice_ai_plugin_storage_key');
await dispatch(resetConfig(['aiSettings'])).unwrap();
window.location.reload();
} catch (error) {
console.error('Error resetting AI settings:', error);
alert('Failed to reset AI settings. Please try again.');
}
}, [dispatch]);
const handleResetAiTasks = useCallback(async () => {
const confirmed = window.confirm('Are you sure you want to reset AI tasks? This action cannot be undone.');
if (!confirmed) return;
try {
localStorage.removeItem('onlyoffice_ai_actions_key');
await dispatch(resetConfig(['aiSettings.actions'])).unwrap();
window.location.reload();
} catch (error) {
console.error('Error resetting AI tasks:', error);
alert('Failed to reset AI tasks. Please try again.');
}
}, [dispatch]);
// Manage plugin windows and register all SDK callbacks
useEffect(() => {
/**
@ -137,6 +170,8 @@ const useAiPlugin = statisticsData => {
registerCloseWindowCallback(handleCloseWindow);
registerSaveCallback(handleSave);
registerLoadInternalProvidersCallback(handleLoadInternalProviders);
registerResetAiSettingsCallback(handleResetAiSettings);
registerResetAiTasksCallback(handleResetAiTasks);
// Cleanup: unregister all callbacks
return () => {
@ -144,6 +179,8 @@ const useAiPlugin = statisticsData => {
registerCloseWindowCallback(null);
registerSaveCallback(null);
registerLoadInternalProvidersCallback(null);
registerResetAiSettingsCallback(null);
registerResetAiTasksCallback(null);
};
}, [config, dispatch]);
@ -153,7 +190,9 @@ const useAiPlugin = statisticsData => {
pluginWindows,
currentWindow,
handleIframeLoad,
internalProvidersLoaded
internalProvidersLoaded,
handleResetAiSettings,
handleResetAiTasks
};
};

View File

@ -10,6 +10,8 @@ let showPluginWindowCallback = null;
let closePluginWindowCallback = null;
let saveCallback = null;
let loadInternalProvidersCallback = null;
let resetAiSettingsCallback = null;
let resetAiTasksCallback = null;
let settingsButton = null;
@ -473,18 +475,26 @@ function ShowWindow(val) {
const [iframeId, config] = val;
const isMain = config.url.includes(mainButtonId);
if (isMain) {
config.buttons = config.buttons.map((button, index) => ({
text: 'Save Changes',
function getButton(text, resetAction = false, resetAll = false) {
return {
text,
onClick: () => {
onButtonClick(index, iframeId);
if (saveCallback) {
saveCallback();
onButtonClick(0, iframeId);
if (resetAction && resetAiTasksCallback) {
resetAiTasksCallback();
} else if (resetAll && resetAiSettingsCallback) {
resetAiSettingsCallback();
} else if (saveCallback) {
saveCallback(resetAction, resetAll);
}
AddToolbarMenuItem(settingsButton);
},
disabled: false
}));
};
}
if (isMain) {
config.buttons = [getButton('Save Changes', false, false), getButton('Reset Tasks', true, false), getButton('Reset All Settings', false, true)];
} else {
config.buttons = config.buttons.map((button, index) => ({
text: button.text,
@ -557,4 +567,28 @@ function registerLoadInternalProvidersCallback(callback) {
loadInternalProvidersCallback = callback;
}
export {initAISettings, registerShowWindowCallback, registerCloseWindowCallback, registerSaveCallback, registerLoadInternalProvidersCallback};
/**
* Registers callback for resetting AI settings
* @param {function} callback - Function to call when AI settings should be reset () => void
*/
function registerResetAiSettingsCallback(callback) {
resetAiSettingsCallback = callback;
}
/**
* Registers callback for resetting AI tasks
* @param {function} callback - Function to call when AI tasks should be reset () => void
*/
function registerResetAiTasksCallback(callback) {
resetAiTasksCallback = callback;
}
export {
initAISettings,
registerShowWindowCallback,
registerCloseWindowCallback,
registerSaveCallback,
registerLoadInternalProvidersCallback,
registerResetAiSettingsCallback,
registerResetAiTasksCallback
};

View File

@ -2,8 +2,10 @@ import {useState} from 'react';
import {changePassword} from '../../api';
import PageHeader from '../../components/PageHeader/PageHeader';
import PageDescription from '../../components/PageDescription/PageDescription';
import Input from '../../components/Input/Input';
import PasswordInput from '../../components/PasswordInput/PasswordInput';
import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
import PasswordInputWithRequirements from '../../components/PasswordInputWithRequirements/PasswordInputWithRequirements';
import {usePasswordValidation} from '../../utils/passwordValidation';
import styles from './ChangePassword.module.scss';
function ChangePassword() {
@ -13,31 +15,29 @@ function ChangePassword() {
const [passwordError, setPasswordError] = useState('');
const [passwordSuccess, setPasswordSuccess] = useState(false);
const handlePasswordChange = async () => {
setPasswordError('');
setPasswordSuccess(false);
const {isValid: newPasswordIsValid, isLoading} = usePasswordValidation(newPassword);
// Validation
if (!currentPassword) {
setPasswordError('Current password is required');
throw new Error('Validation failed');
// Check if form can be submitted
const canSubmit = () => {
if (!currentPassword || !newPassword || !confirmPassword || isLoading) {
return false;
}
if (!newPassword) {
setPasswordError('New password is required');
throw new Error('Validation failed');
}
if (newPassword.length > 128) {
setPasswordError('Password must not exceed 128 characters');
throw new Error('Validation failed');
if (!newPasswordIsValid) {
return false;
}
if (newPassword !== confirmPassword) {
setPasswordError('New passwords do not match');
throw new Error('Validation failed');
return false;
}
return true;
};
const handlePasswordChange = async () => {
setPasswordError('');
setPasswordSuccess(false);
try {
await changePassword({currentPassword, newPassword});
setPasswordSuccess(true);
@ -62,34 +62,41 @@ function ChangePassword() {
{passwordError && <div className={styles.errorMessage}>{passwordError}</div>}
<div className={styles.form}>
<Input
<PasswordInput
label='Current Password'
type='password'
value={currentPassword}
onChange={setCurrentPassword}
placeholder='Enter current password'
description='Your current admin password'
isValid={true}
/>
<Input
<PasswordInputWithRequirements
label='New Password'
type='password'
value={newPassword}
onChange={setNewPassword}
placeholder='Enter new password'
description='Any non-empty password, maximum 128 characters'
description='Create a strong password'
/>
<Input
label='Confirm New Password'
type='password'
value={confirmPassword}
onChange={setConfirmPassword}
placeholder='Confirm new password'
description='Re-enter your new password'
/>
<div className={styles.confirmPasswordGroup}>
<PasswordInput
label='Confirm New Password'
type='password'
value={confirmPassword}
onChange={setConfirmPassword}
placeholder='Confirm new password'
description='Re-enter your new password'
isValid={true}
/>
<div className={styles.passwordMismatch}>
{newPassword && confirmPassword && newPassword !== confirmPassword && newPasswordIsValid && "Passwords don't match"}
</div>
</div>
<FixedSaveButton onClick={handlePasswordChange} />
<FixedSaveButton onClick={handlePasswordChange} disabled={!canSubmit()} />
</div>
</div>
</div>

View File

@ -117,3 +117,18 @@
.logoutButton:active {
background: #bd2130;
}
.passwordMismatch {
color: #dc3545;
font-size: 12px;
margin-top: 4px;
text-align: left;
min-height: 16px;
line-height: 16px;
}
.confirmPasswordGroup {
display: flex;
flex-direction: column;
gap: 0;
}

View File

@ -2,8 +2,8 @@ import {useState, useRef} from 'react';
import {useDispatch} from 'react-redux';
import {fetchUser} from '../../store/slices/userSlice';
import {login} from '../../api';
import Input from '../../components/LoginInput';
import Button from '../../components/LoginButton';
import PasswordInput from '../../components/PasswordInput/PasswordInput';
import Button from '../../components/Button/Button';
import styles from './styles.module.css';
export default function Login() {
@ -43,14 +43,15 @@ export default function Login() {
<div className={styles.form}>
<div className={styles.inputGroup}>
<Input
<PasswordInput
type='password'
value={password}
onChange={setPassword}
placeholder='Enter your password'
description='Admin panel password'
error={error}
onKeyDown={handleKeyDown}
width='200px'
isValid={true}
/>
</div>

View File

@ -1,5 +1,5 @@
import {resetConfiguration} from '../../api';
import SaveButton from '../../components/SaveButton/SaveButton';
import Button from '../../components/Button/Button';
import './Settings.scss';
const Settings = () => {
@ -25,7 +25,7 @@ const Settings = () => {
<p>This will reset all configuration settings to their default values. This action cannot be undone.</p>
</div>
<div className='settings-actions'>
<SaveButton onClick={handleResetConfig}>Reset</SaveButton>
<Button onClick={handleResetConfig}>Reset</Button>
</div>
</div>
</div>

View File

@ -2,8 +2,11 @@ import {useState, useRef} from 'react';
import {useDispatch} from 'react-redux';
import {setupAdminPassword} from '../../api';
import {fetchUser} from '../../store/slices/userSlice';
import Input from '../../components/LoginInput';
import Button from '../../components/LoginButton';
import Input from '../../components/Input/Input';
import Button from '../../components/Button/Button';
import PasswordInput from '../../components/PasswordInput/PasswordInput';
import PasswordInputWithRequirements from '../../components/PasswordInputWithRequirements/PasswordInputWithRequirements';
import {usePasswordValidation} from '../../utils/passwordValidation';
import styles from './styles.module.css';
export default function Setup() {
@ -14,34 +17,28 @@ export default function Setup() {
const dispatch = useDispatch();
const buttonRef = useRef();
const {isValid: passwordIsValid, isLoading} = usePasswordValidation(password);
// Check if form can be submitted
const canSubmit = () => {
if (!bootstrapToken.trim() || !password || !confirmPassword || isLoading) {
return false;
}
// Check if password meets all requirements using hook
if (!passwordIsValid) {
return false;
}
if (password !== confirmPassword) {
return false;
}
return true;
};
const handleSubmit = async () => {
setErrors({});
// Validate all fields
const newErrors = {};
if (!bootstrapToken.trim()) {
newErrors.bootstrapToken = 'Bootstrap token is required';
}
if (!password) {
newErrors.password = 'Password is required';
} else if (password.length > 128) {
newErrors.password = 'Password must not exceed 128 characters';
}
if (!confirmPassword) {
newErrors.confirmPassword = 'Please confirm your password';
} else if (password !== confirmPassword) {
newErrors.confirmPassword = 'Passwords do not match';
}
// If there are validation errors, show them
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
throw new Error('Validation failed');
}
try {
await setupAdminPassword({bootstrapToken, password});
// Wait for cookie to be set and verify authentication works
@ -80,37 +77,38 @@ export default function Setup() {
value={bootstrapToken}
onChange={setBootstrapToken}
placeholder='Enter bootstrap token'
description='Get token from server startup logs'
error={errors.bootstrapToken}
onKeyDown={handleKeyDown}
/>
</div>
<div className={styles.inputGroup}>
<Input
<PasswordInputWithRequirements
type='password'
value={password}
onChange={setPassword}
placeholder='Enter your password'
description='Any non-empty password, maximum 128 characters'
error={errors.password}
onKeyDown={handleKeyDown}
/>
</div>
<div className={styles.inputGroup}>
<Input
<PasswordInput
type='password'
value={confirmPassword}
onChange={setConfirmPassword}
placeholder='Confirm your password'
description='Re-enter your password'
error={errors.confirmPassword}
onKeyDown={handleKeyDown}
isValid={true}
/>
<div className={styles.passwordMismatch}>
{password && confirmPassword && password !== confirmPassword && passwordIsValid && "Passwords don't match"}
</div>
</div>
<Button ref={buttonRef} onClick={handleSubmit} errorText='FAILED'>
<Button ref={buttonRef} onClick={handleSubmit} errorText='FAILED' disabled={!canSubmit()}>
SETUP
</Button>
</div>

View File

@ -66,3 +66,12 @@
max-width: 400px;
width: 100%;
}
.passwordMismatch {
color: #dc3545;
font-size: 12px;
margin-top: 4px;
text-align: left;
min-height: 16px;
line-height: 16px;
}

View File

@ -1,5 +1,5 @@
import {createSlice, createAsyncThunk} from '@reduxjs/toolkit';
import {fetchConfiguration, fetchConfigurationSchema, updateConfiguration, rotateWopiKeys} from '../../api';
import {fetchConfiguration, fetchConfigurationSchema, updateConfiguration, rotateWopiKeys, resetConfiguration} from '../../api';
export const fetchConfig = createAsyncThunk('config/fetchConfig', async (_, {rejectWithValue}) => {
try {
@ -37,9 +37,19 @@ export const rotateWopiKeysAction = createAsyncThunk('config/rotateWopiKeys', as
}
});
export const resetConfig = createAsyncThunk('config/resetConfig', async (paths, {rejectWithValue}) => {
try {
const newConfig = await resetConfiguration(paths);
return newConfig;
} catch (error) {
return rejectWithValue(error.message);
}
});
const initialState = {
config: null,
schema: null,
schema: null, // Full schema for admin panel
passwordSchema: null, // Minimal schema for Setup page password validation
loading: false,
schemaLoading: false,
saving: false,
@ -64,6 +74,9 @@ const configSlice = createSlice({
},
clearError: state => {
state.error = null;
},
setPasswordSchema: (state, action) => {
state.passwordSchema = action.payload;
}
},
extraReducers: builder => {
@ -123,15 +136,28 @@ const configSlice = createSlice({
.addCase(rotateWopiKeysAction.rejected, (state, action) => {
state.saving = false;
state.error = action.payload;
})
.addCase(resetConfig.pending, state => {
state.saving = true;
state.error = null;
})
.addCase(resetConfig.fulfilled, state => {
state.saving = false;
state.error = null;
})
.addCase(resetConfig.rejected, (state, action) => {
state.saving = false;
state.error = action.payload;
});
}
});
export const {updateLocalConfig, clearConfig, clearError} = configSlice.actions;
export const {updateLocalConfig, clearConfig, clearError, setPasswordSchema} = configSlice.actions;
// Selectors
export const selectConfig = state => state.config.config;
export const selectSchema = state => state.config.schema;
export const selectPasswordSchema = state => state.config.passwordSchema;
export const selectConfigLoading = state => state.config.loading;
export const selectSchemaLoading = state => state.config.schemaLoading;
export const selectConfigSaving = state => state.config.saving;

View File

@ -0,0 +1,31 @@
import {useFieldValidation} from '../hooks/useFieldValidation';
import {useMemo} from 'react';
/**
* Hook for password validation
* @param {string} password - Password to validate
* @returns {Object} { isValid, errorMessage, invalidRules, isLoading, error }
*/
export function usePasswordValidation(password) {
const {validateField, isLoading} = useFieldValidation();
const validationResult = useMemo(() => {
const rules = ['minLength', 'hasDigit', 'hasUppercase', 'hasSpecialChar'];
const invalidRules = rules.filter(rule => {
const fieldPath = `adminPanel.passwordValidation.${rule}`;
const error = validateField(fieldPath, password || '');
return !!error;
});
return {
isValid: invalidRules.length === 0,
invalidRules
};
}, [validateField, password]);
return {
...validationResult,
isLoading
};
}

View File

@ -7,13 +7,10 @@ const dotenv = require('dotenv');
module.exports = (env, argv) => {
const mode = argv && argv.mode ? argv.mode : 'development';
// Load environment variables from .env files
// Priority: .env.local > .env.development/.env.production > .env
const envFiles = ['.env.local', mode === 'production' ? '.env.production' : '.env.development', '.env'];
envFiles.forEach(file => {
dotenv.config({path: file});
});
// Load environment variables from .env.development only in development mode
if (mode === 'development') {
dotenv.config({path: '.env.development'});
}
return {
entry: './src/index.js',

View File

@ -55,6 +55,11 @@
"require-from-string": "^2.0.2"
}
},
"ajv-errors": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz",
"integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ=="
},
"ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",

View File

@ -1,5 +1,5 @@
{
"name": "adminpanel",
"name": "onlyoffice-adminpanel-server",
"version": "1.0.0",
"homepage": "https://www.onlyoffice.com",
"private": true,
@ -19,6 +19,7 @@
"express": "^4.19.2",
"ajv": "^8.17.1",
"ajv-formats": "^2.1.1",
"ajv-errors": "^3.0.0",
"joi": "^17.13.3",
"jsonwebtoken": "^9.0.2",
"ms": "^2.1.3"

View File

@ -8,12 +8,45 @@ const bootstrap = require('../../bootstrap');
const adminPanelJwtSecret = require('../../jwtSecret');
const tenantManager = require('../../../../../Common/sources/tenantManager');
const commonDefines = require('../../../../../Common/sources/commondefines');
const {validateScoped} = require('../config/config.service');
const supersetSchema = require('../../../../../Common/config/schemas/config.schema.json');
const router = express.Router();
router.use(express.json());
router.use(cookieParser());
/**
* Validates password against all requirements using existing config service
* @param {operationContext} ctx - Operation context
* @param {string} password - Password to validate
* @returns {Object} Validation result with isValid boolean and error messages
*/
function validatePassword(ctx, password) {
const testData = {
adminPanel: {
passwordValidation: {
minLength: password,
hasDigit: password,
hasUppercase: password,
hasSpecialChar: password
}
}
};
const result = validateScoped(ctx, testData);
if (result.value) {
return {
isValid: true
};
}
return {
isValid: false
};
}
/**
* Create session cookie with standard options
* @param {import('express').Response} res - Express response
@ -51,7 +84,8 @@ function requireAuth(req, res, next) {
}
/**
* Check if AdminPanel setup is required
* Check if initial setup is required and get password validation schema
* Returns setup status and minimal schema for password validation
*/
router.get('/setup/required', async (req, res) => {
const ctx = new operationContext.Context();
@ -68,7 +102,22 @@ router.get('/setup/required', async (req, res) => {
}
}
res.json({setupRequired});
// Include minimal password validation schema for setup page
const passwordValidationSchema = {
$defs: supersetSchema.$defs,
properties: {
adminPanel: {
properties: {
passwordValidation: supersetSchema.properties.adminPanel.properties.passwordValidation
}
}
}
};
res.json({
setupRequired,
passwordValidationSchema
});
} catch (error) {
ctx.logger.error('Setup check error: %s', error.stack);
res.status(500).json({error: 'Internal server error'});
@ -106,12 +155,11 @@ router.post('/setup', async (req, res) => {
return res.status(400).json({error: 'Password is required'});
}
if (password.length < passwordManager.PASSWORD_MIN_LENGTH) {
return res.status(400).json({error: `Password must be at least ${passwordManager.PASSWORD_MIN_LENGTH} characters long`});
}
if (password.length > passwordManager.PASSWORD_MAX_LENGTH) {
return res.status(400).json({error: `Password must not exceed ${passwordManager.PASSWORD_MAX_LENGTH} characters`});
const passwordValidationResult = validatePassword(ctx, password);
if (!passwordValidationResult.isValid) {
return res
.status(400)
.json({error: 'Password must be at least 8 characters long, contain at least one digit, one uppercase letter and one special character'});
}
await passwordManager.saveAdminPassword(ctx, password);
@ -143,12 +191,11 @@ router.post('/change-password', requireAuth, async (req, res) => {
return res.status(400).json({error: 'Current password and new password are required'});
}
if (newPassword.length < passwordManager.PASSWORD_MIN_LENGTH) {
return res.status(400).json({error: `Password must be at least ${passwordManager.PASSWORD_MIN_LENGTH} characters long`});
}
if (newPassword.length > passwordManager.PASSWORD_MAX_LENGTH) {
return res.status(400).json({error: `Password must not exceed ${passwordManager.PASSWORD_MAX_LENGTH} characters`});
const passwordValidationResult = validatePassword(ctx, newPassword);
if (!passwordValidationResult.isValid) {
return res
.status(400)
.json({error: 'Password must be at least 8 characters long, contain at least one digit, one uppercase letter and one special character'});
}
if (currentPassword === newPassword) {

View File

@ -33,13 +33,13 @@
'use strict';
const Ajv = require('ajv');
const addFormats = require('ajv-formats');
const addErrors = require('ajv-errors');
const logger = require('../../../../../Common/sources/logger');
const tenantManager = require('../../../../../Common/sources/tenantManager');
const supersetSchema = require('../../../../../Common/config/schemas/config.schema.json');
const {deriveSchemaForScope, X_SCOPE_KEYWORD} = require('./config.schema.utils');
// Constants
const CRON6_REGEX = /^\s*\S+(?:\s+\S+){5}\s*$/;
const AJV_CONFIG = {allErrors: true, strict: false};
const AJV_FILTER_CONFIG = {allErrors: true, strict: false, removeAdditional: true};
@ -49,7 +49,6 @@ const AJV_FILTER_CONFIG = {allErrors: true, strict: false, removeAdditional: tru
*/
function registerAjvExtras(instance) {
instance.addKeyword({keyword: X_SCOPE_KEYWORD, schemaType: ['string', 'array'], errors: false});
instance.addFormat('cron6', CRON6_REGEX);
}
/**
@ -60,6 +59,7 @@ function registerAjvExtras(instance) {
function createAjvInstance(config) {
const instance = new Ajv(config);
addFormats(instance);
addErrors(instance);
registerAjvExtras(instance);
return instance;
}
@ -114,13 +114,4 @@ function getScopedConfig(ctx) {
return configCopy;
}
/**
* Returns the derived per-scope schema for ctx (admin or tenant).
* @param {operationContext} ctx
* @returns {object}
*/
function getScopedSchema(ctx) {
return isAdminScope(ctx) ? adminSchema : tenantSchema;
}
module.exports = {validateScoped, getScopedConfig, getScopedSchema};
module.exports = {validateScoped, getScopedConfig};

View File

@ -4,10 +4,11 @@ const express = require('express');
const bodyParser = require('body-parser');
const tenantManager = require('../../../../../Common/sources/tenantManager');
const runtimeConfigManager = require('../../../../../Common/sources/runtimeConfigManager');
const {getScopedConfig, validateScoped, getScopedSchema} = require('./config.service');
const {getScopedConfig, validateScoped} = require('./config.service');
const {validateJWT} = require('../../middleware/auth');
const cookieParser = require('cookie-parser');
const utils = require('../../../../../Common/sources/utils');
const supersetSchema = require('../../../../../Common/config/schemas/config.schema.json');
const router = express.Router();
router.use(cookieParser());
@ -35,18 +36,8 @@ router.get('/', validateJWT, async (req, res) => {
}
});
router.get('/schema', validateJWT, async (req, res) => {
const ctx = req.ctx;
try {
ctx.logger.info('config schema start');
const schema = getScopedSchema(ctx);
res.json(schema);
} catch (error) {
ctx.logger.error('Config schema error: %s', error.stack);
res.status(500).json({error: 'Internal server error'});
} finally {
ctx.logger.info('config schema end');
}
router.get('/schema', validateJWT, async (_req, res) => {
res.json(supersetSchema);
});
router.patch('/', validateJWT, rawFileParser, async (req, res) => {
@ -79,7 +70,7 @@ router.patch('/', validateJWT, rawFileParser, async (req, res) => {
}
});
router.post('/reset', validateJWT, async (req, res) => {
router.post('/reset', validateJWT, rawFileParser, async (req, res) => {
const ctx = req.ctx;
try {
ctx.logger.info('config reset start');
@ -87,11 +78,44 @@ router.post('/reset', validateJWT, async (req, res) => {
const currentConfig = await runtimeConfigManager.getConfig(ctx);
const passwordHash = currentConfig?.adminPanel?.passwordHash;
const resetConfig = {};
if (passwordHash) {
resetConfig.adminPanel = {
passwordHash
};
const {paths} = JSON.parse(req.body);
let resetConfig = {};
if (paths.includes('*')) {
if (passwordHash) {
resetConfig.adminPanel = {
passwordHash
};
}
} else {
resetConfig = JSON.parse(JSON.stringify(currentConfig));
paths.forEach(path => {
if (path && path !== '*') {
const pathParts = path.split('.');
let current = resetConfig;
for (let i = 0; i < pathParts.length - 1; i++) {
if (current && typeof current === 'object') {
current = current[pathParts[i]];
} else {
current = undefined;
break;
}
}
if (current && typeof current === 'object') {
delete current[pathParts[pathParts.length - 1]];
}
}
});
if (passwordHash) {
resetConfig.adminPanel = {
...resetConfig.adminPanel,
passwordHash
};
}
}
if (tenantManager.isMultitenantMode(ctx) && !tenantManager.isDefaultTenant(ctx)) {

View File

@ -5,6 +5,29 @@
"description": "Superset schema with x-scope markers. Use at runtime to derive per-scope schemas.",
"type": "object",
"additionalProperties": false,
"$defs": {
"cron6": {
"type": "string",
"pattern": "^\\s*\\S+(?:\\s+\\S+){5}\\s*$",
"errorMessage": "Cron expression must have exactly 6 parts"
},
"passlength": {
"type": "string",
"pattern": "^.{8,128}$"
},
"passdigit": {
"type": "string",
"pattern": ".*\\d.*"
},
"passupper": {
"type": "string",
"pattern": ".*[A-Z].*"
},
"passspecial": {
"type": "string",
"pattern": ".*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?].*"
}
},
"properties": {
"aiSettings": {
"type": "object",
@ -79,8 +102,8 @@
"additionalProperties": false,
"x-scope": ["admin", "tenant"],
"properties": {
"filesCron": {"type": "string", "format": "cron6", "x-scope": "admin"},
"documentsCron": {"type": "string", "format": "cron6", "x-scope": "admin"},
"filesCron": {"$ref": "#/$defs/cron6", "x-scope": "admin"},
"documentsCron": {"$ref": "#/$defs/cron6", "x-scope": "admin"},
"files": {"type": "integer", "minimum": 0, "x-scope": "admin"},
"filesremovedatonce": {"type": "integer", "minimum": 0, "x-scope": "admin"},
"sessionidle": {"type": "string", "x-scope": ["admin", "tenant"]},
@ -394,6 +417,40 @@
}
}
}
},
"adminPanel": {
"type": "object",
"additionalProperties": false,
"x-scope": ["admin", "tenant"],
"properties": {
"passwordValidation": {
"type": "object",
"x-scope": ["admin", "tenant"],
"description": "Password validation requirements using custom format types",
"properties": {
"minLength": {
"$ref": "#/$defs/passlength",
"description": "be at least 8 characters long",
"x-scope": ["admin", "tenant"]
},
"hasDigit": {
"$ref": "#/$defs/passdigit",
"description": "contain at least one digit",
"x-scope": ["admin", "tenant"]
},
"hasUppercase": {
"$ref": "#/$defs/passupper",
"description": "contain at least one uppercase letter",
"x-scope": ["admin", "tenant"]
},
"hasSpecialChar": {
"$ref": "#/$defs/passspecial",
"description": "contain at least one special character",
"x-scope": ["admin", "tenant"]
}
}
}
}
}
}
}