mirror of
https://github.com/ONLYOFFICE/server.git
synced 2026-02-10 18:05:07 +08:00
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:
@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
'*.js': ['eslint', 'prettier'],
|
||||
'*.js': ['eslint', 'prettier --check'],
|
||||
'*.{json,md,html,css,yml,yaml}': []
|
||||
};
|
||||
|
||||
7
AdminPanel/client/package-lock.json
generated
7
AdminPanel/client/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
|
||||
@ -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 |
3
AdminPanel/client/src/assets/SuccessGreen.svg
Normal file
3
AdminPanel/client/src/assets/SuccessGreen.svg
Normal 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 |
@ -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) {
|
||||
|
||||
@ -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);
|
||||
@ -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;
|
||||
}
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
29
AdminPanel/client/src/hooks/useSchemaLoader.js
Normal file
29
AdminPanel/client/src/hooks/useSchemaLoader.js
Normal 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
|
||||
};
|
||||
};
|
||||
@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
31
AdminPanel/client/src/utils/passwordValidation.js
Normal file
31
AdminPanel/client/src/utils/passwordValidation.js
Normal 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
|
||||
};
|
||||
}
|
||||
@ -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',
|
||||
|
||||
5
AdminPanel/server/package-lock.json
generated
5
AdminPanel/server/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user