склади и инструмент готовы

This commit is contained in:
2025-12-07 19:36:28 +03:00
parent 54bf21d52d
commit 65a3bc1671
65 changed files with 3485 additions and 115 deletions
+41
View File
@@ -0,0 +1,41 @@
import os
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from api.routers import router
import config
ENV = os.getenv("APP_ENV", "dev") # dev по умолчанию
def create_app():
if ENV == "prod":
# Полное отключение документации
app = FastAPI(
title="API сервер проекта ToolsBox",
summary="Сервис управления складом инструментов",
docs_url=None,
redoc_url=None,
openapi_url=None,
)
else:
# Dev-режим: документация включена
app = FastAPI(
title="API сервер проекта ToolsBox",
summary="Сервис управления складом инструментов",
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
)
return app
app = create_app()
# Подключение static
app.mount(
"/static",
StaticFiles(directory=f"{config.RELOAD_DIR}api/static"),
name="static",
)
app.include_router(router)
Binary file not shown.
+80
View File
@@ -0,0 +1,80 @@
from fastapi import APIRouter, Depends, Request
from db.handlers.categories import CategoryHandler
from utils import render, requestDict, logger
from .user import router as user
from .stocks import router as stocks
router = APIRouter()
router.include_router(user, prefix="/user", tags=["user"])
router.include_router(stocks, prefix="/stocks", tags=["stocks"])
@router.get("/")
async def main_page(request: Request):
return await render(request)
@router.post("/")
async def post_requests(
request_data: dict = Depends(requestDict),
):
from db.handlers.records import ServiceRecordsHandler, StocksRecordsHandler
from db.handlers.toolbox import ToolboxHandler
from db.handlers.toolkit import ToolkitHandler
from db.handlers.user import UserHandler
reqData = {
"tab": request_data.get("body").get("tabId"),
"userData": request_data.get("body").get("cookiesData").get("userData"),
"accessData": request_data.get("body").get("cookiesData").get("accessData"),
}
resultData = {"status": "error", "data": {}}
logger.info(f"Получение данных для вкладки {reqData.get('tab')}")
match reqData.get("tab"):
case "toolbox":
if reqData.get("accessData").get("view_all_toolboxes", False):
toolbox = await ToolboxHandler.getAll()
else:
toolbox = await ToolboxHandler.getByOwner(
reqData.get("userData").get("id")
)
if toolbox:
resultData["status"] = "ok"
resultData["data"] = toolbox
case "requests":
requests = await StocksRecordsHandler.get(reqData.get("userData").get("id"))
if isinstance(requests, list):
resultData["status"] = "ok"
resultData["data"] = requests
case "toolkits":
toolkits = await ToolkitHandler.getAll()
categories = await CategoryHandler.getAll()
if toolkits:
resultData["status"] = "ok"
resultData["data"] = {
"toolkits": toolkits,
"categories": categories,
}
case "jurnal_toolkits":
jurnal_toolkits = await StocksRecordsHandler.get()
if jurnal_toolkits:
resultData["status"] = "ok"
resultData["data"] = jurnal_toolkits
case "jurnal_service":
jurnal_service = await ServiceRecordsHandler.get()
if jurnal_service:
resultData["status"] = "ok"
resultData["data"] = jurnal_service
case "users":
users = await UserHandler.getAll()
if users:
for user in users:
user.pop("hashed_password")
resultData["status"] = "ok"
resultData["data"] = users
case _:
pass
return resultData
Binary file not shown.
Binary file not shown.
Binary file not shown.
+149
View File
@@ -0,0 +1,149 @@
from fastapi import APIRouter, Depends
from db.handlers.actions import StocksActions
from db.handlers.categories import CategoryHandler
from db.handlers.records import StocksRecordsHandler
from db.handlers.stock import StockHandler
from db.handlers.toolbox import ToolboxHandler
from db.handlers.toolkit import ToolkitHandler
from utils import requestDict, logger
router = APIRouter()
@router.post("/")
async def post_requests(
request_data: dict = Depends(requestDict),
):
toolboxId = request_data.get("body").get("toolboxId")
logger.info(f"Получение инструментов для тулбокса {toolboxId}")
response = {"status": "error", "data": {}}
stocksData = await StockHandler.getByToolboxId(toolboxId)
if not stocksData:
return response
toolkitsIds = set(stock["toolkit_id"] for stock in stocksData)
toolkitsData = await ToolkitHandler.getSeveral(list(toolkitsIds))
if not toolkitsData:
return response
categoriesIds = set(toolkit["category_id"] for toolkit in toolkitsData)
categoriesData = await CategoryHandler.getSeveral(list(categoriesIds))
if not categoriesData:
return response
response["status"] = "ok"
response["data"] = {
"stocks": stocksData,
"toolkits": toolkitsData,
"categories": categoriesData,
}
return response
@router.post("/action", summary="Запрос на перемещение инструмента")
async def post_requests(
request_data: dict = Depends(requestDict),
):
action = request_data.get("body").get("action").get("operation")
logger.info(f"Получение запроса на перемещение ({action}) инструмента")
userId = request_data.get("body").get("userData").get("id")
sourceTollboxId = (
request_data.get("body").get("action").get("selectedItem").get("toolboxId")
)
toolkitId = request_data.get("body").get("action").get("selectedItem").get("id")
quantity = request_data.get("body").get("action").get("quantity")
price = round(
request_data.get("body").get("action").get("selectedItem").get("totalCost")
/ request_data.get("body").get("action").get("selectedItem").get("available"),
2,
)
reason = request_data.get("body").get("action").get("comment")
resonse = {"status": "error"}
match action:
case "writeoff":
result = await StocksActions.movingRequest(
"Списание",
toolkitId,
sourceTollboxId,
None,
quantity,
reason,
userId,
price,
)
if result:
resonse["status"] = "ok"
case "get":
targetTollboxId = await ToolboxHandler.getIdByOwner(userId)
result = await StocksActions.takeToolkit(
sourceTollboxId,
targetTollboxId,
toolkitId,
quantity,
reason,
userId,
price,
)
if result:
resonse["status"] = "ok"
case "return":
targetTollboxId = await StocksRecordsHandler.getOriginalToolboxId(
toolkitId, sourceTollboxId
)
if targetTollboxId:
result = await StocksActions.movingRequest(
"Возврат",
toolkitId,
sourceTollboxId,
targetTollboxId,
quantity,
reason,
userId,
price,
)
if result:
resonse["status"] = "ok"
return resonse
@router.post("/toolkit", summary="Запрос остатка инструмента")
async def toolkit_request(
request_data: dict = Depends(requestDict),
):
response = {"status": "error", "data": {}}
logger.info(f"Получение запроса остатка инструмента")
# logger.info(request_data)
toolkitId = request_data.get("body").get("toolkitId")
stocks = await StockHandler.getByToolkitId(toolkitId)
if not stocks:
return response
userId = request_data.get("body").get("userId")
allToolboxes = request_data.get("body").get("allToolboxes")
toolboxes = (
await ToolboxHandler.getByOwner(userId)
if not allToolboxes
else await ToolboxHandler.getAll()
)
if not toolboxes:
return response
toolboxesTitles = {toolbox["id"]: toolbox["title"] for toolbox in toolboxes}
stocksData = {"count": 0, "toolboxes": {}}
for stock in stocks:
toolboxTitle = toolboxesTitles.get(stock["toolbox_id"], None)
if not toolboxTitle:
continue
stocksData["count"] += stock["quantity"]
if toolboxTitle not in stocksData["toolboxes"]:
stocksData["toolboxes"][toolboxTitle] = {
"count": stock["quantity"],
"placement": stock["placement"],
}
else:
stocksData["toolboxes"][toolboxTitle]["count"] += stock["quantity"]
response["status"] = "ok"
response["data"] = stocksData
logger.info(response)
return response
+44
View File
@@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends, Request
from db.handlers.access import AccessLevelHandler
from db.handlers.user import UserHandler
from utils import requestDict, logger, render
router = APIRouter()
@router.get("/", name="userInfo", summary="Получение информации о пользователе")
async def get_user():
return
@router.post("/")
async def create_user():
return
@router.get("/login", name="Authentication", summary="Авторизация пользователя")
async def authenticationPage(request: Request):
return await render(request)
@router.post("/login")
async def authentication(
request_data: dict = Depends(requestDict),
):
resultData = {"status": "error", "user": {}, "access": {}}
login = request_data.get("body").get("login", None)
password = request_data.get("body").get("password", None)
if not login or not password:
logger.error("Не указан логин или пароль")
return resultData
userData = await UserHandler.auth(login, password)
if not userData:
return resultData
accessData = await AccessLevelHandler.get(userData["access_level_id"])
if not accessData:
return resultData
resultData["status"] = "ok"
resultData["user"] = userData
resultData["access"] = accessData
return resultData
+345
View File
@@ -0,0 +1,345 @@
:root {
--header-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--card-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Заголовок с градиентом и стеклянным эффектом */
.glass-header {
background: var(--header-bg);
backdrop-filter: blur(10px);
background-color: rgba(255, 255, 255, 0.95);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
}
/* Стили для навигационных кнопок */
.tab-nav-btn {
position: relative;
background: transparent !important;
border: none !important;
border-radius: 0.75rem !important;
transition: all 0.3s ease !important;
max-width: 200px !important;
}
.tab-nav-btn:hover {
background-color: rgba(var(--bs-primary-rgb), 0.05) !important;
transform: translateY(-2px);
}
.tab-nav-btn.active {
background-color: rgba(var(--bs-primary-rgb), 0.1) !important;
border: 1px solid rgba(var(--bs-primary-rgb), 0.2) !important;
box-shadow: 0 4px 12px rgba(var(--bs-primary-rgb), 0.15);
}
.tab-nav-btn.active .nav-indicator {
position: absolute;
bottom: -10px;
left: 50%;
transform: translateX(-50%);
width: 60%;
height: 3px;
background-color: var(--bs-primary);
border-radius: 3px;
}
/* Только изменение цвета текста на темно-серый */
.tab-nav-btn.active .nav-icon {
color: #495057 !important;
}
.tab-nav-btn.active .nav-title {
color: #495057 !important;
}
/* Кнопки выбора склада */
.toolbox-nav-btn {
position: relative;
background: transparent !important;
border-radius: 0.75rem !important;
transition: all 0.3s ease !important;
padding: 0.75rem 1rem !important;
min-width: 140px !important;
}
.toolbox-nav-btn:hover {
background-color: rgba(59, 130, 246, 0.05) !important;
transform: translateY(-2px);
color: #495057 !important; /* Темно-серый при наведении */
}
.toolbox-nav-btn.active {
background-color: rgba(59, 130, 246, 0.1) !important;
border: 1px solid rgba(59, 130, 246, 0.2) !important;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
color: #495057 !important; /* Темно-серый при активном состоянии */
}
.toolbox-nav-btn.active .toolbox-nav-indicator {
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 50%;
height: 3px;
background-color: #3b82f6;
border-radius: 3px;
}
/* Убираем стили Bootstrap для активного состояния */
.toolbox-nav-btn.active.btn-primary {
background-color: rgba(59, 130, 246, 0.1) !important;
border-color: rgba(59, 130, 246, 0.2) !important;
color: #495057 !important;
}
/* Стили для контейнера данных склада */
.toolbox-content-container {
min-height: 400px;
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.08);
}
/* Анимации */
.tab-pane.fade {
transition: opacity 0.25s linear;
}
.tab-pane.fade.show.active {
opacity: 1;
}
/* Стили для карточек */
.card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0,0,0,0.1) !important;
}
#mainTabsNav {
display: inline-flex !important;
width: auto !important;
flex: 0 0 auto !important;
flex-wrap: nowrap !important;
}
#mainTabsNavWrapper {
text-align: center;
}
/* Адаптивность */
@media (max-width: 768px) {
.tab-nav-btn {
min-width: 100px !important;
padding: 0.75rem 0.5rem !important;
}
.toolbox-nav-btn {
min-width: 120px !important;
padding: 0.5rem 0.75rem !important;
}
.nav-title {
font-size: 0.8rem !important;
}
.nav-icon {
font-size: 1.5rem !important;
}
}
@media (max-width: 576px) {
#mainTabsNav {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tab-nav-btn {
min-width: 90px !important;
flex-shrink: 0;
}
.toolbox-nav-btn {
min-width: 110px !important;
flex-shrink: 0;
}
}
/* Стили для таблицы с фиксированным заголовком */
.table-responsive {
position: relative;
}
.table-responsive thead th {
position: sticky;
top: 0;
background-color: #f8f9fa;
z-index: 10;
box-shadow: 0 2px 2px -1px rgba(0,0,0,0.1);
}
/* Стили для выделенных строк */
.table-striped tbody tr.table-active:nth-child(odd) {
background-color: rgba(13, 110, 253, 0.3) !important;
}
.table-striped tbody tr.table-active:nth-child(even) {
background-color: rgba(13, 110, 253, 0.2) !important;
}
/* Стили для изображений */
.toolkit-image-link {
text-decoration: none;
transition: opacity 0.2s;
}
.toolkit-image-link:hover {
opacity: 0.8;
}
/* Стили для пагинации */
.pagination {
margin-bottom: 0;
}
.page-link {
cursor: pointer;
}
/* Стили для таблицы сортировки */
th[data-sort] {
cursor: pointer;
position: relative;
user-select: none;
}
th[data-sort]:hover {
background-color: #e9ecef;
}
/* Стили для бейджей статусов */
.badge.bg-success { background-color: #198754 !important; }
.badge.bg-warning { background-color: #ffc107 !important; color: #000; }
.badge.bg-danger { background-color: #dc3545 !important; }
/* Стили для кнопок действий в строке */
.action-buttons {
opacity: 0;
transition: opacity 0.2s;
}
tr:hover .action-buttons {
opacity: 1;
}
/* Уменьшаем отступы у кнопок в строках */
.action-buttons .btn {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
/* Стили для поля поиска */
.input-group {
max-width: 500px;
}
/* Стили для более заметных стрелок карусели */
.carousel-control-prev,
.carousel-control-next {
width: 40px !important;
height: 40px !important;
top: 50% !important;
transform: translateY(-50%) !important;
background-color: rgba(0, 0, 0, 0.5) !important;
border-radius: 50% !important;
opacity: 0.8 !important;
transition: all 0.3s ease !important;
}
.carousel-control-prev {
left: 10px !important;
}
.carousel-control-next {
right: 10px !important;
}
.carousel-control-prev:hover,
.carousel-control-next:hover {
opacity: 1 !important;
background-color: rgba(0, 0, 0, 0.7) !important;
transform: translateY(-50%) scale(1.1) !important;
}
.carousel-control-prev-icon,
.carousel-control-next-icon {
width: 20px !important;
height: 20px !important;
filter: invert(1) !important; /* Делает стрелки белыми */
}
/* Для лучшей видимости на темных изображениях можно добавить тень */
.carousel-control-prev-icon,
.carousel-control-next-icon {
filter: invert(1) drop-shadow(0 0 2px rgba(0, 0, 0, 0.5)) !important;
}
/* Стили для индикаторов карусели */
.carousel-indicators button.active {
background-color: #007bff !important;
border-color: #007bff !important;
}
/* Стили для карточек */
.toolkit-card {
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
height: 100%;
}
.toolkit-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.toolkit-card-img {
height: 200px;
object-fit: contain;
object-position: center;
}
.toolkit-description {
display: -webkit-box;
/* -webkit-line-clamp: 3; */
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.category-badge {
font-size: 0.8rem;
padding: 0.25rem 0.75rem;
}
.filter-btn.active {
background-color: #0d6efd;
color: white;
}
.filter-btn:not(.active) {
background-color: #f8f9fa;
}
/* Адаптивные стили */
@media (max-width: 768px) {
.toolkit-card-img {
height: 150px;
}
}
+5
View File
@@ -0,0 +1,5 @@
body {
background-image: url("../images/background.png");
background-repeat: no-repeat;
background-size: cover;
}
+98
View File
@@ -0,0 +1,98 @@
/* Минимальный стиль для страницы входа */
:root{
--card-w: 420px;
--accent: #0d6efd; /* bootstrap primary */
--bg: #f6f7fb;
--card-bg: #ffffff;
--muted: #6c757d;
}
html,body{
height:100%;
}
.login-page{
min-height:100vh;
background: linear-gradient(180deg, #eef3fb 0%, var(--bg) 100%);
padding: 2rem;
}
.login-card{
width: var(--card-w);
background: var(--card-bg);
border-radius: 12px;
box-shadow: 0 6px 24px rgba(20,25,40,0.08);
padding: 1.25rem;
display:flex;
flex-direction:column;
gap: .75rem;
}
.login-header{
display:flex;
align-items:center;
gap:.75rem;
border-bottom: 1px solid #f1f3f5;
padding-bottom:.5rem;
margin-bottom:.5rem;
}
.brand-icon{
width:36px;
height:36px;
object-fit:contain;
border-radius:6px;
}
.login-header h1{
font-size:1.05rem;
margin:0;
}
.login-form {
display:flex;
flex-direction:column;
gap:.65rem;
}
.form-group label{
font-weight:600;
font-size:.9rem;
margin-bottom:.25rem;
display:block;
}
.form-control{
padding:.6rem .75rem;
border-radius:8px;
border:1px solid #e6e9ef;
font-size: .95rem;
outline:none;
}
.form-control:focus{
box-shadow:0 0 0 3px rgba(13,110,253,0.08);
border-color: var(--accent);
}
.form-actions{
margin-top:.25rem;
}
.form-error{
color:#b00020;
font-size:.9rem;
padding:.25rem .25rem;
}
.login-footer{
margin-top:.5rem;
text-align:center;
color:var(--muted);
font-size:.85rem;
}
/* responsiveness */
@media (max-width:480px){
.login-card{ width: 100%; border-radius:0; min-height:100vh; justify-content:center; }
}
+153
View File
@@ -0,0 +1,153 @@
/* Карточка клиента с современным дизайном */
.client-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 16px;
padding: 1.25rem;
box-shadow: var(--card-shadow);
transition: var(--transition-smooth);
min-width: 300px;
}
.client-card:hover {
transform: translateY(-2px);
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.15);
}
/* Аватар с градиентной обводкой */
.client-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
border: 3px solid transparent;
background: linear-gradient(white, white) padding-box,
linear-gradient(135deg, #667eea 0%, #764ba2 100%) border-box;
padding: 2px;
background-color: #f8f9fa;
}
/* Имя клиента */
.client-name {
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-size: 1.1rem;
min-height: 1.5rem;
}
/* Кнопка выхода */
.logout-btn {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
border: none;
border-radius: 12px;
padding: 0.5rem 1.25rem;
font-weight: 600;
color: white;
transition: var(--transition-smooth);
position: relative;
overflow: hidden;
}
.logout-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: 0.5s;
}
.logout-btn:hover {
transform: translateY(-1px);
box-shadow: 0 5px 15px rgba(245, 87, 108, 0.3);
}
.logout-btn:hover::before {
left: 100%;
}
/* Анимации */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-up {
animation: fadeInUp 0.6s ease-out;
}
/* Стили для состояния загрузки */
.client-avatar[src=""],
.client-avatar:not([src]) {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
.client-name:empty::before,
.client-role:empty::before {
content: "Загрузка...";
color: #ccc;
font-style: italic;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Адаптивность */
@media (max-width: 768px) {
.client-card {
padding: 1rem;
min-width: auto;
width: 100%;
}
.client-avatar {
width: 48px;
height: 48px;
}
.glass-header {
padding: 1rem 0;
}
.client-name {
font-size: 1rem;
}
.logout-btn span:not(.bi) {
display: none;
}
.logout-btn .bi {
margin-right: 0;
}
}
/* Микро-интеракции */
.clickable {
cursor: pointer;
transition: var(--transition-smooth);
}
.clickable:hover {
transform: scale(1.02);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+18
View File
@@ -0,0 +1,18 @@
// api.js
export async function apiRequest(url, payload = {}, method = 'POST') {
const res = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(payload),
credentials: 'same-origin'
});
if (!res.ok) {
const text = await res.text();
throw new Error(`HTTP ${res.status}: ${text}`);
}
return res.json();
}
+84
View File
@@ -0,0 +1,84 @@
// auth.js
import { setCookie } from '/static/js/cookies.js';
import { apiRequest } from '/static/js/api.js';
const form = document.getElementById('loginForm');
const loginInput = document.getElementById('loginInput');
const passwordInput = document.getElementById('passwordInput');
const submitBtn = document.getElementById('submitBtn');
const formError = document.getElementById('formError');
function showError(msg) {
formError.hidden = false;
formError.textContent = msg;
}
function clearError() {
formError.hidden = true;
formError.textContent = '';
}
async function getUserEndpoint() {
const meta = document.querySelector('meta[name="userAuth-endpoint"]');
return meta ? meta.getAttribute('content') : '/user';
}
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
clearError();
submitBtn.disabled = true;
submitBtn.textContent = 'Входим...';
const login = loginInput.value.trim();
const password = passwordInput.value;
if (!login || !password) {
showError('Введите логин и пароль');
submitBtn.disabled = false;
submitBtn.textContent = 'Войти';
return;
}
try {
const url = await getUserEndpoint();
// Отправляем данные
const resp = await apiRequest(url, { login, password });
// Пример ожидаемого формата:
// { "status": "ok" | "error", "user": {...}, "access": {...} }
if (!resp || typeof resp !== 'object') {
throw new Error('Некорректный ответ от сервера');
}
if (resp.status && (resp.status === 'error' || resp.status === 'fail')) {
// Ошибка авторизации
showError(resp.message || 'Неправильный логин или пароль');
submitBtn.disabled = false;
submitBtn.textContent = 'Войти';
return;
}
// Если статус == success / ok / authenticated и есть user + access — считаем успешной
const okStatuses = new Set(['ok', 'success', 'authenticated']);
const isOk = resp.status && okStatuses.has(String(resp.status).toLowerCase());
// fallback: если resp.user или resp.access присутствуют — считаем ок
const hasData = resp.user && typeof resp.user === 'object' && resp.access && typeof resp.access === 'object';
if (!isOk && !hasData) {
showError('Авторизация не выполнена: пустой ответ');
submitBtn.disabled = false;
submitBtn.textContent = 'Войти';
return;
}
await setCookie('toolbox_user', JSON.stringify(resp.user));
await setCookie('toolbox_access', JSON.stringify(resp.access));
window.location.href = '/'; // или другой URL
} catch (err) {
console.error(err);
showError(err.message || 'Ошибка при авторизации');
submitBtn.disabled = false;
submitBtn.textContent = 'Войти';
}
});
+77
View File
@@ -0,0 +1,77 @@
import { deriveKeyFromSecret, encryptJSON, decryptToJSON } from '/static/js/crypto.js';
async function getAppSecret() {
const meta = document.querySelector('meta[name="app-secret"]');
return meta ? meta.getAttribute('content') : null;
}
export async function setCookie(name, value, days = 180) {
const appSecret = await getAppSecret();
let cookieValue;
if (!appSecret) {
console.warn('APP SECRET is missing — cookies will be stored without client-side encryption.');
cookieValue = encodeURIComponent(JSON.stringify(value));
} else {
try {
const key = await deriveKeyFromSecret(appSecret);
cookieValue = await encryptJSON(key, value);
} catch (error) {
console.error('Encryption failed:', error);
cookieValue = encodeURIComponent(JSON.stringify(value));
}
}
const secure = false; // TODO включить после тестов
const sameSite = 'Lax';
const expires = new Date(Date.now() + days * 864e5).toUTCString();
let cookie = `${encodeURIComponent(name)}=${cookieValue}; expires=${expires}; path=/`;
if (secure) cookie += '; Secure';
if (sameSite) cookie += `; SameSite=${sameSite}`;
document.cookie = cookie;
}
export async function getCookie(name) {
const cookies = document.cookie ? document.cookie.split('; ') : [];
for (const c of cookies) {
const parts = c.split('=');
if (parts.length < 2) continue;
const cookieName = decodeURIComponent(parts[0]);
if (cookieName !== name) continue;
const cookieValue = parts.slice(1).join('=');
const appSecret = await getAppSecret();
if (appSecret) {
try {
const key = await deriveKeyFromSecret(appSecret);
const decrypted = await decryptToJSON(key, cookieValue);
return JSON.parse(decrypted);
} catch (error) {
console.error('Decryption failed for cookie', name, error);
return
}
} else {
try {
return JSON.parse(decodeURIComponent(cookieValue));
} catch (e) {
return decodeURIComponent(cookieValue);
}
}
}
return null;
}
export function deleteCookie(name) {
try {
document.cookie = `${encodeURIComponent(name)}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`;
} catch (e) {
console.error(e)
}
}
+94
View File
@@ -0,0 +1,94 @@
export async function deriveKeyFromSecret(secret, salt = 'toolbox_salt_v1') {
if (!secret || typeof secret !== 'string') {
throw new Error('Invalid secret: must be a non-empty string');
}
const enc = new TextEncoder();
const secretKey = await crypto.subtle.importKey(
'raw',
enc.encode(secret),
{ name: 'PBKDF2' },
false,
['deriveKey']
);
const key = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: enc.encode(salt),
iterations: 100000,
hash: 'SHA-256'
},
secretKey,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
return key;
}
function _randBytes(len = 12) {
const b = new Uint8Array(len);
crypto.getRandomValues(b);
return b;
}
export async function encryptJSON(key, obj) {
if (!key || !(key instanceof CryptoKey)) {
throw new Error('Valid CryptoKey is required');
}
const enc = new TextEncoder();
const iv = _randBytes(12);
const plain = enc.encode(JSON.stringify(obj));
const cipher = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv },
key,
plain
);
const combined = new Uint8Array(iv.byteLength + cipher.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(cipher), iv.byteLength);
// Безопасное преобразование в base64
const binaryString = Array.from(combined, byte =>
String.fromCharCode(byte)).join('');
return btoa(binaryString);
}
export async function decryptToJSON(key, b64) {
if (!key || !(key instanceof CryptoKey)) {
throw new Error('Valid CryptoKey is required');
}
if (!b64 || typeof b64 !== 'string') {
throw new Error('Invalid base64 string');
}
try {
// Безопасное преобразование из base64
const binaryString = atob(b64);
const raw = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
raw[i] = binaryString.charCodeAt(i);
}
const iv = raw.slice(0, 12);
const cipher = raw.slice(12);
const plainBuf = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv },
key,
cipher
);
const dec = new TextDecoder();
return JSON.parse(dec.decode(plainBuf));
} catch (error) {
console.error('Decryption error:', error);
throw new Error('Failed to decrypt data: ' + error.message);
}
}
File diff suppressed because it is too large Load Diff
+177
View File
@@ -0,0 +1,177 @@
import { getCookie, deleteCookie } from '/static/js/cookies.js';
class ClientManager {
constructor() {
this.userData = null;
this.accessData = null;
this.init();
}
async init() {
try {
// Получаем данные пользователя из cookie
this.userData = await getCookie('toolbox_user');
this.accessData = await getCookie('toolbox_access');
if (!this.userData || !this.accessData) {
console.warn('User data or access data not found in cookie');
this.clearUserCookie();
window.location.href = '/user/login';
}
// Вставляем данные пользователя в DOM
this.renderUserInfo();
// Инициализируем обработчики
this.initLogoutHandler();
this.initHoverEffects();
this.addAvatarErrorHandler();
} catch (error) {
console.error('Error initializing client manager:', error);
}
}
renderUserInfo() {
if (!this.userData) {
// Если нет данных, показываем заглушку
this.renderFallbackUser();
return;
}
// Находим элементы для обновления
const avatar = document.querySelector('.client-avatar');
const nameElement = document.querySelector('.client-name');
const roleElement = document.querySelector('.client-role');
// Обновляем аватар
if (avatar) {
avatar.src = this.userData.photo || 'static/images/users/default.png';
avatar.alt = this.userData.username || 'Пользователь';
}
// Обновляем имя
if (nameElement) {
nameElement.textContent = this.userData.username || 'Неизвестный пользователь';
}
// Обновляем роль
if (roleElement) {
const role = this.accessData.title || 'Неизвестная роль';
roleElement.textContent = role;
}
}
renderFallbackUser() {
const userCard = document.querySelector('.client-card');
if (userCard) {
userCard.innerHTML = `
<div class="d-flex align-items-center gap-3">
<div class="position-relative">
<img src="images/users/default.png"
alt="Гость"
class="client-avatar">
<span class="position-absolute bottom-0 end-0 bg-warning border border-2 border-white rounded-circle p-1"></span>
</div>
<div class="me-3">
<h6 class="client-name mb-1">Гость</h6>
<div class="d-flex align-items-center">
<span class="badge bg-warning bg-opacity-10 text-warning fs-7 px-2 py-1 me-2">
<i class="bi bi-exclamation-triangle me-1"></i>
Не авторизован
</span>
</div>
</div>
<div>
<a href="/login" class="btn btn-primary btn-sm">
<i class="bi bi-box-arrow-in-right me-1"></i>
Войти
</a>
</div>
</div>
`;
}
}
initLogoutHandler() {
const logoutBtn = document.getElementById('clientLogoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', (e) => {
e.preventDefault();
this.handleLogout(logoutBtn);
});
}
}
handleLogout(button) {
// Анимация нажатия
button.style.transform = 'scale(0.95)';
// Добавляем иконку загрузки
const originalContent = button.innerHTML;
button.innerHTML = `
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Выход...
`;
button.disabled = true;
// Очищаем cookie пользователя
this.clearUserCookie();
// Переход на страницу выхода
setTimeout(() => {
window.location.href = '/user/login';
}, 800);
}
clearUserCookie() {
try {
const cookieNames = ['toolbox_user', 'toolbox_access'];
cookieNames.forEach(cookieName => {
deleteCookie(cookieName);
});
} catch (error) {
console.warn('Error clearing cookies:', error);
}
}
initHoverEffects() {
const clientCard = document.querySelector('.client-card');
if (clientCard) {
clientCard.addEventListener('mouseenter', () => {
clientCard.style.transform = 'translateY(-4px) scale(1.01)';
});
clientCard.addEventListener('mouseleave', () => {
clientCard.style.transform = 'translateY(0) scale(1)';
});
}
}
addAvatarErrorHandler() {
const avatar = document.querySelector('.client-avatar');
if (avatar) {
avatar.onerror = () => {
avatar.src = 'static/images/users/default.png';
};
}
}
}
// Инициализация при загрузке документа
document.addEventListener('DOMContentLoaded', () => {
// Инициализируем менеджер пользователя
window.clientManager = new ClientManager();
// Добавляем анимацию появления
const elements = document.querySelectorAll('.animate-fade-up');
elements.forEach((el, index) => {
el.style.animationDelay = `${index * 0.1}s`;
});
});
// Экспорт для использования в других модулях
export { ClientManager };
+80
View File
@@ -0,0 +1,80 @@
{% extends "layout.html" %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="{{ getUrl('static', path='css/user.css') }}">
<link rel="stylesheet" href="{{ getUrl('static', path='css/index.css') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lightbox2/2.11.4/css/lightbox.min.css">
{% endblock %}
{% block body %}
<!-- Заголовок с современным дизайном -->
<header class="glass-header py-3 mb-4 flex-fill">
<div class="container-xxl">
<div class="d-flex flex-column flex-md-row align-items-md-center justify-content-between">
<!-- Заголовок слева -->
<div class="mb-3 mb-md-0 animate-fade-up">
<div class="d-flex align-items-center">
<img src="{{ getUrl('static', path='images/logo.png') }}" alt="Toolbox" class="me-2 rounded"
width="70">
<div>
<h1 class="h3 fw-bold mb-1">Toolbox</h1>
<p class="text-muted mb-0 opacity-85">
<i class="bi bi-cloud-check me-1"></i>
Онлайн-сервис для управления инструментами и расходниками
</p>
</div>
</div>
</div>
<!-- Информация о клиенте справа -->
<div class="animate-fade-up" style="animation-delay: 0.2s">
<div class="client-card d-inline-block">
<div class="d-flex align-items-center gap-3">
<!-- Аватар с индикатором активности -->
<div class="position-relative clickable">
<img src="" alt="Загрузка..." class="client-avatar">
<span
class="position-absolute bottom-0 end-0 bg-success border border-2 border-white rounded-circle p-1"></span>
</div>
<!-- Информация -->
<div class="me-3">
<h6 class="client-name mb-1">Загрузка...</h6>
<div class="d-flex align-items-center">
<span
class="badge bg-primary bg-opacity-10 text-primary fs-7 px-2 py-1 me-2 client-role">
<i class="bi bi-shield-check me-1"></i>
Загрузка...
</span>
</div>
</div>
<!-- Кнопка выхода -->
<div class="dropdown">
<button class="logout-btn btn d-flex align-items-center" type="button" id="clientLogoutBtn">
<i class="bi bi-box-arrow-right me-2"></i>
<span class="d-none d-md-inline">Выйти</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</header>
<!-- Основной контент -->
<main class="container-xxl" id="mainContent">
</main>
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lightbox2/2.11.4/js/lightbox.min.js"></script>
<script type="module" src="{{ getUrl('static', path='js/user.js') }}"></script>
<script type="module" src="{{ getUrl('static', path='js/index.js') }}"></script>
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<meta name="app-secret" content={{content.app_secret}}>
<title>{% block title %}Toolbox{% endblock %}</title>
<link rel="icon" href="{{ getUrl('static', path='favicon.ico') }}">
<link rel="stylesheet" href="{{ getUrl('static', path='css/layout.css') }}">
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
{% block head %}{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
<!-- Bootstrap 5 JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"
crossorigin="anonymous"></script>
<script type="module" src="{{ getUrl('static', path='js/crypto.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
{% extends "layout.html" %}
{% block title %}Вход — Toolbox{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ getUrl('static', path='css/login.css') }}">
<meta name="userAuth-endpoint" content="{{ getUrl('Authentication') }}">
{% endblock %}
{% block body %}
<main class="login-page d-flex justify-content-center align-items-center">
<div class="login-card">
<header class="login-header">
<img src="{{ getUrl('static', path='favicon.ico') }}" alt="Toolbox" class="brand-icon">
<h1>Toolbox — Вход</h1>
</header>
<form id="loginForm" class="login-form" autocomplete="on">
<div class="form-group">
<label for="loginInput">Логин</label>
<input id="loginInput" name="login" type="text" class="form-control" required autofocus>
</div>
<div class="form-group">
<label for="passwordInput">Пароль</label>
<input id="passwordInput" name="password" type="password" class="form-control" required>
</div>
<div id="formError" class="form-error" aria-live="polite" hidden></div>
<div class="form-actions">
<button id="submitBtn" class="btn btn-primary w-100" type="submit">Войти</button>
</div>
</form>
<footer class="login-footer">
<small>Онлайн-сервис учета инструментов</small>
</footer>
</div>
</main>
{% endblock %}
{% block scripts %}
<!-- Подключаем только модули, разделённые по задачам -->
<script type="module" src="{{ getUrl('static', path='js/cookies.js') }}"></script>
<script type="module" src="{{ getUrl('static', path='js/api.js') }}"></script>
<script type="module" src="{{ getUrl('static', path='js/auth.js') }}"></script>
{% endblock %}