1
0
mirror of https://github.com/Segate-ekb/publicator.git synced 2024-11-16 09:58:27 +02:00

Merge branch 'new_frontend'

This commit is contained in:
ivanovEV 2023-03-23 03:40:19 +03:00
commit 9960a4adb5
55 changed files with 3141 additions and 2914 deletions

View File

@ -41,38 +41,28 @@
Для удобства использования реализован веб интерфейс, доступный по порту :3333
![Веб-интерфейс](/docs/img/web.png)
### Графический интерфейс
Здесь можно отредактировать/сохранить конфигурацию и перезапустить веб-сервер apache.
Реализован графичекий интерфейс. Я постарался его сделать адаптивным и приятно выглядищим на всех видах устройств.
![Веб интерфейс](./docs/img/web.png)
## Структура конфига
Посмотреть пример конфига можно в файле [config.json.example](/volumes/config.json.example)
### bases
Массив со списком информационных баз 1с. На жанный момент поддерживаются только серверные варианты баз
### Редактирование карточки публикации
Потому обязаельно надо заполнить свойства `Srvr` и `Ref`
### publications
* Реализована графическая настройка и валидация таких групп параметров как http-сервисы, web-сервисы, общие настройки и oidc
Массив публикаций конкртеной базы. Их может быть несколько. Например в одна для использование веб-интерфейса, а другая для публикации анонимных http-сервисов
![Редактирование публикации](./docs/img/%D0%A0%D0%B5%D0%B4%D0%B0%D0%BA%D1%82%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D0%B5%20%D0%BF%D1%83%D0%B1%D0%BB%D0%B8%D0%BA%D0%B0%D1%86%D0%B8%D0%B8.png)
Можно указать логин и пароль для авторизации в БД, а так же признак `enable` разрешающий или запрещающий вход.
### ws
### Настройка OpenId Connect
Секция описывающая веб сервисы публикации. Состоит из массива `wsList`, а так же из опции `publishExtensionsByDefault`
Реализован, и будет дополняться графический интерфейс для реализации oidc подключений к базе.
### httpServices
![oidc](./docs/img/%D0%9D%D0%B0%D1%81%D1%82%D1%80%D0%BE%D0%B9%D0%BA%D0%B0%20oidc.png)
Секция описывающая http-сервисы публикации. Состоит из массива `hsList`, а так же свойств `publishExtensionsByDefault` и `publishByDefault`
### oidc
Секция описывающая подключение по openid connect. содерит в себе массив `providers` описание заполнения этого масссива можно найти на ИТС
## To-do
Что будет дорабатываться:
* Добавить схему json для подсказок и валидации
* В ближайших планах, все таки понять как рисовать красивый фронт и сделать конструктор публикаций без прямого редактирования конфигурации
## Спасибо

View File

@ -7,6 +7,6 @@ services:
- 998:80
- 3333:3333
volumes:
- "./volumes/config.json:/opt/app/winow/config/config.json"
- "./volumes/config:/opt/app/winow/config"
# - "./volumes/autumn-properties.json:/opt/app/winow/autumn-properties.json"

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

File diff suppressed because it is too large Load Diff

View File

@ -13,8 +13,9 @@
"@emotion/styled": "latest",
"@mui/icons-material": "^5.11.11",
"@mui/material": "latest",
"install": "^0.13.0",
"npm": "^9.6.2",
"@mui/system": "^5.11.13",
"material-ui-popup-state": "^5.0.8",
"notistack": "^3.0.1",
"react": "latest",
"react-dom": "latest",
"react-scripts": "latest"
@ -30,5 +31,8 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"workbox-webpack-plugin": "^6.5.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -1,17 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="initial-scale=1, width=device-width, shrink-to-fit=no" />
<meta name="theme-color" content="#000000" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/icon-192x192.png" />
<link rel="icon" href="%PUBLIC_URL%/icon-512x512.png" sizes="512x512" type="image/png" />
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta name="theme-color" content="#000000" />
<!--
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
@ -20,16 +22,17 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Публикатор 1с</title>
<!-- Fonts to support Material Design -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
<title>Публикатор 1с </title>
<!-- Fonts to support Material Design -->
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
/>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
@ -39,6 +42,5 @@
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -1,11 +1,21 @@
{
"short_name": "Your Orders",
"name": "Your Orders",
"short_name": "Публикатор 1с",
"name": "Публикатор 1с",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": ".",

View File

@ -0,0 +1,65 @@
// ApiProcessor.js
const serverApi = process.env.REACT_APP_SERVER_API;
const sendRequest = async (url, showSnackbar) => {
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
if (data.message && typeof data.message === 'string') {
showSnackbar && showSnackbar(data.message, "success");
}
return data;
} else {
showSnackbar && showSnackbar(`Произошла ошибка при выполнении запроса: ${response.statusText}`, "error");
return null;
}
};
export const startServer = async (showSnackbar) => {
return sendRequest(`/api/v1/ws/start`, showSnackbar);
};
export const stopServer = async (showSnackbar) => {
return sendRequest(`/api/v1/ws/stop`, showSnackbar);
};
export const restartServer = async (showSnackbar) => {
return sendRequest(`/api/v1/ws/restart`, showSnackbar);
};
export const getConfig = async (showSnackbar) => {
return sendRequest(`/api/v1/config/getconfig`, showSnackbar);
};
export const getSettings = async (showSnackbar) => {
return sendRequest(`/api/v1/config/getsettings`, showSnackbar);
};
const sendPostRequest = async (url, data, showSnackbar) => {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (response.ok) {
const responseData = await response.json();
showSnackbar && showSnackbar(responseData.message, "success");
return responseData;
} else {
showSnackbar && showSnackbar(`Произошла ошибка при выполнении запроса: ${response.statusText}`, "error");
return null;
}
};
export const updateSettings = async (settings, showSnackbar) => {
return sendPostRequest(`/api/v1/config/updatesettings`, settings, showSnackbar);
};
export const updateConfig = async (bases, showSnackbar) => {
return sendPostRequest(`/api/v1/config/updateconfig`, { bases }, showSnackbar);
};

View File

@ -1,50 +1,37 @@
import * as React from 'react';
import Container from '@mui/material/Container';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import Link from '@mui/material/Link';
import AppBarComponent from './AppBarComponent';
import Content from './Content';
import { DataProvider } from './DataContext';
import React, { useState, useContext } from "react";
import {
Box,
Container,
Typography,
Link
} from "@mui/material";
function Copyright() {
return (
<Typography variant="body2" color="text.secondary" align="center">
{'Copyright © '}
<Link color="inherit" href="https://1cdevelopers.ru/">
1cDevelopers.ru
</Link>{' '}
{new Date().getFullYear()}
{'.'}
</Typography>
);
}
import AppBarMenu from "./components/appBarMenu/AppBarMenu";
import BasesMenu from "./components/basesMenu/BasesMenu"
import PublicationsInfo from "./components/PublicationsInfo/PublicationsInfo"
import Copyright from "./components/Copyright";
export default function App() {
const [mobileOpen, setMobileOpen] = React.useState(false);
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
return (
<DataProvider>
<AppBarComponent handleDrawerToggle={handleDrawerToggle} />
<Box
sx={{
display: 'flex',
flexDirection: 'column',
minHeight: '99vh',
}}
>
<Box sx={{ flexGrow: 1 }}>
<Content handleDrawerToggle={handleDrawerToggle} mobileOpen={mobileOpen} />
<>
<Box sx={{ display: "flex", height: "100vh", flexDirection: "column" }}>
<AppBarMenu handleDrawerToggle={handleDrawerToggle} position="fixed" />
<Box sx={{ display: "flex", flexGrow: 1 }}>
<BasesMenu handleDrawerToggle={handleDrawerToggle} mobileOpen={mobileOpen} />
<Box component="main" sx={{ flexGrow: 1, p: 3, display: "flex", flexDirection: "column" }}>
<Container maxWidth="lg">
<PublicationsInfo />
<Copyright sx={{ marginTop: "auto" }} />
</Container>
</Box>
</Box>
<Copyright />
</Box>
</DataProvider>
</>
);
}

View File

@ -0,0 +1,44 @@
import React, { createContext, useState } from "react";
import { createTheme, ThemeProvider } from "@mui/material";
const lightTheme = createTheme({
palette: {
mode: "light",
primary: {
main: "#90caf9",
},
secondary: {
main: "#f48fb1",
},
},
});
const darkTheme = createTheme({
palette: {
mode: "dark",
primary: {
main: "#90caf9",
},
secondary: {
main: "#f48fb1",
},
},
});
const ThemeContext = createContext();
export const ThemeProviderWrapper = ({ children }) => {
const [darkMode, setDarkMode] = useState(false);
const toggleDarkMode = () => {
setDarkMode(!darkMode);
};
return (
<ThemeContext.Provider value={{ darkMode, toggleDarkMode }}>
<ThemeProvider theme={darkMode ? darkTheme : lightTheme}>{children}</ThemeProvider>
</ThemeContext.Provider>
);
};
export default ThemeContext;

View File

@ -0,0 +1,31 @@
import React from "react";
import { Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button } from "@mui/material";
const ConfirmationDialog = ({ open, title, content, onClose }) => {
const handleCancel = () => {
onClose(false);
};
const handleConfirm = () => {
onClose(true);
};
return (
<Dialog open={open} onClose={handleCancel}>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<DialogContentText>{content}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleCancel} color="primary">
Отмена
</Button>
<Button onClick={handleConfirm} color="primary" autoFocus>
Подтвердить
</Button>
</DialogActions>
</Dialog>
);
};
export default ConfirmationDialog;

View File

@ -0,0 +1,18 @@
import React from "react";
import { Typography, Link } from "@mui/material";
import CopyrightIcon from "@mui/icons-material/Copyright";
function Copyright() {
return (
<Typography variant="body2" color="text.secondary" align="center">
<CopyrightIcon fontSize="small" sx={{ verticalAlign: "middle", mr: 0.5 }} />
<Link color="inherit" href="https://1cDevelopers.ru/">
1cDevelopers.ru
</Link>{" "}
{new Date().getFullYear()}
{"."}
</Typography>
);
}
export default Copyright;

View File

@ -0,0 +1,123 @@
import React, { useState } from 'react';
import { Grid, TextField, Switch, FormControlLabel } from '@mui/material';
export default function GeneralSettings({ publication, onChange, onValidChange, validateTitle, validateName }) {
const [errors, setErrors] = useState({ title: false, name: false });
const handleChange = (event) => {
const { name, value, checked } = event.target;
onChange({
...publication,
[name]: event.target.type === 'checkbox' ? checked : value,
});
};
const handleBlur = (event) => {
const { name, value } = event.target;
let isValid = true;
if (name === 'title') {
isValid = validateTitle(value);
} else if (name === 'name') {
isValid = validateName(value);
}
setErrors({ ...errors, [name]: !isValid });
// Вызовите колбэк с результатом валидации
onValidChange(isValid);
};
return (
<Grid container spacing={2} sx={{ mt: 0.5 }}>
<Grid item xs={12} sm={10}>
<TextField
label="Название публикации"
name="title"
value={publication.title}
required
fullWidth
onChange={handleChange}
onBlur={handleBlur}
error={errors.title}
helperText={errors.title ? 'Название должно быть уникальным и заполненным' : ''}
/>
</Grid>
<Grid item xs={12} sm={2}>
<FormControlLabel
control={
<Switch
name="active"
checked={publication.active}
color="primary"
onChange={handleChange}
/>
}
label="Активна"
/>
</Grid>
<Grid item xs={12}>
<TextField
label="endpoint публикации"
name="name"
value={publication.name}
required
fullWidth
onChange={handleChange}
onBlur={handleBlur}
error={errors.name}
helperText={errors.name ? 'Endpoint должен быть уникальным, заполненным и содержать только латиницу и допустимые символы URL' : ''}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Имя пользователя"
name="usr"
value={publication.usr}
fullWidth
onChange={handleChange}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="пароль пользователя"
name="pwd"
value={publication.pwd}
type="password"
fullWidth
disabled={!publication.usr}
onChange={handleChange}
/>
</Grid>
<Grid item xs={12}>
<FormControlLabel
control={
<Switch
name="enable"
checked={publication.enable}
color="primary"
onChange={handleChange}
/>
}
label="вход пользователей разрешен"
/>
</Grid>
<Grid item xs={12}>
<FormControlLabel
control={
<Switch
name="enableStandardOData"
checked={publication.enableStandardOData}
color="primary"
onChange={handleChange}
/>
}
label="разрешить Odata"
/>
</Grid>
</Grid>
);
}

View File

@ -0,0 +1,73 @@
import React, { useEffect } from 'react';
import { FormControlLabel, Switch, Divider } from '@mui/material';
import ServicesArray from "./ServicesArray/ServicesArray"
export default function HttpSettings({ publication, onChange, onValidChange }) {
const handleSwitchChange = (event) => {
onChange({
...publication,
httpServices: {
...publication.httpServices,
[event.target.name]: event.target.checked,
},
});
};
useEffect(() => {
const isServicesValid = !publication.httpServices.hsList ||
publication.httpServices.hsList.length === 0 ||
publication.httpServices.hsList.every(
(service) => {
return (
service.name.trim() !== "" &&
/^[a-zA-Z0-9-_]+$/.test(
service.rootUrl
)
);
}
);
onValidChange(isServicesValid);
}, [publication.httpServices.hsList, onValidChange]);
const handleServicesChange = (newServices) => {
onChange({
...publication,
httpServices: {
...publication.httpServices,
hsList: newServices,
},
});
};
return (
<div>
<FormControlLabel
control={
<Switch
checked={publication.httpServices.publishExtensionsByDefault}
onChange={handleSwitchChange}
name="publishExtensionsByDefault"
/>
}
label="Публиковать http-сервисы расширений по уммолчанию"
/>
<FormControlLabel
control={
<Switch
checked={publication.httpServices.publishByDefault}
onChange={handleSwitchChange}
name="publishByDefault"
/>
}
label="Публиковать http-сервисы основной конфигурации по умолчанию"
/>
<Divider />
<div>HTTP-сервисы</div>
<ServicesArray
type="http"
servicesList={publication.httpServices.hsList}
onChange={handleServicesChange}
/>
</div>
);
}

View File

@ -0,0 +1,125 @@
import React, { useState, useEffect, useCallback } from 'react';
import { TextField, Typography, Grid, Select, MenuItem, InputLabel, FormControl } from '@mui/material';
export default function ClientConfigSettings({ provider = {}, onChange }) {
const [config, setConfig] = useState({});
const [errors, setErrors] = useState({});
useEffect(() => {
setConfig(provider.clientconfig);
}, [provider]);
const validate = useCallback((config) => {
const newErrors = {};
['authority', 'client_id', 'redirect_uri'].forEach(field => {
if (!config[field] || config[field].trim() === '') {
newErrors[field] = 'This field is required';
} else if (!isValidUrl(config[field])) {
newErrors[field] = 'Must be a valid URL';
}
});
setErrors(newErrors);
}, []);
const isValidUrl = (url) => {
try {
new URL(url);
return true;
} catch (_) {
return false;
}
};
const OIDC_RESPONSE_TYPES = [
{ value: "code", label: "Code" },
{ value: "id_token", label: "ID Token" },
{ value: "token id_token", label: "Token ID Token" },
{ value: "code id_token ", label: "code id_token " },
{ value: "code token", label: "code token" },
{ value: "code id_token token", label: "code id_token token" },
];
const OIDC_SCOPES = [
{ value: "openid", label: "OpenID" },
{ value: "profile", label: "Profile" },
{ value: "email", label: "Email" },
{ value: "address", label: "Address" },
{ value: "phone", label: "Phone" },
];
const handleInputChange = (event) => {
const updatedConfig = {
...config,
[event.target.name]: event.target.value,
};
validate(updatedConfig);
onChange({...provider, clientconfig: updatedConfig});
};
const handleScopeChange = (event) => {
const selectedValues = event.target.value;
const updatedConfig = {
...config,
scope: selectedValues.join(' '),
};
validate(updatedConfig);
onChange({...provider, clientconfig: updatedConfig});
};
return (
<div>
<Typography variant="h6">Настройки клиента</Typography>
<Grid container spacing={2} alignItems="center" sx={{ mt: 0.5 }}>
{['authority', 'client_id', 'redirect_uri'].map((field) => (
<Grid item xs={12} sm={6} key={field}>
<TextField
error={Boolean(errors[field])}
helperText={errors[field]}
label={field}
name={field}
value={config[field]}
onChange={handleInputChange}
fullWidth
/>
</Grid>
))}
</Grid>
<Grid container spacing={4} alignItems="center" sx={{ mt: 0.5 }}>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel htmlFor="response_type">Response Type</InputLabel>
<Select
value={config.response_type || ''}
onChange={handleInputChange}
inputProps={{ name: 'response_type', id: 'response_type' }}
>
{OIDC_RESPONSE_TYPES.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel htmlFor="scope">Scope</InputLabel>
<Select
value={config.scope ? config.scope.split(' ') : []}
onChange={handleScopeChange}
inputProps={{ name: 'scope', id: 'scope' }}
multiple={true}
>
{OIDC_SCOPES.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid>
</div>
);
}

View File

@ -0,0 +1,192 @@
import React, { useState, useEffect } from 'react';
import {
Typography,
TextField,
Grid,
Button,
Box,
} from '@mui/material';
import { styled } from '@mui/system';
import UploadIcon from '@mui/icons-material/Upload';
const InputImage = styled('input')({
display: 'none',
});
const ImagePreview = styled('img')({
maxHeight: '100px',
maxWidth: '100px',
});
export default function GeneralSettings({ provider, onChange, providers }) {
const [imagePreview, setImagePreview] = useState(provider.image);
const [errors, setErrors] = useState({});
useEffect(() => {
validate(provider);
}, [provider]);
const validate = (provider) => {
const newErrors = {};
if (!provider.name || provider.name.trim() === '') {
newErrors.name = 'Name is required';
} else if (
providers &&
providers.some((p) => p.name === provider.name && p !== provider)
) {
newErrors.name = 'Name must be unique';
}
if (!provider.title || provider.title.trim() === '') {
newErrors.title = 'Title is required';
}
if (provider.discovery && !isValidUrl(provider.discovery)) {
newErrors.discovery = 'Discovery must be a valid URL';
}
setErrors(newErrors);
};
const isValidUrl = (url) => {
try {
new URL(url);
return true;
} catch (_) {
return false;
}
};
const handleInputChange = (event) => {
const updatedProvider = {
...provider,
[event.target.name]: event.target.value,
};
validate(updatedProvider);
onChange(updatedProvider);
};
const handleImageUpload = (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
const base64 = reader.result;
setImagePreview(base64);
onChange({
...provider,
image: base64,
});
};
reader.readAsDataURL(file);
}
};
return (
<div>
<Grid container spacing={2} alignItems="center" sx={{ mt: 0.5 }}>
<Grid item xs={12} sm={6}>
<TextField
error={Boolean(errors.name)}
helperText={errors.name}
label="Имя провайдера"
name="name"
value={provider.name}
onChange={handleInputChange}
fullWidth
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
error={Boolean(errors.title)}
helperText={errors.title}
label="Title"
name="title"
value={provider.title}
onChange={handleInputChange}
fullWidth
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
select
label="Authentication User Property Name"
value={provider.authenticationUserPropertyName}
onChange={handleInputChange}
fullWidth
name="authenticationUserPropertyName"
SelectProps={{
native: true,
}}
>
{[
{
value: 'name',
label: 'Имя пользователя',
},
{
value: 'OSUser',
label: 'Пользователь ОС',
},
{
value: 'email',
label: 'Email пользователя ИБ',
},
{
value: 'matchingKey',
label: 'Ключ сопоставления пользователя',
}
].map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</TextField>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Authentication Claim Name"
name="authenticationClaimName"
value={provider.authenticationClaimName}
onChange={handleInputChange}
fullWidth
/>
</Grid>
<Grid item xs={12} sm={6}>
<Box>
<InputImage
accept="image/*"
id="contained-button-file"
type="file"
onChange={handleImageUpload}
/>
<label htmlFor="contained-button-file">
<Button component="span" color="inherit" startIcon={<UploadIcon />}>
Загрузить изображение
</Button>
</label>
</Box>
{imagePreview && (
<ImagePreview
src={imagePreview}
alt="Предпросмотр изображения"
/>
)}
</Grid>
<Grid item xs={12} sm={6}>
<TextField
error={Boolean(errors.discovery)}
helperText={errors.discovery}
label="Discovery"
name="discovery"
value={provider.discovery}
onChange={handleInputChange}
fullWidth
/>
</Grid>
</Grid>
</div>
);
}

View File

@ -0,0 +1,117 @@
import React, { useState, useEffect, useCallback } from 'react';
import { TextField, Typography, Grid, Select, MenuItem, InputLabel, FormControl } from '@mui/material';
export default function ProviderConfigSettings({ provider = {}, onChange }) {
const [providerConfig, setProviderConfig] = useState({});
const [errors, setErrors] = useState({});
useEffect(() => {
setProviderConfig(provider.providerconfig);
}, [provider]);
const validate = useCallback((config) => {
const newErrors = {};
['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_uri', 'userinfo_endpoint'].forEach(field => {
if (!config[field] || config[field].trim() === '') {
newErrors[field] = 'This field is required';
} else if (!isValidUrl(config[field])) {
newErrors[field] = 'Must be a valid URL';
}
});
setErrors(newErrors);
}, []);
const isValidUrl = (url) => {
try {
new URL(url);
return true;
} catch (_) {
return false;
}
};
const OIDC_RESPONSE_TYPES = [
{ value: "code", label: "Code" },
{ value: "id_token", label: "ID Token" },
{ value: "token id_token", label: "Token ID Token" },
{ value: "code id_token ", label: "code id_token " },
{ value: "code token", label: "code token" },
{ value: "code id_token token", label: "code id_token token" },
];
const OIDC_SCOPES = [
{ value: "openid", label: "OpenID" },
{ value: "profile", label: "Profile" },
{ value: "email", label: "Email" },
{ value: "address", label: "Address" },
{ value: "phone", label: "Phone" },
];
const handleInputChange = (event) => {
const updatedConfig = {
...providerConfig,
[event.target.name]: event.target.value,
};
validate(updatedConfig);
onChange({...provider, providerconfig: updatedConfig});
};
return (
<div>
<Typography variant="h6">Настройки провайдера</Typography>
<Grid container spacing={2} alignItems="center" sx={{ mt: 0.5 }}>
{['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_uri', 'userinfo_endpoint'].map((field) => (
<Grid item xs={12} sm={6} key={field}>
<TextField
error={Boolean(errors[field])}
helperText={errors[field]}
label={field}
name={field}
value={providerConfig[field]}
onChange={handleInputChange}
fullWidth
/>
</Grid>
))}
</Grid>
<Grid container spacing={4} alignItems="center" sx={{ mt: 0.5 }}>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel htmlFor="response_type">Response Type</InputLabel>
<Select
multiple
value={providerConfig.response_types_supported || []}
onChange={handleInputChange}
inputProps={{ name: 'response_types_supported', id: 'response_types_supported' }}
>
{OIDC_RESPONSE_TYPES.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel htmlFor="scope">Scope</InputLabel>
<Select
multiple
value={providerConfig.scopes_supported || []}
onChange={handleInputChange}
inputProps={{ name: 'scopes_supported', id: 'scopes_supported' }}
>
{OIDC_SCOPES.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid>
</div>
);
}

View File

@ -0,0 +1,75 @@
import React, { useState } from 'react';
import { Tabs, Tab, Grid, IconButton, Box } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import ConfirmationDialog from '../../../../ConfirmationDialog';
import GeneralSettings from './Components/GeneralSettings';
import ProviderSettingsTab from './Components/ProviderConfig';
import ClientSettingsTab from './Components/ClientConfig';
import { useMediaQuery } from '@mui/material';
import SettingsIcon from '@mui/icons-material/Settings';
import VpnKeyIcon from '@mui/icons-material/VpnKey';
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
export default function ProviderSettings({ provider, onChange, onDelete }) {
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const [tabValue, setTabValue] = React.useState(0);
const matches = useMediaQuery(theme => theme.breakpoints.down('md'));
const handleInputChange = (event) => {
onChange({
...provider,
[event.target.name]: event.target.value,
});
};
const handleDeleteConfirmationOpen = () => {
setConfirmationDialogOpen(true);
};
const handleDeleteConfirmationClose = (confirmed) => {
if (confirmed) {
onDelete();
}
setConfirmationDialogOpen(false);
};
const handleTabChange = (event, newValue) => {
setTabValue(newValue);
};
const handleProviderChange = (updatedProviderData) => {
// Обновите данные провайдера и передайте их в callback
const updatedProvider = { ...provider, ...updatedProviderData };
onChange(updatedProvider);
};
return (
<div>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12}>
<Box>
<Tabs value={tabValue} onChange={handleTabChange}>
<Tab icon={<SettingsIcon />} label={!matches && "Общие настройки"} />
<Tab icon={<VpnKeyIcon />} label={!matches && "Настройки провайдера"} />
<Tab icon={<AccountCircleIcon />} label={!matches && "Настройки клиента"} />
</Tabs>
{tabValue === 0 && <GeneralSettings provider={provider} onChange={handleProviderChange} />}
{tabValue === 1 && <ProviderSettingsTab provider={provider} onChange={handleProviderChange} />}
{tabValue === 2 && <ClientSettingsTab provider={provider} onChange={handleProviderChange} />}
</Box>
</Grid>
<Grid item xs={12}>
<IconButton onClick={handleDeleteConfirmationOpen} color="secondary">
<DeleteIcon />
</IconButton>
</Grid>
</Grid>
<ConfirmationDialog
open={confirmationDialogOpen}
title="Подтверждение удаления"
content="Вы уверены, что хотите удалить провайдер?"
onClose={handleDeleteConfirmationClose}
/>
</div>
);
}

View File

@ -0,0 +1,105 @@
import React, { useState, useEffect } from 'react';
import { Accordion, AccordionSummary, AccordionDetails, Typography, Button, Grid, Avatar } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ProviderSettings from './OidcProviders/ProviderSettings';
export default function OidcSettings({ publication, onChange }) {
const [providers, setProviders] = useState(publication?.oidc?.providers || []);
const [isProvidersUpdated, setIsProvidersUpdated] = useState(false);
useEffect(() => {
if (publication?.oidc?.providers) {
setProviders(publication.oidc.providers);
setIsProvidersUpdated(true);
}
}, [publication]);
const handleAddProvider = () => {
const newProvider = {
name: "",
title: "",
authenticationClaimName: "email",
authenticationUserPropertyName: "email ",
image: "",
discovery:"",
providerconfig: {
issuer: "",
authorization_endpoint: "",
token_endpoint: "",
jwks_uri: "",
userinfo_endpoint: "",
response_types_supported: ["token id_token"],
scopes_supported: [
"openid",
"email"
]
},
clientconfig: {
authority: "",
client_id: "",
redirect_uri: "",
response_type: "token id_token",
scope: "openid email"
}
};
setProviders([...providers, newProvider]);
setIsProvidersUpdated(true);
};
const handleProviderChange = (index, updatedProvider) => {
const newProviders = providers.slice();
newProviders[index] = updatedProvider;
setProviders(newProviders);
onChange({ ...publication, oidc: { ...publication.oidc, providers: newProviders } });
};
const handleProviderDelete = (index) => {
const newProviders = providers.filter((_, i) => i !== index);
setProviders(newProviders);
onChange({ ...publication, oidc: { ...publication.oidc, providers: newProviders } });
};
return (
<Grid container direction="column" spacing={2}>
{isProvidersUpdated &&
providers.map((provider, index) => (
<Grid item key={index}>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
{provider.image && (
<Avatar
src={provider.image}
alt={provider.name}
sx={{ marginRight: 1 }}
/>
)}
<Typography variant="h6" component="div">
{provider.name || 'Новый провайдер'}
</Typography>
</AccordionSummary>
<AccordionDetails>
<ProviderSettings
provider={provider}
onChange={(updatedProvider) => handleProviderChange(index, updatedProvider)}
onDelete={() => handleProviderDelete(index)}
/>
</AccordionDetails>
</Accordion>
</Grid>
))}
<Grid item>
<Button
variant="outlined"
color="primary"
onClick={handleAddProvider}
startIcon={<AddIcon />}
>
Добавить сервис
</Button>
</Grid>
</Grid>
);
}

View File

@ -0,0 +1,216 @@
import React, { useState } from "react";
import {
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Grid,
Switch,
FormControlLabel,
IconButton
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import ConfirmationDialog from "../../../../ConfirmationDialog"
export default function ServiceForm({ service = {}, onChange, onDelete, type }) {
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const [nameError, setNameError] = useState(false);
const [rootUrlError, setRootUrlError] = useState(false);
const [aliasError, setAliasError] = useState(false);
const handleInputChange = (event) => {
onChange({
...service,
[event.target.name]: event.target.value,
});
};
const handleDeleteConfirmationOpen = () => {
setConfirmationDialogOpen(true);
};
const handleDeleteConfirmationClose = (confirmed) => {
if (confirmed) {
onDelete(service);
}
setConfirmationDialogOpen(false);
};
const validateName = (event, value) => {
if (value.trim() === '') {
setNameError(true);
event.preventDefault();
return false;
} else {
setNameError(false);
return true;
}
};
const validateRootUrl = (event, value) => {
const pattern = /^[a-zA-Z0-9-_]+$/;
if (!pattern.test(value)) {
setRootUrlError(true);
event.preventDefault();
return false;
} else {
setRootUrlError(false);
return true;
}
};
const validateAlias = (event, value) => {
const pattern = /^[a-zA-Z0-9-_]+\.1cws$/;
if (!pattern.test(value)) {
setAliasError(true);
event.preventDefault();
return false;
} else {
setAliasError(false);
return true;
}
};
return (
<div>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} sm={9}>
<TextField
label="Название"
name="name"
value={service.name}
onChange={(event) => {
handleInputChange(event);
}}
onBlur={(event) => validateName(event,service.name)}
error={nameError}
helperText={nameError ? "Поле обязательно для заполнения" : ""}
fullWidth
required
/>
</Grid>
<Grid item xs={12} sm={3}>
<FormControlLabel control={<Switch
edge="start"
checked={service.enable}
onChange={handleInputChange}
/>} label="Включен" />
</Grid>
{type === 'http' && (
<Grid item xs={12}>
<TextField
label="Корневой URL"
name="rootUrl"
value={service.rootUrl}
onChange={(event) => {
handleInputChange(event);
}}
onBlur={(event) => validateRootUrl(event,service.rootUrl)}
error={rootUrlError}
helperText={rootUrlError ? "Введите корректный URL" : ""}
fullWidth
required
/>
</Grid>
)}
{type === 'web' && (
<Grid item xs={12}>
<TextField
label="Alias"
name="alias"
value={service.alias}
onChange={(event) => {
handleInputChange(event);
}}
onBlur={(event) => validateAlias(event, service.alias)}
error={aliasError}
helperText={aliasError ? "Введите корректный Alias с расширением .1cws" : ""}
fullWidth
required
/>
</Grid>
)}
<Grid item xs={12} sm={6} md={3}>
<TextField
label="Pool Size"
name="poolSize"
type="number"
value={service.poolSize}
onChange={handleInputChange}
fullWidth
/>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<TextField
label="Pool Timeout"
name="poolTimeout"
type="number"
value={service.poolTimeout}
onChange={handleInputChange}
fullWidth
/>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<TextField
label="Session Max Age"
name="sessionMaxAge"
type="number"
value={service.sessionMaxAge}
onChange={handleInputChange}
fullWidth
/>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<TextField
select
label="Reuse Sessions"
value={service.reuseSessions}
onChange={handleInputChange}
fullWidth
name="reuseSessions"
SelectProps={{
native: true,
}}
>
{[
{
value: 'dontuse',
label: 'Не использовать',
},
{
value: 'use',
label: 'Использовать',
},
{
value: 'autouse',
label: 'Авто',
}
].map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</TextField>
</Grid>
<Grid item xs={12}>
<IconButton onClick={handleDeleteConfirmationOpen} color="secondary">
<DeleteIcon />
</IconButton>
</Grid>
</Grid>
<ConfirmationDialog
open={confirmationDialogOpen}
title="Подтверждение удаления"
content="Вы уверены, что хотите удалить сервис?"
onClose={handleDeleteConfirmationClose}
/>
</div>
);
}

View File

@ -0,0 +1,103 @@
import React, { useState,useEffect } from "react";
import {
Accordion,
AccordionSummary,
AccordionDetails,
Typography,
Button,
Grid,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import AddIcon from "@mui/icons-material/Add";
import ServiceForm from "./ServiceForm";
export default function ServicesArray({ servicesList, onChange, type }) {
const [services, setServices] = useState(servicesList || []);
const handleAddService = () => {
setServices([
...services, type='http'?
{
name: "",
rootUrl: "",
enable: true,
reuseSessions: "dontuse",
sessionMaxAge: 20,
poolSize: 10,
poolTimeout: 5,
}: {
name: "",
alias: "",
enable: true,
reuseSessions: "dontuse",
sessionMaxAge: 20,
poolSize: 10,
poolTimeout: 5,
},
]);
};
const [isServicesUpdated, setIsServicesUpdated] = useState(false);
useEffect(() => {
if (servicesList) {
setServices(servicesList);
setIsServicesUpdated(true);
}
}, [servicesList]);
const handleServiceChange = (index, updatedService) => {
const newServices = services.slice();
newServices[index] = updatedService;
setServices(newServices);
onChange(newServices);
};
const handleServiceDelete = (index) => {
const newServices = services.filter((_, i) => i !== index);
setServices(newServices);
onChange(newServices);
};
return (
<Grid container direction="column" spacing={2}>
{isServicesUpdated && services.map((service, index) => (
<Grid item key={index}>
<Accordion>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
>
<Grid container alignItems="center" spacing={2}>
<Grid item xs>
<Typography variant="h6" component="div">
{service.name || "Новый сервис"}
</Typography>
</Grid>
</Grid>
</AccordionSummary>
<AccordionDetails>
<ServiceForm
type = {type}
service={service}
onChange={(updatedService) =>
handleServiceChange(index, updatedService)
}
onDelete={() => handleServiceDelete(index)}
/>
</AccordionDetails>
</Accordion>
</Grid>
))}
<Grid item>
<Button
variant="outlined"
color="primary"
onClick={handleAddService}
startIcon={<AddIcon />}
>
Добавить сервис
</Button>
</Grid>
</Grid>
);
}

View File

@ -0,0 +1,64 @@
import React, { useEffect } from 'react';
import { FormControlLabel, Switch, Divider } from '@mui/material';
import ServicesArray from "./ServicesArray/ServicesArray";
export default function WebSettings({ publication, onChange, onValidChange }) {
const handleSwitchChange = (event) => {
onChange({
...publication,
ws: {
...publication.ws,
[event.target.name]: event.target.checked,
},
});
};
useEffect(() => {
const isServicesValid = !publication.ws.wsList ||
publication.ws.wsList.length === 0 ||
publication.ws.wsList.every(
(service) => {
return (
service.name.trim() !== "" &&
/^[a-zA-Z0-9-_]+\.1cws$/.test(
service.alias
)
);
}
);
onValidChange(isServicesValid);
}, [publication.ws.wsList, onValidChange]);
const handleServicesChange = (newServices) => {
onChange({
...publication,
ws: {
...publication.ws,
wsList: newServices,
},
});
};
return (
<div>
<FormControlLabel
control={
<Switch
checked={publication.ws.publishExtensionsByDefault}
onChange={handleSwitchChange}
name="publishExtensionsByDefault"
/>
}
label="Публиковать web-сервисы расширений по умолчанию"
/>
<Divider />
<div>Web-сервисы</div>
<ServicesArray
type="web"
servicesList={publication.ws.wsList}
onChange={handleServicesChange}
/>
</div>
);
}

View File

@ -0,0 +1,220 @@
import React, { useState, useEffect, useContext } from "react";
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Tab, Tabs, Tooltip, Grid, IconButton, useMediaQuery, useTheme } from "@mui/material";
import GeneralSettings from "./PropertiesParts/GeneralSettings";
import HttpSettings from "./PropertiesParts/HttpSettings";
import WebSettings from "./PropertiesParts/WebSettings";
import OidcSettings from "./PropertiesParts/OidcSettings";
import AppContext from '../../../context/AppContext';
import DeleteIcon from "@mui/icons-material/Delete";
import ConfirmationDialog from "../../ConfirmationDialog";
import SettingsIcon from "@mui/icons-material/Settings";
import HttpIcon from "@mui/icons-material/Http";
import WebIcon from "@mui/icons-material/Web";
import OidcIcon from "@mui/icons-material/VerifiedUser";
export default function PublicationPropertiesEdit({ open, publication, onClose }) {
const [tabIndex, setTabIndex] = React.useState(0);
const [newPublication, setNewPublication] = useState({
name: "",
title: "",
usr: "",
pwd: "",
active: true,
enable: true,
enableStandardOData: false,
httpServices: {
publishExtensionsByDefault: false,
publishByDefault: false,
hsList:[]
},
ws: {
publishExtensionsByDefault: false,
wsList: []
}
});
const [update, setUpdate] = useState(false);
const [isGeneralSettingsValid, setIsGeneralSettingsValid] = useState(false);
const [isHttpSettingsValid, setIsHttpSettingsValid] = useState(false);
const [isWebSettingsValid, setIsWebSettingsValid] = useState(true);
const [isAllSettingsValid, setIsAllSettingsValid] = useState(true);
const { getUniqueTitles, getUniqueEndpoints, addPublication, updatePublication,deletePublication } = useContext(AppContext);
const uniqueTitles = getUniqueTitles() || [];
const uniqueEndpoints = getUniqueEndpoints() || [];
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const theme = useTheme();
const isLargeScreen = useMediaQuery(theme.breakpoints.up('md'));
useEffect(() => {
if (open) {
setTabIndex(0);
if (!publication) {
setNewPublication(
{
id: "",
name: "",
title: "",
usr: "",
pwd: "",
active: true,
enable: true,
enableStandardOData: false,
httpServices: {
publishExtensionsByDefault: false,
publishByDefault: false,
hsList: []
},
ws: {
publishExtensionsByDefault: false,
wsList:[]
},
});
setUpdate(false);
} else {
setNewPublication(publication);
setUpdate(true);
}
}
}, [open]);
useEffect(() => {
setIsAllSettingsValid(isHttpSettingsValid && isWebSettingsValid && isGeneralSettingsValid);
}, [isHttpSettingsValid, isWebSettingsValid, isGeneralSettingsValid]);
useEffect(() => {
handleGeneralSettingsValidChange(
validateTitle(newPublication.title) && validateName(newPublication.name)
);
}, [newPublication]);
const handleTabChange = (event, newValue) => {
setTabIndex(newValue);
};
const handleChange = (updatedPublication) => {
setNewPublication(updatedPublication);
};
const handleGeneralSettingsValidChange = (isValid) => {
setIsGeneralSettingsValid(isValid);
};
const handleHttpSettingsValidChange = (isValid) => {
setIsHttpSettingsValid(isValid);
};
const handleWebSettingsValidChange = (isValid) => {
setIsWebSettingsValid(isValid);
};
const validateTitle = (title) => {
const otherUniqueTitles = uniqueTitles.filter((t) => t !== newPublication?.title);
return title && !otherUniqueTitles.includes(title);
};
const validateName = (name) => {
const urlPattern = /^[a-zA-Z0-9-_]+$/;
const otherUniqueEndpoints = uniqueEndpoints.filter((e) => e !== newPublication?.name);
return name && !otherUniqueEndpoints.includes(name) && urlPattern.test(name);
};
const handleSave = () => {
if (update) {
updatePublication(publication.id, newPublication);
} else {
addPublication(newPublication);
}
onClose();
};
const handleDeleteConfirmationOpen = () => {
setConfirmationDialogOpen(true);
};
const handleDeleteConfirmationClose = (confirmed) => {
if (confirmed) {
deletePublication(publication);
onClose(null);
}
setConfirmationDialogOpen(false);
};
return (
<div>
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<DialogTitle>{update ? "Редактирование публикации: " + newPublication.title : "Создать новую публикацию"}</DialogTitle>
<DialogContent>
<Tabs value={tabIndex} onChange={handleTabChange}>
<Tab icon={<SettingsIcon />} label={isLargeScreen ? "Основные настройки" : ""} />
<Tab icon={<HttpIcon />} label={isLargeScreen ? "Http-сервисы" : ""} />
<Tab icon={<WebIcon />} label={isLargeScreen ? "Web-сервисы" : ""} />
<Tab icon={<OidcIcon />} label={isLargeScreen ? "Настройки OpenID Connect" : ""} />
</Tabs>
{newPublication && (
<>
{tabIndex === 0 && <GeneralSettings publication={newPublication}
onChange={handleChange}
onValidChange={handleGeneralSettingsValidChange}
validateTitle={validateTitle}
validateName={validateName}
/>}
{tabIndex === 1 && <HttpSettings publication={newPublication}
onChange={handleChange}
onValidChange={handleHttpSettingsValidChange}/>}
{tabIndex === 2 && <WebSettings publication={newPublication}
onChange={handleChange}
onValidChange={setIsWebSettingsValid}/>}
{tabIndex === 3 && <OidcSettings publication={newPublication}
onChange={handleChange} />}
</>
)}
</DialogContent>
<DialogActions>
<Grid container justifyContent="space-between" alignItems="flex-start">
{update && (
<Grid item>
<IconButton onClick={handleDeleteConfirmationOpen} color="secondary">
<DeleteIcon />
</IconButton>
</Grid>
)}
<Grid item xs style={{ flexGrow: 1 }} />
<Grid item>
<Button onClick={onClose}>Отмена</Button>
</Grid>
<Grid item>
<Tooltip
title={
isAllSettingsValid
? ""
: "Пожалуйста, исправьте ошибки перед сохранением"
}
>
<span>
<Button
onClick={handleSave}
color="primary"
disabled={!isAllSettingsValid}
>
Сохранить
</Button>
</span>
</Tooltip>
</Grid>
</Grid>
</DialogActions >
</Dialog >
<ConfirmationDialog
open={confirmationDialogOpen}
title="Подтверждение удаления"
content="Вы уверены, что хотите удалить эту публикацию?"
onClose={handleDeleteConfirmationClose}
/>
</div>
);
}

View File

@ -0,0 +1,114 @@
import React, { useContext, useState } from "react";
import { Box, Card, CardContent, Typography, CardActions, IconButton, Switch, Fab, Grid, useMediaQuery, useTheme, Link } from "@mui/material";
import EditIcon from "@mui/icons-material/Edit";
import AddIcon from "@mui/icons-material/Add";
import LinkIcon from "@mui/icons-material/Link";
import AppContext from "../../context/AppContext";
import PublicationPropertiesEdit from "./PublicationPropertiesEdit/PublicationPropertiesEdit";
export default function PublicationsInfo() {
const { state, updatePublication } = useContext(AppContext);
const selectedBase = state.bases.find((base) => base.id === state.selectedBaseId);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [editingPublication, setEditingPublication] = useState(null);
const theme = useTheme();
const isMdDown = useMediaQuery(theme.breakpoints.down("md"));
const createPublicationUrl = (baseUrl, publicationName) => {
const cleanedBaseUrl = baseUrl.replace(/\/+$/, ""); // убираем слэши в конце строки
const cleanedPublicationName = publicationName.replace(/^\/+/, ""); // убираем слэши в начале строки
return `${cleanedBaseUrl}/${cleanedPublicationName}`;
};
const handlePublicationToggle = (publication) => {
const updatedPublication = {
...publication,
active: !publication.active,
};
updatePublication(publication.id, updatedPublication);
};
const handleEditDialogOpen = (publication) => {
setEditingPublication(publication);
setEditDialogOpen(true);
};
const handleEditDialogClose = (publication) => {
setEditDialogOpen(false);
setEditingPublication(null);
};
if (!selectedBase) {
return <Typography variant="h6">Выберите базу данных</Typography>;
}
return (
<Box>
<Grid container spacing={2}>
<Grid item xs={12}>
<Fab
variant={isMdDown ? "circular" : "extended"}
color="primary"
onClick={() => handleEditDialogOpen(null)}
sx={{
position: "fixed",
top: 72,
right: 24,
}}
>
<AddIcon />
{!isMdDown && "Новая публикация"}
</Fab>
</Grid>
{selectedBase.publications.map((publication) => (
<Grid item key={publication.id} xs={12} sm={6} md={6} lg={4} sx={{ marginTop: theme.spacing(5) }}>
<Card key={publication.id} sx={{ maxWidth: 400, mb: 2 }}>
<CardContent>
<Typography variant="h5" component="div">
{publication.title}
</Typography>
<Typography variant="subtitle1" color="text.secondary">
<LinkIcon sx={{ verticalAlign: "bottom", mr: 0.5 }} />
{state.settings && state.settings.publicationServerUrl && publication.active ? (
<Link
href={createPublicationUrl(state.settings.publicationServerUrl, publication.name)}
target="_blank"
rel="noopener noreferrer"
>
Ссылка на публикацию
</Link>
) : (
<span style={{ color: "rgba(0, 0, 0, 0.38)" }}>Ссылка на публикацию</span>
)}
</Typography>
</CardContent>
<CardActions>
<Box display="flex" justifyContent="space-between" alignItems="center" width="100%">
<Switch
checked={publication.active}
onChange={() => handlePublicationToggle(publication)}
inputProps={{ "aria-label": "controlled" }}
/>
<IconButton
edge="end"
color="inherit"
aria-label="edit"
onClick={() => handleEditDialogOpen(publication)}
sx={{ mr: 0.1 }}
>
<EditIcon />
</IconButton>
</Box>
</CardActions>
</Card>
</Grid>
))}
</Grid>
<PublicationPropertiesEdit
open={editDialogOpen}
publication={editingPublication}
onClose={handleEditDialogClose}
/>
</Box>
);
}

View File

@ -0,0 +1,45 @@
import React, { useCallback, useEffect } from "react";
import { IconButton } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import { useSnackbar } from "notistack";
const SnackbarHandler = ({ snackbar, closeSnackbar }) => {
const { enqueueSnackbar, closeSnackbar: dequeueSnackbar } = useSnackbar();
const action = useCallback(
(key) => (
<IconButton
size="small"
aria-label="close"
color="inherit"
onClick={() => {
closeSnackbar(key);
dequeueSnackbar(key);
}}
>
<CloseIcon fontSize="small" />
</IconButton>
),
[closeSnackbar, dequeueSnackbar]
);
useEffect(() => {
if (snackbar) {
const key = enqueueSnackbar(snackbar.message, {
variant: snackbar.severity,
autoHideDuration: 6000,
anchorOrigin: {
vertical: "bottom",
horizontal: "right",
},
action: (key) => action(key),
});
closeSnackbar(key);
}
}, [snackbar, closeSnackbar, enqueueSnackbar, action]);
return null;
};
export default SnackbarHandler;

View File

@ -0,0 +1,103 @@
// AppBarMenu.js
import React, { useState, useContext } from "react";
import { AppBar, Toolbar, IconButton, Button, Menu, Box, Typography, Hidden, Tooltip } from "@mui/material";
import MenuIcon from "@mui/icons-material/Menu";
import SettingsIcon from "@mui/icons-material/Settings";
import SettingsModal from "./components/SettingsModal";
import InfoModal from "./components/InfoModal";
import DropdownMenu from "./components/DropdownMenu";
import SettingsEthernetIcon from "@mui/icons-material/SettingsEthernet";
import InfoIcon from "@mui/icons-material/Info";
import Brightness4Icon from "@mui/icons-material/Brightness4";
import Brightness7Icon from "@mui/icons-material/Brightness7";
import ThemeContext from ".//../../ThemeContext";
const AppBarMenu = ({ handleDrawerToggle }) => {
const [settingsOpen, setSettingsOpen] = useState(false);
const [infoModalOpen, setInfoModalOpen] = useState(false);
const [serverControlAnchorEl, setServerControlAnchorEl] = useState(null);
const { darkMode, toggleDarkMode } = useContext(ThemeContext);
const handleSettingsOpen = (event) => {
setSettingsOpen(true);
};
const handleSettingsClose = () => {
setSettingsOpen(false);
};
const handleServerControlClick = (event) => {
setServerControlAnchorEl(event.currentTarget);
};
const handleServerControlClose = () => {
setServerControlAnchorEl(null);
};
const handleInfoModalOpen = () => {
setInfoModalOpen(true);
};
const handleInfoModalClose = () => {
setInfoModalOpen(false);
};
return (
<AppBar position="static" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1, Height: (theme) => theme.mixins.toolbar.minHeight }}>
<Toolbar>
<Box
sx={{
minHeight: (theme) => theme.mixins.toolbar.minHeight,
display: "flex",
alignItems: "center",
width: "100%",
}}
>
<IconButton
edge="start"
color="inherit"
aria-label="menu"
onClick={handleDrawerToggle}
sx={{ display: { sm: "block", md: "none" }, }}
>
<MenuIcon />
</IconButton>
{darkMode ?
<img src="/logo_dark.png" alt="Logo" style={{ maxHeight: '55px', marginRight: '8px' }} /> :
<img src="/logo.png" alt="Logo" style={{ maxHeight: '55px', marginRight: '8px' }} />
}
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
Публикатор 1с.
</Typography>
<Box sx={{ flexGrow: 1 }} />
<Button color="inherit" onClick={handleServerControlClick} startIcon={<SettingsEthernetIcon />}>
<Hidden mdDown>Управление веб-сервером</Hidden>
</Button>
<Menu
anchorEl={serverControlAnchorEl}
open={Boolean(serverControlAnchorEl)}
onClose={handleServerControlClose}
>
<DropdownMenu />
</Menu>
<Button color="inherit" onClick={handleSettingsOpen} startIcon={<SettingsIcon />}>
<Hidden mdDown>Настройки</Hidden>
</Button>
<Button color="inherit" onClick={handleInfoModalOpen} startIcon={<InfoIcon />}>
<Hidden mdDown>Информация</Hidden>
</Button>
<Tooltip title={darkMode ? "Светлая тема" : "Темная тема"}>
<IconButton onClick={toggleDarkMode} color="inherit">
{darkMode ? <Brightness7Icon /> : <Brightness4Icon />}
</IconButton>
</Tooltip>
<SettingsModal open={settingsOpen} onClose={handleSettingsClose} />
<InfoModal open={infoModalOpen} onClose={handleInfoModalClose} />
</Box>
</Toolbar>
</AppBar>
);
};
export default AppBarMenu;

View File

@ -0,0 +1,31 @@
// components/DropdownMenu.js
import React, { useContext } from "react";
import { MenuItem } from "@mui/material";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import StopIcon from "@mui/icons-material/Stop";
import RefreshIcon from "@mui/icons-material/Refresh";
import AppContext from "../../../context/AppContext";
import { startServer, stopServer, restartServer } from "../../../ApiProcessor";
const DropdownMenu = () => {
const { showSnackbar } = useContext(AppContext);
return (
<>
<MenuItem onClick={() => startServer(showSnackbar)}>
<PlayArrowIcon />
Старт сервера
</MenuItem>
<MenuItem onClick={() => stopServer(showSnackbar)}>
<StopIcon />
Стоп сервера
</MenuItem>
<MenuItem onClick={() => restartServer(showSnackbar)}>
<RefreshIcon />
Рестарт сервера
</MenuItem>
</>
);
};
export default DropdownMenu;

View File

@ -0,0 +1,43 @@
import * as React from 'react';
import { Typography, Button, Dialog, DialogTitle, DialogContent, DialogActions, Box, Link } from '@mui/material';
import TelegramIcon from '@mui/icons-material/Telegram';
import GitHubIcon from '@mui/icons-material/GitHub';
function ProjectInfoForm({ open, onClose }) {
const projectName = 'Публикатор 1С';
const projectVersion = '1.0.0';
const projectDescription = 'Проект преднозначен для удобной работы с публикациями на Веб-сервере. Код полностью открыт и доступен! \n Присоединяйтесь в катанл в телеграм, чтобы не пропутсить критичные обновления.';
const telegramUrl = 'https://t.me/DevOps_onec';
const githubUrl = 'https://github.com/Segate-ekb/publicator';
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Информация о проекте</DialogTitle>
<DialogContent>
<Box display="flex" alignItems="center" justifyContent="center" mb={2}>
<img src="/logo.png" alt="Logo" style={{ maxHeight: '80px', marginRight: '8px' }} />
<Typography variant="h5">{projectName}</Typography>
</Box>
<Typography variant="subtitle1">Версия: {projectVersion}</Typography>
<Typography variant="body1" mt={2}>{projectDescription}</Typography>
<Box mt={2}>
<Typography variant="subtitle2">Ссылки:</Typography>
<Box display="flex" alignItems="center">
<Link href={telegramUrl} target="_blank" rel="noopener noreferrer" sx={{ marginRight: 2 }}>
<TelegramIcon color="primary" fontSize="large" />
</Link>
<Link href={githubUrl} target="_blank" rel="noopener noreferrer">
<GitHubIcon color="action" fontSize="large" />
</Link>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Ок</Button>
</DialogActions>
</Dialog>
);
}
export default ProjectInfoForm;

View File

@ -0,0 +1,53 @@
import React, { useContext, useState, useEffect } from "react";
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField } from "@mui/material";
import AppContext from "../../../context/AppContext";
const SettingsModal = ({ open, onClose }) => {
const { state, updateSettings } = useContext(AppContext);
const [settings, setSettings] = useState(state.settings);
const handleChange = (e) => {
setSettings({ ...settings, [e.target.name]: e.target.value });
};
const handleSave = () => {
updateSettings(settings);
onClose();
};
useEffect(() => {
setSettings(state.settings);
}, [state.settings]);
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
aria-labelledby="settings-dialog-title"
>
<DialogTitle id="settings-dialog-title">Настройки</DialogTitle>
<DialogContent>
<TextField
label="Адрес сервера публикации"
name="publicationServerUrl"
value={settings.publicationServerUrl}
onChange={handleChange}
fullWidth
style={{ marginTop: '16px' }}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="primary">
Закрыть
</Button>
<Button onClick={handleSave} color="primary">
Сохранить
</Button>
</DialogActions>
</Dialog>
);
};
export default SettingsModal;

View File

@ -0,0 +1,170 @@
import React, { useContext, useState } from "react";
import {
Box,
Drawer,
Hidden,
List,
ListItem,
IconButton,
ListItemButton,
ListItemText,
Fab,
CssBaseline
} from "@mui/material";
import { styled } from "@mui/system";
import AddIcon from "@mui/icons-material/Add";
import EditIcon from "@mui/icons-material/Edit";
import AppContext from "../../context/AppContext";
import BaseSettingsEdit from "./components/BaseSettingsEdit";
const drawerWidth = 360;
const AddFab = styled(Fab)(({ theme }) => ({
position: "absolute",
bottom: theme.spacing(9),
right: theme.spacing(2),
}));
export default function BasesMenu(props) {
const { window } = props;
const {
state,
addBase,
updateBase,
deleteBase,
showSnackbar,
setSelectedBaseId,
} = useContext(AppContext);
const { handleDrawerToggle, mobileOpen } = props;
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [editingBase, setEditingBase] = useState(null);
const container = window !== undefined ? () => window().document.body : undefined;
const handleEditDialogOpen = (base) => {
setEditingBase(base);
setEditDialogOpen(true);
};
const handleEditDialogClose = (base) => {
setEditDialogOpen(false);
if (base) {
if (editingBase) {
updateBase(base);
showSnackbar("База успешно обновлена", "success");
} else {
addBase(base);
showSnackbar("База успешно добавлена", "success");
}
}
setEditingBase(null);
};
const handleListItemClick = (event, id) => {
setSelectedBaseId(id);
};
const [drawerOpen, setDrawerOpen] = React.useState(false);
const toggleDrawer = (open) => (event) => {
if (
event.type === 'keydown' &&
(event.key === 'Tab' || event.key === 'Shift')
) {
return;
}
setDrawerOpen(open);
};
const drawerContent = (
<Box>
<List>
{state.bases.length === 0 ? (
<ListItem secondaryAction={<IconButton
edge="end"
aria-label="new"
onClick={() => handleEditDialogOpen(null)}
>
<AddIcon />
</IconButton>
}
disablePadding
>
<ListItemText primary="Добаьте первую базу." style={{ marginLeft: "20px" }} />
</ListItem>
) : (
state.bases.map((base) => (
<ListItem
key={base.id}
secondaryAction={
<IconButton
edge="end"
aria-label="edit"
onClick={() => handleEditDialogOpen(base)}
>
<EditIcon />
</IconButton>
}
disablePadding
>
<ListItemButton
selected={state.selectedBaseId === base.id}
onClick={(event) => handleListItemClick(event, base.id)}
>
<ListItemText primary={base.name} />
</ListItemButton>
</ListItem>
))
)}
</List>
<AddFab color="primary" onClick={() => handleEditDialogOpen()}>
<AddIcon />
</AddFab>
</Box>
);
return (
<div>
<CssBaseline />
<Drawer
container={container}
variant="temporary"
open={mobileOpen}
onClose={handleDrawerToggle}
ModalProps={{
keepMounted: true,
}}
sx={{
display: { xs: "block", sm: "block", md: "none" },
width: drawerWidth,
marginTop: 6,
flexShrink: 0,
[`& .MuiDrawer-paper`]: { width: drawerWidth, marginTop: 8, boxSizing: 'border-box' },
}}
>
{drawerContent}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: { xs: "none", sm: "none", md: "block" },
width: drawerWidth,
flexShrink: 0,
[`& .MuiDrawer-paper`]: { width: drawerWidth, marginTop: 8, boxSizing: 'border-box' },
}}
open
>
{drawerContent}
</Drawer>
<BaseSettingsEdit
open={editDialogOpen}
base={editingBase}
onClose={handleEditDialogClose}
deleteBase={deleteBase}
/>
</div>
);
}

View File

@ -0,0 +1,230 @@
import React, { useState, useEffect, useContext } from "react";
import { IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Button, Grid, Tooltip } from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import ConfirmationDialog from "../../ConfirmationDialog"
import AppContext from "../../../context/AppContext";
export default function BaseSettingsEdit({
open,
base,
onClose,
deleteBase
}) {
const [newBase, setNewBase] = useState({name: "", srvr: "", ref: "" });
const [errors, setErrors] = useState({ name: "", srvr: "", ref: "" });
const [update, setUpdate] = useState(false);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const { state } = useContext(AppContext);
const [isGeneralSettingsValid, setIsGeneralSettingsValid] = useState(false);
useEffect(() => {
if (open) {
if (!base) {
setNewBase({name: "", srvr: "", ref: "" });
setUpdate(false);
} else {
setNewBase(base);
setUpdate(true);
};
}
}, [open]);
// Добавьте новый useEffect с зависимостью от состояния newPublication
useEffect(() => {
const nameError = validateName(newBase.name);
const srvrRefError = validateSrvrRef(newBase.ref, newBase.srvr);
const srvrError = validateServer(newBase.srvr);
const refError = validateRef(newBase.ref);
setErrors({ ...errors, name: nameError, srvr: !srvrError ? srvrRefError : srvrError, ref: !refError ? srvrRefError : srvrError});
handleGeneralSettingsValidChange(
!nameError && !srvrError && !refError
);
}, [newBase]);
const handleGeneralSettingsValidChange = (isValid) => {
setIsGeneralSettingsValid(isValid);
};
const handleSave = () => {
onClose(newBase);
};
const handleChange = (event) => {
const { name, value } = event.target;
setNewBase({ ...newBase, [name]: value });
};
const handleDeleteConfirmationOpen = () => {
setConfirmationDialogOpen(true);
};
const handleDeleteConfirmationClose = (confirmed) => {
if (confirmed) {
deleteBase(newBase);
onClose(null);
}
setConfirmationDialogOpen(false);
};
const isValidCharacters = (str) => {
const regex = /^[a-zA-Z0-9-_]+$/;
return regex.test(str);
};
const validateBase = (base, field) => {
if (field) {
if (field === "name") {
return base[field].trim().length > 0 && isNameUnique(base[field]);
} else if (field === "ref" || field === "srvr") {
return base[field].trim().length > 0 && isRefSrvrUnique(base.ref, base.srvr);
}
} else {
return (
base.name.trim().length > 0 &&
base.srvr.trim().length > 0 &&
base.ref.trim().length > 0 &&
isNameUnique(base.name) &&
isRefSrvrUnique(base.ref, base.srvr)
);
}
};
const isNameUnique = (name) => {
return !state.bases.some((b) => (!base || b.id !== base.id) && b.name === name);
};
const isRefSrvrUnique = (ref, srvr) => {
return !state.bases.some((b) => (!base || b.id !== base.id) && b.ref === ref && b.srvr === srvr);
};
const validateName = (name) => {
if (!isNameUnique(name)) {
return "Введите уникальное название базы";
}
return "";
};
const validateSrvrRef = (ref, srvr) => {
if (!isRefSrvrUnique(ref, srvr)) {
return "Введите уникальное сочетание сервера и имени базы на сервере";
}
return "";
};
const validateServer = (srvr) => {
if (!isValidCharacters(srvr)) {
return "Сервер может содержать только латиницу, цифры и спецсимволы uri";
}
return "";
};
const validateRef = (ref) => {
if (!isValidCharacters(ref)) {
return "Имя базы на сервере может содержать только латиницу, цифры и спецсимволы uri";
}
return "";
};
return (
<div>
<Dialog open={open} onClose={() => onClose(null)}>
<DialogTitle>{update ? "Редактировать базу" : "Добавить базу"}</DialogTitle>
<DialogContent>
<TextField
label="Название базы"
name="name"
value={newBase.name}
onChange={handleChange}
fullWidth
margin="dense"
required
onBlur={() => setErrors({ ...errors, name: validateName(newBase.name) })}
error={!validateBase(newBase, "name")}
helperText={errors.name}
/>
<TextField
label="Сервер"
name="srvr"
value={newBase.srvr}
onChange={handleChange}
fullWidth
margin="dense"
required
onBlur={() =>
setErrors({ ...errors, srvr: validateServer(newBase.srvr), ref: validateSrvrRef(newBase.ref, newBase.srvr) })}
error={!validateBase(newBase, "srvr")}
helperText={errors.srvr}
/>
<TextField
label="Имя базы на сервере"
name="ref"
value={newBase.ref}
onChange={handleChange}
fullWidth
margin="dense"
required
onBlur={() =>
setErrors({ ...errors, srvr: validateRef(newBase.ref), ref: validateSrvrRef(newBase.ref, newBase.srvr) })}
error={!validateBase(newBase, "ref")}
helperText={errors.ref}
/>
</DialogContent>
<DialogActions>
<Grid container justifyContent="space-between" alignItems="flex-start">
{update && (
<Grid item>
<IconButton onClick={handleDeleteConfirmationOpen} color="secondary">
<DeleteIcon />
</IconButton>
</Grid>
)}
<Grid item xs style={{ flexGrow: 1 }} />
<Grid item>
<Button onClick={() => onClose(null)} color="primary">
Отмена
</Button>
</Grid>
<Grid item>
<Tooltip
title={
isGeneralSettingsValid
? ""
: "Пожалуйста, исправьте ошибки перед сохранением"
}
>
<span>
<Button
onClick={handleSave}
color="primary"
disabled={!isGeneralSettingsValid}
>
Сохранить
</Button>
</span>
</Tooltip>
</Grid>
</Grid>
</DialogActions>
</Dialog>
<ConfirmationDialog
open={confirmationDialogOpen}
title="Подтверждение удаления"
content="Вы уверены, что хотите удалить эту базу?"
onClose={handleDeleteConfirmationClose}
/>
</div>
);
}

View File

@ -0,0 +1,7 @@
// AppContext.js
import { createContext } from 'react';
const AppContext = createContext();
export default AppContext;

View File

@ -0,0 +1,275 @@
import React, { useReducer, useEffect, useState } from "react";
import AppContext from "./AppContext";
import * as ApiProcessor from "../ApiProcessor"
import { useSnackbar } from "notistack";
import { v4 as uuidv4 } from "uuid";
const AppContextProvider = ({ children }) => {
const initialState = {
bases: [],
selectedBaseId: null,
settings: {},
};
const [baseUpdated, setBaseUpdated] = useState(false);
const [settingsUpdated, setSettingsUpdated] = useState(false);
function appReducer(state, action) {
switch (action.type) {
case "ADD_BASES":
return { ...state, bases: action.payload };
case "ADD_BASE":
return { ...state, bases: [...state.bases, action.payload] };
case "UPDATE_BASE":
return {
...state,
bases: updateItem(state.bases, action.payload.id, () => action.payload),
};
case 'DELETE_BASE':
return {
...state,
bases: state.bases.filter(base => base.id !== action.payload),
};
case "UPDATE_PUBLICATION":
const { publicationId, updatedPublication } = action.payload;
return {
...state,
bases: updateItem(
state.bases,
state.selectedBaseId,
(base) => ({
...base,
publications: updateItem(
[...base.publications],
publicationId,
() => updatedPublication
),
}),
"id"
),
};
case "ADD_PUBLICATION":
const { newPublication } = action.payload;
return {
...state,
bases: updateItem(
state.bases,
state.selectedBaseId,
(base) => ({
...base,
publications: [...base.publications, newPublication],
}),
"id"
),
};
case "DELETE_PUBLICATION":
const deletedPublicationId = action.payload;
return {
...state,
bases: updateItem
(state.bases, state.selectedBaseId, (base) => {
console.log(base.publications);
console.log(deletedPublicationId);
const newPublications = base.publications.filter((publication) => publication.id !== deletedPublicationId);
console.log(newPublications);
return { ...base, publications: newPublications };
}, "id"),
};
case "SET_SELECTED_BASE_ID":
return { ...state, selectedBaseId: action.payload };
case "UPDATE_SETTINGS":
return { ...state, settings: action.payload };
default:
return state;
}
}
const updateItem = (items, targetId, callback, idFieldName = "id") =>
items.map((item) =>
item[idFieldName] === targetId ? callback(item) : item
);
const [state, dispatch] = useReducer(appReducer, initialState);
useEffect(() => {
const fetchData = async () => {
const configData = await ApiProcessor.getConfig(showSnackbar);
if (configData) {
addBases(configData.bases);
}
const settingsData = await ApiProcessor.getSettings(showSnackbar);
if (settingsData) {
loadSettings(settingsData);
}
};
fetchData();
}, []);
useEffect(() => {
const updateBases = async () => {
await ApiProcessor.updateConfig(state.bases, showSnackbar);
setBaseUpdated(false);
};
if (baseUpdated) {
updateBases();
}
}, [state.bases, baseUpdated]);
useEffect(() => {
const saveSettings = async () => {
if (state.settings && Object.keys(state.settings).length > 0) {
await ApiProcessor.updateSettings(state.settings, showSnackbar);
setSettingsUpdated(false);
}
};
if (settingsUpdated) {
saveSettings();
}
}, [state.settings, settingsUpdated]);
const addBases = (newBases) => {
dispatch({ type: "ADD_BASES", payload: newBases });
};
const addBase = (newBase) => {
const payload = {
id: uuidv4(),
name: newBase.name,
srvr: newBase.srvr,
ref: newBase.ref,
publications: [
{
id: uuidv4(),
name: newBase.ref,
title: newBase.name,
enable: true,
active: false,
usr: "",
pwd: "",
enableStandardOData: false,
ws: {
publishExtensionsByDefault: true,
wsList: []
},
httpServices: {
publishExtensionsByDefault: true,
publishByDefault: true,
hsList: []
}
}
],
};
dispatch({
type: "ADD_BASE",
payload: payload,
});
setBaseUpdated(true);
setSelectedBaseId(payload.id);
};
const updateBase = (updatedBase) => {
dispatch({ type: "UPDATE_BASE", payload: updatedBase });
setBaseUpdated(true);
};
const deleteBase = (deletedBase) => {
dispatch({ type: 'DELETE_BASE', payload: deletedBase.id });
setBaseUpdated(true);
};
const updatePublication = (publicationId, updatedPublication) => {
dispatch({
type: "UPDATE_PUBLICATION",
payload: { publicationId, updatedPublication },
});
setBaseUpdated(true);
};
const addPublication = (newPublication) => {
const publicationWithId = {
...newPublication,
id: uuidv4(),
};
dispatch({
type: "ADD_PUBLICATION",
payload: { newPublication: publicationWithId },
});
setBaseUpdated(true);
};
const deletePublication = (deletedPublication) => {
const deletedPublicationId = deletedPublication.id;
dispatch({ type: 'DELETE_PUBLICATION', payload: deletedPublicationId });
setBaseUpdated(true);
};
const setSelectedBaseId = (id) => {
dispatch({ type: "SET_SELECTED_BASE_ID", payload: id });
};
const getUniqueTitles = () => {
const selectedBase = state.bases.find(base => base.id === state.selectedBaseId);
if (!selectedBase) return [];
const titles = selectedBase.publications.map(publication => publication.title);
return [...new Set(titles)];
};
const getUniqueEndpoints = () => {
const allPublications = state.bases.flatMap(base => base.publications);
const endpoints = allPublications.map(publication => publication.name);
return [...new Set(endpoints)];
};
const { enqueueSnackbar } = useSnackbar();
const showSnackbar = (message, variant = "default") => {
enqueueSnackbar(message, {
variant: variant,
anchorOrigin: {
vertical: "bottom",
horizontal: "right",
},
});
};
const updateSettings = (newSettings) => {
dispatch({ type: "UPDATE_SETTINGS", payload: newSettings });
setSettingsUpdated(true);
};
const loadSettings = (newSettings) => {
dispatch({ type: "UPDATE_SETTINGS", payload: newSettings });
};
return (
<AppContext.Provider
value={{
state,
addBases,
addBase,
updateBase,
deleteBase,
updatePublication,
addPublication,
showSnackbar,
setSelectedBaseId,
getUniqueTitles,
getUniqueEndpoints,
deletePublication,
updateSettings
}}
>
{children}
</AppContext.Provider>
);
};
export default AppContextProvider;

View File

@ -1,17 +1,33 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom/client';
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@mui/material/styles';
import App from './App';
import theme from './darkTheme';
import * as React from "react";
import * as ReactDOM from "react-dom/client";
import App from "./App";
import { ThemeProviderWrapper } from "./ThemeContext";
import AppContextProvider from "./context/AppContextProvider.js";
import { SnackbarProvider } from "notistack";
import * as serviceWorker from "./serviceWorker";
const rootElement = document.getElementById('root');
const rootElement = document.getElementById("root");
const root = ReactDOM.createRoot(rootElement);
root.render(
<ThemeProvider theme={theme}>
{/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
<CssBaseline />
<App />
</ThemeProvider>,
<ThemeProviderWrapper>
<SnackbarProvider maxSnack={3}>
<AppContextProvider>
<App />
</AppContextProvider>
</SnackbarProvider>
</ThemeProviderWrapper>
);
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/service-worker.js")
.then((registration) => {
console.log("SW registered: ", registration);
})
.catch((registrationError) => {
console.log("SW registration failed: ", registrationError);
});
});
}

View File

@ -0,0 +1,98 @@
const isLocalhost = Boolean(
window.location.hostname === "localhost" ||
window.location.hostname === "[::1]" ||
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export function register(config) {
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
return;
}
window.addEventListener("load", () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
checkValidServiceWorker(swUrl, config);
navigator.serviceWorker.ready.then(() => {
console.log("This web app is being served cache-first by a service worker.");
});
} else {
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === "installed") {
if (navigator.serviceWorker.controller) {
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
console.log("Content is cached for offline use.");
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch((error) => {
console.error("Error during service worker registration:", error);
});
}
function checkValidServiceWorker(swUrl, config) {
fetch(swUrl, {
headers: { "Service-Worker": "script" },
})
.then((response) => {
const contentType = response.headers.get("content-type");
if (
response.status === 404 ||
(contentType != null && contentType.indexOf("javascript") === -1)
) {
navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log("No internet connection found. App is running in offline mode.");
});
}
export function unregister() {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.ready
.then((registration) => {
registration.unregister();
})
.catch((error) => {
console.error(error.message);
});
}
}

View File

@ -0,0 +1,19 @@
import { red } from '@mui/material/colors';
import { createTheme } from '@mui/material/styles';
// A custom theme for this app
const theme = createTheme({
palette: {
primary: {
main: '#556cd6',
},
secondary: {
main: '#19857b',
},
error: {
main: red.A400,
},
},
});
export default theme;

0
volumes/config/.gitkeep Normal file
View File

View File

@ -11,6 +11,7 @@
"КаталогПубликаций": "var/www/1C/",
"КаталогАпач": "/usr/local/apache2/",
"ПутьК1С": "/opt/1cv8/current/",
"ПутьККонфигу": "./config/config.json"
"ПутьККонфигу": "./config/config.json",
"ПутьКНастройкам": "./config/settings.json"
}
}

View File

@ -8,7 +8,7 @@
КонецПроцедуры
&ТочкаМаршрута("getconfig.json")
&ТочкаМаршрута("config/getconfig")
Процедура ПолучитьКонфиг(Ответ) Экспорт
JsonСтрока = УправлениеКонфигурацией.ВернутьТекстКонфига();
Ответ.УстановитьТипКонтента("json");
@ -17,7 +17,16 @@
Ответ.УстановитьСостояниеОК();
КонецПроцедуры
&ТочкаМаршрута("updateconfig")
&ТочкаМаршрута("config/getsettings")
Процедура ПолучитьНастройки(Ответ) Экспорт
JsonСтрока = УправлениеКонфигурацией.ВернутьТекстНастроек();
Ответ.УстановитьТипКонтента("json");
Ответ.ТелоТекст = JsonСтрока;
Ответ.заголовки.Вставить("Access-Control-Allow-Origin", "*");
Ответ.УстановитьСостояниеОК();
КонецПроцедуры
&ТочкаМаршрута("config/updateconfig")
Процедура ПерезаписатьКонфиг(Запрос, ТелоЗапросОбъект, Ответ) Экспорт
Ответ.заголовки.Вставить("Access-Control-Allow-Origin", "*");
Ответ.заголовки.Вставить("Access-Control-Allow-Headers", "Content-Type");
@ -31,28 +40,48 @@
УправлениеКонфигурацией.ЗаписатьКонфиг(Запрос.Тело);
АпачМодификаторКонфига.ОпубликоватьБазы(УправлениеКонфигурацией.ПрочитатьКонфиг());
Ответ.ТелоТекст = "{""success"": true}";
Ответ.ТелоТекст = "{""success"": true, ""message"": ""Конфигурация успешно обновлена""}";
Ответ.УстановитьСостояниеОК();
КонецПроцедуры
&ТочкаМаршрута("config/updatesettings")
Процедура ПерезаписатьНастройки(Запрос, ТелоЗапросОбъект, Ответ) Экспорт
Ответ.заголовки.Вставить("Access-Control-Allow-Origin", "*");
Ответ.заголовки.Вставить("Access-Control-Allow-Headers", "Content-Type");
Если не Запрос.Метод = "POST" Тогда
Ответ.Модель = Новый Структура();
Ответ.Модель.Вставить("КодСостояния", 405);
Ответ.Модель.Вставить("ТекстСообщения", "Недопустимый метод!");
Ответ.Модель.Вставить("Запрос", Запрос);
Возврат;
КонецЕсли;
УправлениеКонфигурацией.ЗаписатьНастройки(Запрос.Тело);
Ответ.ТелоТекст = "{""success"": true, ""message"": ""Настройки успешно сохранены""}";
Ответ.УстановитьСостояниеОК();
КонецПроцедуры
&ТочкаМаршрута("ws/start")
Процедура Старт(Ответ) Экспорт
АпачУправлятор.ЗапуститьАпач();
Ответ.ТелоТекст = "{""success"": true}";
Ответ.ТелоТекст = "{""success"": true, ""message"": ""Сервер успешно запущен""}";
Ответ.заголовки.Вставить("Access-Control-Allow-Origin", "*");
Ответ.УстановитьСостояниеОК();
КонецПроцедуры
&ТочкаМаршрута("ws/stop")
Процедура Стоп(Ответ) Экспорт
АпачУправлятор.ОстановитьАпач();
Ответ.ТелоТекст = "{""success"": true}";
Ответ.ТелоТекст = "{""success"": true, ""message"": ""Сервер успешно остановлен""}";
Ответ.заголовки.Вставить("Access-Control-Allow-Origin", "*");
Ответ.УстановитьСостояниеОК();
КонецПроцедуры
&ТочкаМаршрута("ws/restart")
Процедура Рестарт(Ответ) Экспорт
АпачУправлятор.ПерезапуститьАпач();
Ответ.ТелоТекст = "{""success"": true}";
Ответ.ТелоТекст = "{""success"": true, ""message"": ""Сервер успешно перезапущен""}";
Ответ.заголовки.Вставить("Access-Control-Allow-Origin", "*");
Ответ.УстановитьСостояниеОК();
КонецПроцедуры

View File

@ -1,13 +1,10 @@
{
"files": {
"main.css": "/static/css/main.b72ea8bc.css",
"main.js": "/static/js/main.75b577f3.js",
"main.js": "/static/js/main.270f6462.js",
"index.html": "/index.html",
"main.b72ea8bc.css.map": "/static/css/main.b72ea8bc.css.map",
"main.75b577f3.js.map": "/static/js/main.75b577f3.js.map"
"main.270f6462.js.map": "/static/js/main.270f6462.js.map"
},
"entrypoints": [
"static/css/main.b72ea8bc.css",
"static/js/main.75b577f3.js"
"static/js/main.270f6462.js"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="shortcut icon" href="/favicon.ico"/><meta name="viewport" content="initial-scale=1,width=device-width"/><meta name="theme-color" content="#000000"/><link rel="manifest" href="/manifest.json"/><title>Публикатор 1с</title><link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"/><link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons"/><script defer="defer" src="/static/js/main.75b577f3.js"></script><link href="/static/css/main.b72ea8bc.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="shortcut icon" href="/favicon.ico"/><meta name="viewport" content="initial-scale=1,width=device-width,shrink-to-fit=no"/><meta name="theme-color" content="#000000"/><link rel="apple-touch-icon" href="/icon-192x192.png"/><link rel="icon" href="/icon-512x512.png" sizes="512x512" type="image/png"/><link rel="manifest" href="/manifest.json"/><title>Публикатор 1с</title><link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"/><script defer="defer" src="/static/js/main.270f6462.js"></script></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -1,11 +1,21 @@
{
"short_name": "Your Orders",
"name": "Your Orders",
"short_name": "Публикатор 1с",
"name": "Публикатор 1с",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": ".",

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,68 @@
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @mui/styled-engine v5.11.11
*
* @license MIT
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v16.13.1
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

File diff suppressed because one or more lines are too long

View File

@ -10,6 +10,9 @@
&Деталька(Значение = "Публикатор.ПутьККонфигу", ЗначениеПоУмолчанию = "./config/config.json")
Перем ПутьККонфигу Экспорт;
&Деталька(Значение = "Публикатор.ПутьКНастройкам", ЗначениеПоУмолчанию = "./config/settings.json")
Перем ПутьКНастройкам Экспорт;
Перем ЭтоLinux Экспорт;
&Желудь

View File

@ -9,12 +9,13 @@
Функция ПрочитатьКонфиг() Экспорт
Лог.Отладка("Приступаем к чтению Json c настройками.");
Конфиг = ФайловыеОперации.ПрочитатьТекстФайла(НастройкиПубликатора.ПутьККонфигу);
ПарсерJSON = Новый ПарсерJSON;
Возврат ПарсерJSON.ПрочитатьJSON(ФайловыеОперации.ПрочитатьТекстФайла(НастройкиПубликатора.ПутьККонфигу),,,Истина);
Возврат ПарсерJSON.ПрочитатьJSON(?(ЗначениеЗаполнено(Конфиг), Конфиг, "{}"),,,Истина);
КонецФункции
Процедура ЗаписатьКонфиг(Знач Конфиг) Экспорт
Лог.Отладка("Приступаем к обновлению Json c настройками.");
Лог.Отладка("Приступаем к обновлению Json c конфигом.");
Если Не ТипЗнч(Конфиг) = Тип("Строка") Тогда
Лог.Отладка("Передана не json строка. Сериализуем.");
@ -25,8 +26,25 @@
Лог.Информация("Файл конфигурации успешно обновлен.");
КонецПроцедуры
Процедура ЗаписатьНастройки(Знач Настройки) Экспорт
Лог.Отладка("Приступаем к обновлению Json c настройками.");
Если Не ТипЗнч(Настройки) = Тип("Строка") Тогда
Лог.Отладка("Передана не json строка. Сериализуем.");
ПарсерJSON = Новый ПарсерJSON;
Настройки = ПарсерJSON.ЗаписатьJSON(Настройки);
КонецЕсли;
ФайловыеОперации.ЗаписатьТекстФайла(НастройкиПубликатора.ПутьКНастройкам, Настройки);
Лог.Информация("Файл конфигурации успешно обновлен.");
КонецПроцедуры
Функция ВернутьТекстКонфига() Экспорт
Возврат ФайловыеОперации.ПрочитатьТекстФайла(НастройкиПубликатора.ПутьККонфигу);
ТекстКонфига = ФайловыеОперации.ПрочитатьТекстФайла(НастройкиПубликатора.ПутьККонфигу);
Возврат ?(ЗначениеЗаполнено(ТекстКонфига), ТекстКонфига, "{""bases"":[]}");
КонецФункции
Функция ВернутьТекстНастроек() Экспорт
ТекстКонфига = ФайловыеОперации.ПрочитатьТекстФайла(НастройкиПубликатора.ПутьКНастройкам);
Возврат ?(ЗначениеЗаполнено(ТекстКонфига), ТекстКонфига, "{""publicationServerUrl"":""http://localhost""}");
КонецФункции

View File

@ -43,15 +43,22 @@
//
Функция ПрочитатьТекстФайла(ПутьКФайлу) Экспорт
Лог.Отладка("Приступаем к чтению файла по пути: %1", ПутьКФайлу);
Кодировка = КодировкаТекста.UTF8NoBOM;
Текст = Новый ЧтениеТекста();
Текст.Открыть(ПутьКФайлу, Кодировка);
файл = НовыйФайл(ПутьКФайлу);
Если файл.Существует() Тогда
Кодировка = КодировкаТекста.UTF8NoBOM;
Текст = Новый ЧтениеТекста();
Текст.Открыть(ПутьКФайлу, Кодировка);
СодержимоеФайла = Текст.Прочитать();
Текст.Закрыть();
СодержимоеФайла = Текст.Прочитать();
Текст.Закрыть();
Лог.Отладка("Файл успешно прочитан");
Иначе
СодержимоеФайла = "";
Лог.Отладка("Файл не найден")
КонецЕсли;
Лог.Отладка("Файл успешно прочитан");
Возврат СодержимоеФайла;
КонецФункции // ПрочитатьТекстФайла