[feature] admin-panel react

# Conflicts:
#	DocService/npm-shrinkwrap.json
#	DocService/package.json
#	DocService/sources/routes/config/config.service.js
#	DocService/sources/routes/config/router.js
#	DocService/sources/server.js
This commit is contained in:
PauI Ostrovckij
2025-07-29 11:25:50 +03:00
committed by Sergey Konovalov
parent 8ee59496c6
commit c1584abfa1
52 changed files with 10646 additions and 11 deletions

3
.gitignore vendored
View File

@ -10,4 +10,5 @@ node_modules
/Gruntfile.js.out
local-development-*.json
*.pyc
run-develop-local.py
run-develop-local.py
runtime.json

1
AdminPanel/.env Normal file
View File

@ -0,0 +1 @@
REACT_APP_BACKEND_URL=http://localhost:8080

1
AdminPanel/.env.example Normal file
View File

@ -0,0 +1 @@
REACT_APP_BACKEND_URL=http://localhost:8080

23
AdminPanel/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

0
AdminPanel/config.json Normal file
View File

8690
AdminPanel/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
AdminPanel/package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "docscloud",
"version": "1.3.0",
"private": true,
"scripts": {
"start": "webpack serve --mode=development",
"build": "webpack --mode=production"
},
"dependencies": {
"@reduxjs/toolkit": "^2.8.2",
"@tanstack/react-query": "^5.83.0",
"axios": "1.7.4",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.0.5",
"react-router-dom": "^6.8.1",
"redux": "^4.2.1",
"redux-thunk": "^2.4.2",
"styled-components": "^5.3.11"
},
"devDependencies": {
"@babel/cli": "7.17.0",
"@babel/core": "7.17.0",
"@babel/preset-env": "^7.15.6",
"@babel/preset-react": "7.16.0",
"babel-loader": "8.2.0",
"copy-webpack-plugin": "11.0.0",
"css-loader": "^6.2.0",
"file-loader": "^6.2.0",
"html-webpack-plugin": "5.5.0",
"style-loader": "3.2.1",
"webpack": "5.70.0",
"webpack-cli": "4.10.0",
"webpack-dev-server": "4.11.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/images/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Document Server Admin Panel"
/>
<title>ONLIOFFICE Admin</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

39
AdminPanel/src/App.css Normal file
View File

@ -0,0 +1,39 @@
.App {
text-align: center;
width: 300px;
}
body {
font-family: 'Open Sans',sans-serif,Arial;
font-size: 1rem;
font-weight: 400;
margin: 0px !important;
overflow-y: scroll;
scrollbar-gutter: stable;
color: rgb(51, 51, 51);
}
.App, .content, h1, h2, h3, h4, h5, h6, p, div, span, td, th, label, a, li, ul, ol {
color: rgb(51, 51, 51);
}
body::-webkit-scrollbar {
width: 6px;
background: #FFFFFF;
}
body::-webkit-scrollbar-thumb {
background: #EFEFEF;
}
.content {
padding-right: 20%;
padding-left: 20%;
}
@media (max-width: 1200px) {
.content {
padding-right: 5%;
padding-left: 5%;
}
}

27
AdminPanel/src/App.js Normal file
View File

@ -0,0 +1,27 @@
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import { Provider } from 'react-redux';
import './App.css';
import { store } from './store';
import AuthWrapper from './components/AuthWrapper';
import Header from './components/Header';
import Home from './pages/Home';
function App() {
return (
<Provider store={store}>
<div>
<Header />
<AuthWrapper>
<div className="content">
<Routes>
<Route path="/" element={<Home />} />
</Routes>
</div>
</AuthWrapper>
</div>
</Provider>
);
}
export default App;

101
AdminPanel/src/api/index.js Normal file
View File

@ -0,0 +1,101 @@
const BACKEND_URL = process.env.REACT_APP_BACKEND_URL || 'http://localhost:8000';
export const fetchStatistics = async () => {
const response = await fetch(`${BACKEND_URL}/info/info.json`);
if (!response.ok) {
throw new Error('Failed to fetch statistics');
}
return response.json();
};
export const fetchConfiguration = async () => {
const response = await fetch(`${BACKEND_URL}/info/config`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error('Failed to fetch configuration');
}
return response.json();
};
export const updateConfiguration = async (configData) => {
const response = await fetch(`${BACKEND_URL}/info/config`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(configData),
});
if (!response.ok) {
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const errorData = await response.json();
throw errorData;
} else {
const errorText = await response.text();
throw new Error(errorText);
}
}
// Try to parse as JSON, fallback to text if it's not JSON
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return response.json();
} else {
return response.text();
}
};
export const fetchCurrentUser = async () => {
const response = await fetch(`${BACKEND_URL}/info/adminpanel/me`, {
method: 'GET',
credentials: 'include', // Include cookies in the request
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Unauthorized');
}
throw new Error('Failed to fetch current user');
}
return response.json();
};
export const login = async (secret) => {
const response = await fetch(`${BACKEND_URL}/info/adminpanel/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ secret }),
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Invalid credentials');
}
throw new Error('Login failed');
}
return response.json();
};
export const logout = async () => {
const response = await fetch(`${BACKEND_URL}/info/adminpanel/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (!response.ok) {
throw new Error('Logout failed');
}
return response.json();
};

View File

@ -0,0 +1,15 @@
<svg width="144" height="23" viewBox="0 0 144 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.9387 22.7181L0.656893 17.9809C-0.218964 17.5673 -0.218964 16.9282 0.656893 16.5522L4.23648 14.8979L10.9006 17.9809C11.7765 18.3945 13.1855 18.3945 14.0232 17.9809L20.6874 14.8979L24.267 16.5522C25.1428 16.9658 25.1428 17.6049 24.267 17.9809L13.9852 22.7181C13.1855 23.0941 11.7765 23.0941 10.9387 22.7181Z" fill="#FF6F3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.9387 16.8905L0.656893 12.1533C-0.218964 11.7397 -0.218964 11.1005 0.656893 10.7246L4.16032 9.10791L10.9387 12.2285C11.8145 12.642 13.2235 12.642 14.0613 12.2285L20.8397 9.10791L24.3431 10.7246C25.219 11.1381 25.219 11.7773 24.3431 12.1533L14.0613 16.8905C13.1855 17.304 11.7765 17.304 10.9387 16.8905Z" fill="#95C038"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.9387 11.2133L0.656893 6.47608C-0.218964 6.06251 -0.218964 5.42336 0.656893 5.04739L10.9387 0.310175C11.8145 -0.103392 13.2235 -0.103392 14.0613 0.310175L24.3431 5.04739C25.219 5.46096 25.219 6.10011 24.3431 6.47608L14.0613 11.2133C13.1855 11.5893 11.7765 11.5893 10.9387 11.2133Z" fill="#5DC0E8"/>
<path d="M33 11.4781C33 9.15204 33.6769 7.39655 35.076 6.25549C36.4298 5.07053 38.0545 4.5 39.9048 4.5C41.7551 4.5 43.3346 5.07053 44.6885 6.25549C46.0424 7.44044 46.7194 9.15204 46.7194 11.5219C46.7194 13.848 46.0424 15.6034 44.6885 16.7445C43.3346 17.9295 41.71 18.5 39.9048 18.5C38.0545 18.5 36.475 17.9295 35.076 16.7445C33.6769 15.5596 33 13.8041 33 11.4781ZM35.9785 11.4781C35.9785 13.1019 36.2944 14.2429 36.8811 14.989C37.5129 15.7351 38.1899 16.2179 38.912 16.3934C39.0925 16.4373 39.2279 16.4812 39.4084 16.4812C39.5438 16.4812 39.7243 16.5251 39.8597 16.5251C40.0402 16.5251 40.1756 16.5251 40.3561 16.4812C40.5366 16.4812 40.672 16.4373 40.8525 16.3934C41.5746 16.2179 42.2515 15.7351 42.8382 14.989C43.4249 14.2429 43.7408 13.058 43.7408 11.5219C43.7408 9.94201 43.4249 8.80094 42.8382 8.05486C42.2515 7.30878 41.5746 6.82602 40.8525 6.65047C40.672 6.60658 40.4915 6.5627 40.3561 6.5627C40.1756 6.5627 40.0402 6.51881 39.8597 6.51881C39.6792 6.51881 39.5438 6.51881 39.4084 6.5627C39.273 6.5627 39.0925 6.60658 38.912 6.65047C38.1899 6.82602 37.5129 7.30878 36.8811 8.05486C36.2944 8.71317 35.9785 9.89812 35.9785 11.4781Z" fill="white"/>
<path d="M48.2087 4.63184H51.9094L56.7833 13.2337L57.5054 15.1209H57.5505L57.5054 12.6632V4.63184H60.3486V18.3246H56.648L51.774 9.37165L51.0519 7.8356H51.0068L51.0519 10.2494V18.3246H48.2087V4.63184Z" fill="white"/>
<path d="M63.282 4.63184H66.1251V15.9986H71.7212V18.3246H63.282V4.63184Z" fill="white"/>
<path d="M69.9612 4.63184H73.2556L76.1439 9.41554L76.5952 10.3811H76.6855L77.1368 9.41554L80.0702 4.63184H83.0939L77.9942 12.751V18.3246H75.1511V12.7071L69.9612 4.63184Z" fill="white"/>
<path d="M82.7327 11.4781C82.7327 9.15204 83.4096 7.39655 84.8086 6.25548C86.1625 5.07053 87.7872 4.5 89.6375 4.5C91.4878 4.5 93.0673 5.07053 94.4212 6.25548C95.7751 7.44044 96.452 9.15204 96.452 11.5219C96.452 13.848 95.7751 15.6034 94.4212 16.7445C93.0673 17.9295 91.4427 18.5 89.6375 18.5C87.7872 18.5 86.2076 17.9295 84.8086 16.7445C83.4547 15.5596 82.7327 13.8041 82.7327 11.4781ZM85.7112 11.4781C85.7112 13.1019 86.0271 14.2429 86.6138 14.989C87.2456 15.7351 87.8774 16.2179 88.6446 16.3934C88.8251 16.4373 88.9605 16.4812 89.1411 16.4812C89.2764 16.4812 89.457 16.5251 89.5924 16.5251C89.7729 16.5251 89.9083 16.5251 90.0888 16.4812C90.2693 16.4812 90.4047 16.4373 90.5852 16.3934C91.3073 16.2179 91.9842 15.7351 92.5709 14.989C93.1576 14.2429 93.4735 13.058 93.4735 11.5219C93.4735 9.942 93.1576 8.80094 92.5709 8.05486C91.9842 7.30878 91.3073 6.82602 90.5852 6.65047C90.4047 6.60658 90.2242 6.5627 90.0888 6.5627C89.9083 6.5627 89.7729 6.51881 89.5924 6.51881C89.4118 6.51881 89.2764 6.51881 89.1411 6.5627C89.0057 6.5627 88.8251 6.60658 88.6446 6.65047C87.9226 6.82602 87.2456 7.30878 86.6138 8.05486C86.0271 8.71316 85.7112 9.89812 85.7112 11.4781Z" fill="white"/>
<path d="M98.4375 4.63184H106.29V6.91397H101.281V10.2494H106.064V12.5754H101.281V18.3246H98.4375V4.63184Z" fill="white"/>
<path d="M108.411 4.63184H116.264V6.91397H111.255V10.2494H116.038V12.5754H111.255V18.3246H108.411V4.63184Z" fill="white"/>
<path d="M117.934 18.3246V4.63184H120.777V18.3246H117.934Z" fill="white"/>
<path d="M133.304 4.93899V7.30889C132.808 7.13335 132.311 7.00168 131.77 6.91391C131.228 6.82613 130.597 6.78225 129.965 6.78225C128.475 6.78225 127.347 7.22112 126.535 8.14275C125.723 9.02049 125.316 10.1616 125.316 11.5221C125.316 12.8387 125.677 13.9359 126.445 14.8136C127.212 15.6913 128.295 16.1741 129.694 16.1741C130.19 16.1741 130.687 16.1302 131.274 16.0863C131.86 15.9985 132.447 15.8669 133.079 15.6036L133.259 17.9296C133.169 17.9735 133.034 18.0174 132.898 18.0612C132.718 18.1051 132.537 18.149 132.311 18.1929C131.95 18.2807 131.499 18.3246 130.958 18.4123C130.416 18.4562 129.874 18.5001 129.288 18.5001C129.198 18.5001 129.107 18.5001 129.062 18.5001C128.972 18.5001 128.882 18.5001 128.837 18.5001C127.212 18.4123 125.723 17.7979 124.369 16.7446C123.015 15.6475 122.338 13.9359 122.338 11.6537C122.338 9.41548 123.015 7.65999 124.324 6.43115C125.632 5.20231 127.438 4.58789 129.649 4.58789C130.236 4.58789 130.777 4.58789 131.228 4.63178C131.725 4.67567 132.176 4.76344 132.673 4.85121C132.763 4.8951 132.898 4.8951 132.988 4.93899C133.079 4.8951 133.169 4.93899 133.304 4.93899Z" fill="white"/>
<path d="M135.561 4.63184H144V6.78231H138.449V10.2055H143.458V12.3121H138.449V16.1742H144V18.3246H135.561V4.63184Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.06812 15.75C5.29857 15.75 2.25568 12.735 2.25568 9C2.25568 5.265 5.29857 2.25 9.06812 2.25C10.8812 2.25 12.5247 2.97759 13.7397 4.12194C13.8255 4.20274 13.9152 4.2797 14.0198 4.33409C14.3161 4.48823 14.9843 4.74308 15.487 4.245C15.9865 3.75001 15.7356 3.09308 15.5798 2.79677C15.5233 2.6894 15.4438 2.59682 15.3556 2.51353C13.7181 0.967092 11.5151 0 9.06812 0C4.05719 0 0 4.035 0 9C0 13.965 4.05719 18 9.06812 18C13.0816 18 16.4798 15.4184 17.6694 11.8342C17.8962 11.1509 17.3444 10.5 16.6244 10.5C16.0825 10.5 15.6221 10.8784 15.4283 11.3844C14.4527 13.9315 11.9806 15.75 9.06812 15.75Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 719 B

View File

@ -0,0 +1,3 @@
<svg width="18" height="14" 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="white"/>
</svg>

After

Width:  |  Height:  |  Size: 243 B

View File

@ -0,0 +1,61 @@
import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchUser, selectUser, selectUserLoading, selectUserError, selectIsAuthenticated } from '../../store/slices/userSlice';
import Spinner from '../../assets/Spinner.svg';
import Login from '../../pages/Login';
export default function AuthWrapper({ children }) {
const dispatch = useDispatch();
const user = useSelector(selectUser);
const loading = useSelector(selectUserLoading);
const error = useSelector(selectUserError);
const isAuthenticated = useSelector(selectIsAuthenticated);
const [hasInitialized, setHasInitialized] = useState(false);
useEffect(() => {
dispatch(fetchUser()).finally(() => {
setHasInitialized(true);
});
}, [dispatch]);
// Show loading spinner only for initial auth check, not for login operations
if ((loading || !hasInitialized) && !isAuthenticated) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
width: '100vw'
}}>
<img
src={Spinner}
alt="Loading"
style={{
width: '50px',
height: '50px',
filter: 'invert(1) brightness(0.5)', // Makes white SVG dark gray
animation: 'spin 1s linear infinite' // Rotates continuously
}}
/>
<style>{`
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
`}</style>
</div>
);
}
if (!isAuthenticated || !user) {
return <Login />;
}
// Show the main app content if user is authenticated
return children;
}

View File

@ -0,0 +1,83 @@
import React, { 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

@ -0,0 +1,53 @@
.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

@ -0,0 +1,156 @@
import React, { useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { fetchConfiguration, updateConfiguration } from '../../api';
import { getNestedValue } from '../../utils/getNestedValue';
import { mergeNestedObjects } from '../../utils/mergeNestedObjects';
import { configurationSections, ROLES } from '../../config/configurationSchema';
import { selectUser } from '../../store/slices/userSlice';
import ExpandableSection from '../ExpandableSection';
import ConfigurationField from '../ConfigurationInput';
import Button from '../Button';
import styles from './styles.module.css';
export default function Configuration() {
const user = useSelector(selectUser);
const [config, setConfig] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [fieldValues, setFieldValues] = useState({});
const [fieldErrors, setFieldErrors] = useState({});
const filteredSections = configurationSections.map(section => ({
...section,
fields: section.fields.filter(field => {
if (user?.isAdmin && field.roles.includes(ROLES.ADMIN)) {
return true;
}
if (!user?.isAdmin && field.roles.includes(ROLES.USER)) {
return true;
}
return false;
})
})).filter(section => section.fields.length > 0);
useEffect(() => {
const loadConfiguration = async () => {
try {
setLoading(true);
setError(null);
const data = await fetchConfiguration();
setConfig(data);
const initialValues = {};
filteredSections.forEach(section => {
section.fields.forEach(field => {
initialValues[field.path] = getNestedValue(data, field.path, '');
});
});
setFieldValues(initialValues);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
loadConfiguration();
}, []);
const handleFieldChange = (path, value) => {
setFieldValues(prev => ({
...prev,
[path]: value
}));
// Clear error for this field when user modifies it
if (fieldErrors[path]) {
setFieldErrors(prev => {
const newErrors = { ...prev };
delete newErrors[path];
return newErrors;
});
}
};
const handleSaveSection = async (sectionTitle) => {
const section = filteredSections.find(s => s.title === sectionTitle);
if (!section) return;
// Clear previous errors for this section
const newFieldErrors = { ...fieldErrors };
section.fields.forEach(field => {
delete newFieldErrors[field.path];
});
setFieldErrors(newFieldErrors);
const changedObjects = section.fields.map(field => {
const obj = {};
obj[field.path] = fieldValues[field.path];
return obj;
});
const mergedConfig = mergeNestedObjects(changedObjects);
try {
await updateConfiguration(mergedConfig);
} catch (error) {
console.log('error777', JSON.stringify(error));
// Handle validation errors from backend
if (error.error && error.error.details && Array.isArray(error.error.details)) {
const errors = {};
error.error.details.forEach(detail => {
if (detail.path && detail.message) {
// Join the path array to create the field path
const fieldPath = detail.path.join('.');
errors[fieldPath] = detail.message;
}
});
setFieldErrors(prev => ({ ...prev, ...errors }));
} else {
// Handle other types of errors
console.error('Save error:', error);
}
throw error; // Re-throw to trigger error state in Button component
}
};
if (loading) {
return <div className={styles.loading}>Loading configuration...</div>;
}
if (error) {
return <div className={styles.error}>Error: {error}</div>;
}
return (
<div className={styles.configuration}>
{filteredSections.map((section, index) => {
return (
<ExpandableSection key={index} title={section.title}>
{section.fields.map((field) => (
<ConfigurationField
key={field.path}
label={field.label}
value={fieldValues[field.path] || ''}
onChange={(value) => handleFieldChange(field.path, value)}
type={field.type}
error={fieldErrors[field.path]}
min={field.min}
max={field.max}
options={field.options}
description={field.description}
/>
))}
<Button
onClick={() => handleSaveSection(section.title)}
errorText="FAILED"
>
SAVE
</Button>
</ExpandableSection>
);
})}
</div>
);
}

View File

@ -0,0 +1,66 @@
.configuration {
}
.title {
margin-bottom: 24px;
color: #333;
font-size: 24px;
font-weight: 600;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
font-size: 16px;
color: #666;
}
.error {
padding: 16px;
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
border-radius: 4px;
margin-bottom: 16px;
}
.saveError {
padding: 12px;
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
border-radius: 4px;
margin-bottom: 16px;
font-size: 14px;
}
.saveButton {
padding: 16px;
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;
}
.saveButton:hover:not(:disabled) {
background: rgb(255, 149, 113);
}
.saveButton:disabled {
background: #6c757d;
cursor: not-allowed;
}
.saveButton.loading {
cursor: not-allowed;
height: auto;
display: inline-block;
}

View File

@ -0,0 +1,65 @@
import React from 'react';
import Input from '../Input';
import styles from './styles.module.css';
export default function ConfigurationInput({
label,
value,
onChange,
placeholder = '',
type = 'text',
error = null,
min = null,
max = null,
options = [],
description = null
}) {
const renderInput = () => {
if (type === 'select') {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={`${styles.input} ${error ? styles.inputError : ''}`}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
return (
<Input
type={type}
value={value}
onChange={onChange}
placeholder={placeholder}
error={error}
min={min}
max={max}
/>
);
};
return (
<div className={styles.field}>
<label className={styles.label}>{label}</label>
<div className={styles.inputContainer}>
{renderInput()}
{description && (
<div className={styles.description}>
{description}
</div>
)}
{error && (
<div className={styles.errorMessage}>
{error}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,64 @@
.field {
margin-bottom: 16px;
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.label {
display: block;
min-width: 120px;
flex-shrink: 0;
color: rgb(68, 68, 68);
font-weight: 400;
font-size: 13px;
margin-top: 11px;
}
.label::after {
content: ':';
}
.inputContainer {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
max-width: 300px;
}
.input {
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
width: 100%;
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.inputError {
border-color: #dc3545;
}
.inputError:focus {
border-color: #dc3545;
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.25);
}
.description {
color: #6c757d;
font-size: 12px;
font-style: italic;
margin-top: 2px;
}
.errorMessage {
color: #dc3545;
font-size: 12px;
}

View File

@ -0,0 +1,29 @@
import React, { useState } from 'react';
import styles from './styles.module.css';
export default function ExpandableSection({ title, children }) {
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpanded = () => {
setIsExpanded(!isExpanded);
};
return (
<div className={styles.expandableSection}>
<div
className={styles.header}
onClick={toggleExpanded}
>
<span className={styles.title}>{title}</span>
<span className={`${styles.arrow} ${isExpanded ? styles.expanded : styles.collapsed}`}>
</span>
</div>
{isExpanded && (
<div className={styles.content}>
{children}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,44 @@
.expandableSection {
border: 1px solid #e0e0e0;
border-radius: 4px;
margin-bottom: 16px;
background: white;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
cursor: pointer;
background: #f8f9fa;
border-bottom: 1px solid #e0e0e0;
transition: background-color 0.2s ease;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.header:hover {
background: #e9ecef;
}
.title {
font-weight: 600;
color: #333;
}
.arrow {
font-size: 12px;
color: #666;
transition: transform 0.2s ease;
}
.arrow.collapsed {
transform: rotate(-90deg);
}
.content {
padding: 16px;
}

View File

@ -0,0 +1,43 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { selectIsAuthenticated } from '../../store/slices/userSlice';
import { logout } from '../../api';
import Logo from '@assets/AppLogo.svg';
import styles from './styles.module.css';
function Header() {
const isAuthenticated = useSelector(selectIsAuthenticated);
const handleLogout = async () => {
try {
await logout();
// Reload the page after successful logout
window.location.reload();
} catch (error) {
console.error('Logout failed:', error);
// Still reload the page even if logout API fails
window.location.reload();
}
};
return (
<div className={styles.header}>
<div className={styles.headerContent}>
<Link to="/">
<img src={Logo} alt="ONLYOFFICE" style={{ cursor: 'pointer' }} />
</Link>
{isAuthenticated && (
<button
onClick={handleLogout}
className={styles.logoutButton}
>
Logout
</button>
)}
</div>
</div>
);
}
export default Header;

View File

@ -0,0 +1,42 @@
.header {
height: 48px;
position: fixed;
width: 100%;
top: 0px;
background: #333333;
z-index: 2;
}
.headerContent {
height: 23px;
margin-top: calc(48px / 2 - 23px / 2);
display: flex;
align-items: center;
justify-content: space-between;
width: 60%;
margin-left: 20%;
}
.headerContent img {
height: 23px;
width: auto;
margin-left: 0;
display: block;
}
.logoutButton {
background: #666666;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.logoutButton:hover {
background: #888888;
}
@media (max-width: 1200px) {
.headerContent {
width: 90%;
margin-left: 5%;
}
}

View File

@ -0,0 +1,27 @@
import React from 'react';
import styles from './styles.module.css';
export default function Input({
value,
onChange,
placeholder = '',
type = 'text',
error = null,
className = '',
onKeyDown = null,
min = null,
max = null
}) {
return (
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
className={`${styles.input} ${error ? styles.inputError : ''} ${className}`}
min={min}
max={max}
/>
);
}

View File

@ -0,0 +1,28 @@
.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;
}

View File

@ -0,0 +1,42 @@
import React from 'react';
import styles from './styles.module.css';
export default function InfoTable({ caption, editor, viewer, desc }) {
return (
<div className={styles.container}>
{caption && (
<div className={styles.sectionHeader}>{caption}</div>
)}
<div className={styles.editorsLabel}>EDITORS</div>
<div className={styles.divider}></div>
<div className={styles.row}>
{editor.map((v, i) => (
<div
key={i}
className={`${styles.valueCell} ${desc[i] === 'Remaining' ? styles.remainingValue : ''}`}
>
{v[0]}
</div>
))}
</div>
<div className={styles.row}>
{desc.map((d, i) => <div key={i} className={styles.labelCell}>{d}</div>)}
</div>
<div className={styles.viewerLabel}>LIVE VIEWER</div>
<div className={styles.divider}></div>
<div className={styles.row}>
{viewer.map((v, i) => (
<div
key={i}
className={`${styles.valueCell} ${desc[i] === 'Remaining' ? styles.remainingValue : ''}`}
>
{v[0]}
</div>
))}
</div>
<div className={styles.row}>
{desc.map((d, i) => <div key={i} className={styles.labelCell}>{d}</div>)}
</div>
</div>
);
}

View File

@ -0,0 +1,44 @@
.container {
margin-bottom: 25px;
}
.sectionHeader {
background: #f5f5f5;
font-weight: 400;
padding: 6px 8px;
margin-bottom: 8px;
}
.row {
display: flex;
justify-content: start;
}
.valueCell {
font-size: 22px;
padding: 8px 0;
width: 25%;
padding-left: 10px;
}
.labelCell {
font-size: 13px;
color: #555;
padding-bottom: 8px;
width: 25%;
padding-left: 10px;
}
.editorsLabel, .viewerLabel {
font-weight: 600;
font-size: 12px;
padding: 8px 0 0 10px;
}
.viewerLabel {
margin-top: 15px;
}
.divider {
border-bottom: 1px solid #ddd;
margin: 2px 10px;
}
.remainingValue {
color: rgb(1, 125, 28);
}

View File

@ -0,0 +1,11 @@
import React from 'react';
import styles from './styles.module.css';
export default function TopBlock({ title, children }) {
return (
<div className={styles.block}>
<div className={styles.title}>{title}</div>
<div className={styles.content}>{children}</div>
</div>
);
}

View File

@ -0,0 +1,18 @@
.block {
flex: 1;
min-width: 180px;
}
.title {
font-weight: 400;
font-size: 18px;
border-bottom: 1px solid #ddd;
padding: 0 0 5px 10px;
}
.content {
font-size: 12px;
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
padding-left: 10px;
}

View File

@ -0,0 +1,68 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import TopBlock from './TopBlock/index';
import InfoTable from './InfoTable/index';
import styles from './styles.module.css';
import { fetchStatistics } from '../../api';
export default function Statistics() {
const { data, isLoading, error } = useQuery({
queryKey: ['statistics'],
queryFn: fetchStatistics,
});
if (isLoading) return <div>Loading statistics...</div>;
if (error) return <div style={{ color: 'red' }}>Error: {error.message}</div>;
if (!data) return null;
const { licenseInfo, quota, connectionsStat, serverInfo } = data;
const buildDate = licenseInfo.buildDate ? new Date(licenseInfo.buildDate).toLocaleDateString() : '';
const buildBlock = (
<TopBlock title="Build">
<div>Type: {licenseInfo.packageType === 0 ? 'Open source' : licenseInfo.packageType === 1 ? 'Enterprise Edition' : 'Developer Edition'}</div>
<div>Version: {serverInfo.buildVersion}.{serverInfo.buildNumber}</div>
<div>Release date: {buildDate}</div>
</TopBlock>
);
const licenseBlock = (
<TopBlock title="License">
{licenseInfo.startDate === null ? 'No license' :
<div>Start date: {licenseInfo.startDate ? new Date(licenseInfo.startDate).toLocaleDateString() : ''}</div>
}
</TopBlock>
);
const connectionsBlock = (
<TopBlock title="Connections limit">
<div>Editors: {licenseInfo.connections}</div>
<div>Live Viewer: {licenseInfo.connectionsView}</div>
</TopBlock>
);
const valueEdit = licenseInfo.connections - (quota.edit.connectionsCount || 0);
const valueView = licenseInfo.connectionsView - (quota.view.connectionsCount || 0);
const editor = [
[quota.edit.connectionsCount || 0, ''],
[valueEdit, valueEdit > licenseInfo.connections * 0.1 ? 'normal' : 'critical']
];
const viewer = [
[quota.view.connectionsCount || 0, ''],
[valueView, valueView > licenseInfo.connectionsView * 0.1 ? 'normal' : 'critical']
];
const desc = ['Active', 'Remaining'];
const peaksDesc = ['Last Hour', '24 Hours', 'Week', 'Month'];
const peaksEditor = ['hour', 'day', 'week', 'month'].map(k => [connectionsStat?.[k]?.edit?.max || 0]);
const peaksViewer = ['hour', 'day', 'week', 'month'].map(k => [connectionsStat?.[k]?.liveview?.max || 0]);
const avrEditor = ['hour', 'day', 'week', 'month'].map(k => [connectionsStat?.[k]?.edit?.avr || 0]);
const avrViewer = ['hour', 'day', 'week', 'month'].map(k => [connectionsStat?.[k]?.liveview?.avr || 0]);
return (
<div>
<div className={styles.topRow}>{buildBlock}{licenseBlock}{connectionsBlock}</div>
<InfoTable caption="Current connections" editor={editor} viewer={viewer} desc={desc} />
<InfoTable caption="Peaks" editor={peaksEditor} viewer={peaksViewer} desc={peaksDesc} />
<InfoTable caption="Average" editor={avrEditor} viewer={avrViewer} desc={peaksDesc} />
</div>
);
}

View File

@ -0,0 +1,5 @@
.topRow {
display: flex;
margin-bottom: 24px;
gap: 24px;
}

View File

@ -0,0 +1,23 @@
import React from 'react';
import styles from './styles.module.css';
export default function Tabs({ tabs, activeTab, onTabChange, children }) {
return (
<div>
<div className={styles.tabContainer}>
{tabs.map(tab => (
<div
key={tab.key}
onClick={() => onTabChange(tab.key)}
className={activeTab === tab.key ? `${styles.tab} ${styles.tabActive}` : styles.tab}
>
{tab.label}
</div>
))}
</div>
<div className={styles.tabContent}>
{children}
</div>
</div>
);
}

View File

@ -0,0 +1,22 @@
.tabContainer {
display: flex;
border-bottom: 1px solid rgb(239, 239, 239);
margin-bottom: 24px;
}
.tab {
padding: 5px 12px;
cursor: pointer;
border-bottom: 2px solid transparent;
color: rgb(68, 68, 68);
font-size: 13px;
font-weight: 600;
transition: color 0.2s, border-bottom 0.2s;
}
.tabActive {
border-bottom: 2px solid rgb(255, 111, 61);
color: rgb(255, 111, 61);
font-weight: bold;
}
.tabContent {
/* Add any content-specific styles here */
}

View File

@ -0,0 +1,76 @@
// Role enum for access control
export const ROLES = {
ADMIN: 'admin',
USER: 'user',
};
export const configurationSections = [
{
title: 'Garbage Collector',
fields: [
{
path: 'services.CoAuthoring.expire.filesCron',
label: 'Files Cron',
type: 'text',
roles: [ROLES.ADMIN],
description: 'Cron expression for file cleanup (admin only)'
},
{
path: 'services.CoAuthoring.expire.documentsCron',
label: 'Documents Cron',
type: 'text',
roles: [ROLES.ADMIN],
description: 'Cron expression for document cleanup (admin only)'
},
{
path: 'services.CoAuthoring.expire.files',
label: 'Files Expiration Time',
type: 'number',
min: 0,
roles: [ROLES.ADMIN],
description: 'Files expiration time in seconds (admin only)'
},
{
path: 'services.CoAuthoring.expire.filesremovedatonce',
label: 'Files Removed At Once',
type: 'number',
min: 0,
roles: [ROLES.ADMIN],
description: 'Number of files to remove at once (admin only)'
}
]
},
{
title: 'Auto Assembly',
fields: [
{
path: 'services.CoAuthoring.autoAssembly.step',
label: 'Auto Assembly Step',
type: 'select',
options: [
{ value: '1m', label: '1 minute' },
{ value: '5m', label: '5 minutes' },
{ value: '10m', label: '10 minutes' },
{ value: '15m', label: '15 minutes' },
{ value: '30m', label: '30 minutes' }
],
roles: [ROLES.ADMIN, ROLES.USER],
description: 'Step interval for auto assembly process'
}
]
},
{
title: 'File Size Limits',
fields: [
{
path: 'FileConverter.converter.maxDownloadBytes',
label: 'Max Download Bytes',
type: 'number',
min: 0,
max: 104857600,
roles: [ROLES.ADMIN, ROLES.USER],
description: 'Maximum number of bytes allowed for download (max: 100MB)'
}
]
}
];

26
AdminPanel/src/index.js Normal file
View File

@ -0,0 +1,26 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
},
},
});
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);

View File

@ -0,0 +1,28 @@
import React, { useState } from 'react';
import Statistics from '../components/Statistics';
import Configuration from '../components/Configuration';
import Tabs from '../components/Tabs';
import styles from './styles.module.css';
const tabs = [
{ key: 'statistics', label: 'STATISTICS' },
{ key: 'configuration', label: 'CONFIGURATION' },
];
const tabComponents = {
statistics: <Statistics />,
configuration: <Configuration />,
};
export default function Home() {
const [activeTab, setActiveTab] = useState('statistics');
return (
<div className={styles.container}>
<h1 className={styles.title}>Document Server Admin Panel</h1>
<Tabs tabs={tabs} activeTab={activeTab} onTabChange={setActiveTab}>
{tabComponents[activeTab]}
</Tabs>
</div>
);
}

View File

@ -0,0 +1,66 @@
import React, { useState, useRef } from 'react';
import { useDispatch } from 'react-redux';
import { loginUser } from '../../store/slices/userSlice';
import Input from '../../components/Input';
import Button from '../../components/Button';
import styles from './styles.module.css';
export default function Login() {
const [secret, setSecret] = useState('');
const [error, setError] = useState('');
const dispatch = useDispatch();
const buttonRef = useRef();
const handleSubmit = async () => {
setError('');
try {
await dispatch(loginUser(secret)).unwrap();
} catch (error) {
setError(error || 'Invalid credentials. Please try again.');
throw error; // Re-throw to trigger error state in Button component
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
if (buttonRef.current) {
buttonRef.current.click();
}
}
};
return (
<div className={styles.loginContainer}>
<div className={styles.loginCard}>
<h1 className={styles.title}>ONLYOFFICE Admin Panel</h1>
<p className={styles.subtitle}>Enter your secret key to access the admin panel</p>
<p className={styles.description}>
The session is valid for 60 minutes.
</p>
<div className={styles.form}>
<div className={styles.inputGroup}>
<Input
label="Secret Key"
type="password"
value={secret}
onChange={setSecret}
placeholder="Enter your secret key"
error={error}
onKeyDown={handleKeyDown}
/>
</div>
<Button
ref={buttonRef}
onClick={handleSubmit}
errorText="FAILED"
>
LOGIN
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,51 @@
.loginContainer {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 60px); /* Account for header height */
background: white;
padding: 20px;
}
.loginCard {
background: white;
width: 100%;
max-width: 500px;
text-align: center;
}
.title {
color: #333;
font-size: 32px;
font-weight: bold;
margin: 0 0 16px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.subtitle {
color: #333;
font-size: 16px;
font-weight: bold;
margin: 0 0 16px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.description {
color: #333;
font-size: 14px;
margin: 0 0 32px 0;
line-height: 1.5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.form {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.inputGroup {
width: 100%;
max-width: 300px;
}

View File

@ -0,0 +1,9 @@
.container {
padding-top: 90px;
}
.title {
color: rgb(51, 51, 51);
margin-bottom: 10px;
font-size: 2rem;
font-weight: 600;
}

View File

@ -0,0 +1,8 @@
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './slices/userSlice';
export const store = configureStore({
reducer: {
user: userReducer,
},
});

View File

@ -0,0 +1,108 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { fetchCurrentUser, login } from '../../api';
export const fetchUser = createAsyncThunk(
'user/fetchUser',
async (_, { rejectWithValue }) => {
try {
return await fetchCurrentUser();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
export const loginUser = createAsyncThunk(
'user/loginUser',
async (secret, { rejectWithValue }) => {
try {
const response = await login(secret);
return response;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
const initialState = {
user: null,
loading: false,
loginLoading: false,
error: null,
isAuthenticated: false,
};
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
// Clear user data (logout)
clearUser: (state) => {
state.user = null;
state.isAuthenticated = false;
state.error = null;
},
// Set user data manually
setUser: (state, action) => {
state.user = action.payload;
state.isAuthenticated = true;
state.error = null;
},
// Clear error
clearError: (state) => {
state.error = null;
},
},
extraReducers: (builder) => {
builder
// Fetch user cases
.addCase(fetchUser.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false;
state.user = {
tenant: action.payload.tenant,
isAdmin: action.payload.isAdmin
};
state.isAuthenticated = true;
state.error = null;
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
state.isAuthenticated = false;
})
// Login cases
.addCase(loginUser.pending, (state) => {
state.loginLoading = true;
state.error = null;
})
.addCase(loginUser.fulfilled, (state, action) => {
state.loginLoading = false;
state.user = {
tenant: action.payload.tenant,
isAdmin: action.payload.isAdmin
};
state.isAuthenticated = true;
state.error = null;
})
.addCase(loginUser.rejected, (state, action) => {
state.loginLoading = false;
state.error = action.payload;
state.isAuthenticated = false;
});
},
});
export const { clearUser, setUser, clearError } = userSlice.actions;
// Selectors
export const selectUser = (state) => state.user.user;
export const selectUserLoading = (state) => state.user.loading;
export const selectLoginLoading = (state) => state.user.loginLoading;
export const selectUserError = (state) => state.user.error;
export const selectIsAuthenticated = (state) => state.user.isAuthenticated;
export default userSlice.reducer;

View File

@ -0,0 +1,16 @@
export function getNestedValue(obj, path, defaultValue = '') {
if (!obj || !path) return defaultValue;
const keys = path.split('.');
let current = obj;
for (const key of keys) {
if (current && typeof current === 'object' && key in current) {
current = current[key];
} else {
return defaultValue;
}
}
return current !== undefined ? current : defaultValue;
}

View File

@ -0,0 +1,27 @@
export function mergeNestedObjects(objects) {
const result = {};
for (const obj of objects) {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const keys = key.split('.');
let current = result;
for (let i = 0; i < keys.length; i++) {
const part = keys[i];
if (i === keys.length - 1) {
current[part] = obj[key];
} else {
if (!current[part]) {
current[part] = {};
}
current = current[part];
}
}
}
}
}
return result;
}

View File

@ -0,0 +1,82 @@
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const CopyPlugin = require("copy-webpack-plugin");
const webpack = require("webpack");
module.exports = {
entry: "./src/index.js",
output: {
filename: "main.[contenthash].js",
path: path.resolve(__dirname, "build"),
publicPath: "/"
},
devServer: {
static: {
directory: path.join(__dirname, "build"),
publicPath: "/"
},
port: 3000,
open: true,
historyApiFallback: true
},
plugins: [
new HtmlWebpackPlugin({
template: path.join(__dirname, "public", "index.html"),
}),
new CopyPlugin({
patterns: [
{
context: path.resolve(__dirname, "public"),
from: "images/*.*",
},
{
context: path.resolve(__dirname),
from: "config.json"
},
],
}),
new webpack.DefinePlugin({
'process.env.REACT_APP_BACKEND_URL': JSON.stringify(process.env.REACT_APP_BACKEND_URL),
}),
],
module: {
rules: [
{
test: /\.(js)$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-react", "@babel/preset-env"],
},
},
},
{
test: /\.css$/i,
use: ["style-loader", "css-loader"],
},
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
generator: {
filename: 'static/[hash][ext]'
}
},
],
},
resolve: {
extensions: ["", ".js"],
alias: {
"@components": path.resolve(__dirname, "src/components"),
"@screen": path.resolve(__dirname, "src/screen"),
"@services": path.resolve(__dirname, "src/services"),
"@store": path.resolve(__dirname, "src/store"),
"@utility": path.resolve(__dirname, "src/utility"),
"@assets": path.resolve(__dirname, "src/assets")
},
}
};

View File

@ -961,6 +961,22 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="
},
"cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"requires": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"dependencies": {
"cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="
}
}
},
"cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",

View File

@ -16,6 +16,8 @@
"bytes": "3.1.2",
"co": "4.6.0",
"config": "3.3.12",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cron": "1.5.0",
"dmdb": "1.0.36002",
"ejs": "3.1.10",

View File

@ -0,0 +1,158 @@
/*
* (c) Copyright Ascensio System SIA 2010-2024
*
* This program is a free software product. You can redistribute it and/or
* modify it under the terms of the GNU Affero General Public License (AGPL)
* version 3 as published by the Free Software Foundation. In accordance with
* Section 7(a) of the GNU AGPL its Section 15 shall be amended to the effect
* that Ascensio System SIA expressly excludes the warranty of non-infringement
* of any third-party rights.
*
* This program is distributed WITHOUT ANY WARRANTY; without even the implied
* warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. For
* details, see the GNU AGPL at: http://www.gnu.org/licenses/agpl-3.0.html
*
* You can contact Ascensio System SIA at 20A-6 Ernesta Birznieka-Upish
* street, Riga, Latvia, EU, LV-1050.
*
* The interactive user interfaces in modified source and object code versions
* of the Program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU AGPL version 3.
*
* Pursuant to Section 7(b) of the License you must retain the original Product
* logo when distributing the program. Pursuant to Section 7(e) we decline to
* grant you any rights under trademark law for use of our trademarks.
*
* All the Product's GUI elements, including illustrations and icon sets, as
* well as technical writing content are licensed under the terms of the
* Creative Commons Attribution-ShareAlike 4.0 International. See the License
* terms at http://creativecommons.org/licenses/by-sa/4.0/legalcode
*
*/
'use strict';
const config = require('config');
const express = require('express');
const operationContext = require('../../../../Common/sources/operationContext');
const tenantBaseDir = config.get('tenants.baseDir');
// const isMultitenantMode = config.get('tenants.isMultitenantMode');
const defaultTenantSecret = config.get('services.CoAuthoring.secret.browser.string');
const filenameSecret = config.get('tenants.filenameSecret');
const tenantManager = require('../../../../Common/sources/tenantManager');
const jwt = require('jsonwebtoken');
const fs = require('fs');
const path = require('path');
const cookieParser = require('cookie-parser');
const commonDefines = require('../../../../Common/sources/commondefines');
const router = express.Router();
// Middleware to parse JSON request bodies
router.use(express.json());
// Middleware to parse cookies
router.use(cookieParser());
router.get('/me', async (req, res) => {
try {
const token = req.cookies.accessToken;
if (!token) {
return res.status(401).json({ error: 'Unauthorized' });
}
// Try to verify with default tenant secret first
try {
const decoded = jwt.verify(token, defaultTenantSecret);
res.json(decoded);
return;
} catch (defaultError) {
// If default secret fails, try to find the tenant and verify with their secret
const tenantList = fs.readdirSync(tenantBaseDir);
for (const tenant of tenantList) {
try {
const tenantSecret = fs.readFileSync(path.join(tenantBaseDir, tenant, filenameSecret), 'utf8');
const decoded = jwt.verify(token, tenantSecret);
res.json({
tenant: decoded.tenant,
isAdmin: decoded.isAdmin
});
return;
} catch (tenantError) {
// Continue to next tenant
continue;
}
}
// If no tenant secret works, return unauthorized
return res.status(401).json({ error: 'Invalid token' });
}
} catch (error) {
console.log('error', error);
res.status(401).json({ error: 'Unauthorized' });
}
});
router.post('/login', async (req, res) => {
let ctx = new operationContext.Context();
ctx.initDefault()
try {
const { secret } = req.body;
const tenant = findTenantBySecret(secret);
if (!tenant) {
return res.status(401).json({ error: 'Invalid secret' });
}
const token = jwt.sign({ ...tenant }, secret, { expiresIn: '1h' });
res.cookie('accessToken', token, {
httpOnly: true,
sameSite: 'strict',
maxAge: 60 * 60 * 1000,
path: '/'
});
res.json({ tenant: tenant.tenant, isAdmin: tenant.isAdmin });
} catch (error) {
ctx.logger.error('Config get error: %s', error.stack);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post('/logout', async (req, res) => {
try {
// Clear the httpOnly accessToken cookie
res.clearCookie('accessToken', {
httpOnly: true,
sameSite: 'strict',
path: '/'
});
res.json({ message: 'Logged out successfully' });
} catch (error) {
console.log('logout error', error);
res.status(500).json({ error: 'Internal server error' });
}
});
//TODO: make function async, use cache
function findTenantBySecret(secret) {
if (secret === defaultTenantSecret) {
return {
tenant: config.get('tenants.defaultTenant'),
isAdmin: true
};
}
const tenantList = fs.readdirSync(tenantBaseDir);
for (const tenant of tenantList) {
const tenantSecret = fs.readFileSync(path.join(tenantBaseDir, tenant, filenameSecret), 'utf8');
if (tenantSecret === secret) {
return {
tenant,
isAdmin: true
};
}
}
return null;
}
module.exports = router;

View File

@ -60,8 +60,10 @@ const operationContext = require('./../../Common/sources/operationContext');
const tenantManager = require('./../../Common/sources/tenantManager');
const staticRouter = require('./routes/static');
const configRouter = require('./routes/config');
const adminpanelRouter = require('./routes/adminpanel/router');
const ms = require('ms');
const aiProxyHandler = require('./ai/aiProxyHandler');
const cors = require('cors');
const cfgWopiEnable = config.get('wopi.enable');
const cfgWopiDummyEnable = config.get('wopi.dummy.enable');
@ -117,6 +119,12 @@ const updateLicense = async () => {
operationContext.global.logger.error('updateLicense error: %s', err.stack);
}
};
const corsWithCredentials = cors({
origin: true,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
});
operationContext.global.logger.warn('Express server starting...');
@ -234,16 +242,17 @@ docsCoServer.install(server, () => {
res.send("User-agent: *\nDisallow: /");
});
app.post('/docbuilder', utils.checkClientIp, rawFileParser, (req, res) => {
converterService.builder(req, res);
});
app.get('/info/info.json', utils.checkClientIp, docsCoServer.licenseInfo);
app.use('/info/config', utils.checkClientIp, configRouter);
app.get('/info/plugin/settings', utils.checkClientIp, aiProxyHandler.requestSettings);
app.post('/info/plugin/models', utils.checkClientIp, rawFileParser, aiProxyHandler.requestModels);
app.put('/internal/cluster/inactive', utils.checkClientIp, docsCoServer.shutdown);
app.delete('/internal/cluster/inactive', utils.checkClientIp, docsCoServer.shutdown);
app.get('/internal/connections/edit', docsCoServer.getEditorConnectionsCount);
app.post('/docbuilder', utils.checkClientIp, rawFileParser, (req, res) => {
converterService.builder(req, res);
});
app.get('/info/info.json', cors(), utils.checkClientIp, docsCoServer.licenseInfo);
app.use('/info/config', corsWithCredentials, utils.checkClientIp, configRouter);
app.use('/info/adminpanel', corsWithCredentials, utils.checkClientIp, adminpanelRouter);
app.get('/info/plugin/settings', utils.checkClientIp, aiProxyHandler.requestSettings);
app.post('/info/plugin/models', utils.checkClientIp, rawFileParser, aiProxyHandler.requestModels);
app.put('/internal/cluster/inactive', utils.checkClientIp, docsCoServer.shutdown);
app.delete('/internal/cluster/inactive', utils.checkClientIp, docsCoServer.shutdown);
app.get('/internal/connections/edit', docsCoServer.getEditorConnectionsCount);
function checkWopiEnable(req, res, next) {
//todo may be move code into wopiClient or wopiClient.discovery...