mirror of
https://github.com/ONLYOFFICE/server.git
synced 2026-02-10 09:55:11 +08:00
[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:
committed by
Sergey Konovalov
parent
8ee59496c6
commit
c1584abfa1
3
.gitignore
vendored
3
.gitignore
vendored
@ -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
1
AdminPanel/.env
Normal file
@ -0,0 +1 @@
|
||||
REACT_APP_BACKEND_URL=http://localhost:8080
|
||||
1
AdminPanel/.env.example
Normal file
1
AdminPanel/.env.example
Normal file
@ -0,0 +1 @@
|
||||
REACT_APP_BACKEND_URL=http://localhost:8080
|
||||
23
AdminPanel/.gitignore
vendored
Normal file
23
AdminPanel/.gitignore
vendored
Normal 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
0
AdminPanel/config.json
Normal file
8690
AdminPanel/package-lock.json
generated
Normal 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
37
AdminPanel/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
AdminPanel/public/images/favicon.ico
Normal file
BIN
AdminPanel/public/images/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
0
AdminPanel/public/images/jnj.png
Normal file
0
AdminPanel/public/images/jnj.png
Normal file
18
AdminPanel/public/index.html
Normal file
18
AdminPanel/public/index.html
Normal 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
39
AdminPanel/src/App.css
Normal 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
27
AdminPanel/src/App.js
Normal 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
101
AdminPanel/src/api/index.js
Normal 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();
|
||||
};
|
||||
15
AdminPanel/src/assets/AppLogo.svg
Normal file
15
AdminPanel/src/assets/AppLogo.svg
Normal 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 |
3
AdminPanel/src/assets/Spinner.svg
Normal file
3
AdminPanel/src/assets/Spinner.svg
Normal 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 |
3
AdminPanel/src/assets/Success.svg
Normal file
3
AdminPanel/src/assets/Success.svg
Normal 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 |
61
AdminPanel/src/components/AuthWrapper/index.js
Normal file
61
AdminPanel/src/components/AuthWrapper/index.js
Normal 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;
|
||||
}
|
||||
83
AdminPanel/src/components/Button/index.js
Normal file
83
AdminPanel/src/components/Button/index.js
Normal 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;
|
||||
53
AdminPanel/src/components/Button/styles.module.css
Normal file
53
AdminPanel/src/components/Button/styles.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
156
AdminPanel/src/components/Configuration/index.js
Normal file
156
AdminPanel/src/components/Configuration/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
66
AdminPanel/src/components/Configuration/styles.module.css
Normal file
66
AdminPanel/src/components/Configuration/styles.module.css
Normal 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;
|
||||
}
|
||||
65
AdminPanel/src/components/ConfigurationInput/index.js
Normal file
65
AdminPanel/src/components/ConfigurationInput/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
29
AdminPanel/src/components/ExpandableSection/index.js
Normal file
29
AdminPanel/src/components/ExpandableSection/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
43
AdminPanel/src/components/Header/index.js
Normal file
43
AdminPanel/src/components/Header/index.js
Normal 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;
|
||||
42
AdminPanel/src/components/Header/styles.module.css
Normal file
42
AdminPanel/src/components/Header/styles.module.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
27
AdminPanel/src/components/Input/index.js
Normal file
27
AdminPanel/src/components/Input/index.js
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
28
AdminPanel/src/components/Input/styles.module.css
Normal file
28
AdminPanel/src/components/Input/styles.module.css
Normal 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;
|
||||
}
|
||||
42
AdminPanel/src/components/Statistics/InfoTable/index.js
Normal file
42
AdminPanel/src/components/Statistics/InfoTable/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
11
AdminPanel/src/components/Statistics/TopBlock/index.js
Normal file
11
AdminPanel/src/components/Statistics/TopBlock/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
68
AdminPanel/src/components/Statistics/index.js
Normal file
68
AdminPanel/src/components/Statistics/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
5
AdminPanel/src/components/Statistics/styles.module.css
Normal file
5
AdminPanel/src/components/Statistics/styles.module.css
Normal file
@ -0,0 +1,5 @@
|
||||
.topRow {
|
||||
display: flex;
|
||||
margin-bottom: 24px;
|
||||
gap: 24px;
|
||||
}
|
||||
23
AdminPanel/src/components/Tabs/index.js
Normal file
23
AdminPanel/src/components/Tabs/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
22
AdminPanel/src/components/Tabs/styles.module.css
Normal file
22
AdminPanel/src/components/Tabs/styles.module.css
Normal 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 */
|
||||
}
|
||||
76
AdminPanel/src/config/configurationSchema.js
Normal file
76
AdminPanel/src/config/configurationSchema.js
Normal 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
26
AdminPanel/src/index.js
Normal 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>
|
||||
);
|
||||
28
AdminPanel/src/pages/Home.js
Normal file
28
AdminPanel/src/pages/Home.js
Normal 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>
|
||||
);
|
||||
}
|
||||
66
AdminPanel/src/pages/Login/index.js
Normal file
66
AdminPanel/src/pages/Login/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
51
AdminPanel/src/pages/Login/styles.module.css
Normal file
51
AdminPanel/src/pages/Login/styles.module.css
Normal 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;
|
||||
}
|
||||
9
AdminPanel/src/pages/styles.module.css
Normal file
9
AdminPanel/src/pages/styles.module.css
Normal file
@ -0,0 +1,9 @@
|
||||
.container {
|
||||
padding-top: 90px;
|
||||
}
|
||||
.title {
|
||||
color: rgb(51, 51, 51);
|
||||
margin-bottom: 10px;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
8
AdminPanel/src/store/index.js
Normal file
8
AdminPanel/src/store/index.js
Normal file
@ -0,0 +1,8 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import userReducer from './slices/userSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
user: userReducer,
|
||||
},
|
||||
});
|
||||
108
AdminPanel/src/store/slices/userSlice.js
Normal file
108
AdminPanel/src/store/slices/userSlice.js
Normal 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;
|
||||
16
AdminPanel/src/utils/getNestedValue.js
Normal file
16
AdminPanel/src/utils/getNestedValue.js
Normal 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;
|
||||
}
|
||||
27
AdminPanel/src/utils/mergeNestedObjects.js
Normal file
27
AdminPanel/src/utils/mergeNestedObjects.js
Normal 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;
|
||||
}
|
||||
82
AdminPanel/webpack.config.js
Normal file
82
AdminPanel/webpack.config.js
Normal 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")
|
||||
},
|
||||
}
|
||||
};
|
||||
16
DocService/npm-shrinkwrap.json
generated
16
DocService/npm-shrinkwrap.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
158
DocService/sources/routes/adminpanel/router.js
Normal file
158
DocService/sources/routes/adminpanel/router.js
Normal 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;
|
||||
@ -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...
|
||||
|
||||
Reference in New Issue
Block a user