37 Commits

Author SHA1 Message Date
2a35386c1d refactor(api): format with ruff 2025-07-02 12:23:22 +05:00
a936500101 refactor(client): remove required fileds 2025-07-01 13:35:40 +05:00
4c0beb24f9 fix(api): on duplicate password update 2025-06-30 17:52:31 +05:00
ad1369c3e3 refactor(api): change UserCreate model fields 2025-06-30 16:30:53 +05:00
5958f29ba8 fix(AccountPage): fix empty string in search params 2025-06-30 14:02:53 +05:00
784be40369 feat(AccountsPage): update accounts list after create user 2025-06-30 12:37:45 +05:00
ad312d4ff8 feat(AccountsPage): loading with search params 2025-06-30 12:19:29 +05:00
ba65f36696 feat(AccountsPage): add page and limit to search params 2025-06-27 16:41:48 +05:00
ad0a4837fc feat(api): add update password 2025-06-27 13:31:32 +05:00
1eadd834e3 feat(client): add update password 2025-06-27 13:25:25 +05:00
8f3fde623f refactor(api): move create password 2025-06-27 12:25:25 +05:00
edfd2c1159 refactor(Makefile): fix alembic 2025-06-26 16:38:57 +05:00
3d8ee4835d refactor(db): increase account_keyring_table.key_value size 2025-06-26 16:38:26 +05:00
9c9201f130 feat(client): add userEdit 2025-06-26 16:15:03 +05:00
0eed0b0f20 refactor(clint-UserEdit): remove login and role on self user 2025-06-26 15:36:45 +05:00
7127d88524 feat(client-Header): add userEdit on header 2025-06-26 15:26:49 +05:00
692461e266 feat: create new user with password 2025-06-26 15:08:05 +05:00
22064d2b52 feat(types): generate new types 2025-06-26 15:04:43 +05:00
febac9659f refactor(Makefile): change source with poetry 2025-06-26 12:27:14 +05:00
6c0a6ac1d4 feat(AccountsPage): add change page size 2025-06-25 21:01:19 +05:00
a7e813b447 feat(UserCreate): add loading animation 2025-06-25 14:09:51 +05:00
919758ab69 fix: bind tenant id 2025-06-25 13:54:48 +05:00
53bf173373 fix(api): fix get_user_by_id method 2025-06-25 13:39:16 +05:00
8f5dd07bf5 feat(client): add create user 2025-06-25 13:38:43 +05:00
448e4264a5 feat(AccountPage): add destroyOnHidden to ContentDrawer and fix tenant and login 2025-06-24 16:30:13 +05:00
e5dfdc3464 feat(AccountsPage): add userEdit 2025-06-24 16:23:43 +05:00
aae56a8c73 feat(AccountsPage): add on table change 2025-06-24 13:19:17 +05:00
7c2c4071cc feat(Makefile): add regenerate-openapi-local command 2025-06-24 13:17:28 +05:00
18bb79262c feat(api): add current page to AllUserResponse and fix returning type 2025-06-24 13:12:31 +05:00
a3ee18f6fd feat: add accounts table 2025-06-24 13:00:40 +05:00
71ab39a03c chore: update client patch version to 0.0.4 2025-06-24 12:56:57 +05:00
5d39065a7f Merge pull request 'VORKOUT-15' (#12) from VORKOUT-15 into master
Reviewed-on: #12
2025-06-24 12:56:16 +05:00
92ff1d3d0a refactor: Makefile 2025-06-23 13:08:07 +05:00
5ed8ca9251 chore(client): update sctucture after switching to vite 2025-06-23 13:07:51 +05:00
8ac329e76e feat: add readme 2025-06-23 13:05:13 +05:00
c68b512902 chore: update client patch version to 0.0.3 and api patch version to 0.0.4 2025-06-16 16:38:22 +05:00
70aaaeabf1 Merge pull request 'VORKOUT-6' (#11) from VORKOUT-6 into master
Reviewed-on: #11
Reviewed-by: cyrussmeat <dr.cyrill@gmail.com>
2025-06-16 16:34:24 +05:00
46 changed files with 2915 additions and 15859 deletions

View File

@@ -12,36 +12,35 @@ services:
start-api:
cd api && \
source .venv/bin/activate && \
python -m ${API_APPLICATION_NAME}
poetry run python -m ${API_APPLICATION_NAME}
start-client:
cd client && \
npm start
npm run dev
migrate:
cd api && \
source .venv/bin/activate && \
cd $(API_APPLICATION_NAME)/db && \
PYTHONPATH='../..' ALEMBIC_MIGRATIONS=True alembic upgrade $(args)
PYTHONPATH='../..' ALEMBIC_MIGRATIONS=True poetry run alembic upgrade $(args)
downgrade:
cd api && \
source .venv/bin/activate && \
cd $(API_APPLICATION_NAME)/db && \
PYTHONPATH='../..' alembic downgrade -1
PYTHONPATH='../..' poetry run alembic downgrade -1
revision:
cd api && \
source .venv/bin/activate && \
cd $(API_APPLICATION_NAME)/db && \
PYTHONPATH='../..' ALEMBIC_MIGRATIONS=True alembic revision --autogenerate
PYTHONPATH='../..' ALEMBIC_MIGRATIONS=True poetry run alembic revision --autogenerate
venv-api:
cd api && \
poetry env activate \
poetry install
venv-client:
cd client && \
npm install
install:
make migrate head && \
cd api && \
@@ -58,3 +57,8 @@ format-api:
check-api:
cd api && \
poetry run ruff format . --check
regenerate-openapi-local:
cd client \
rm src/types/openapi-types.ts \
npx openapi-typescript http://localhost:8000/openapi -o src/types/openapi-types.ts

View File

@@ -1 +1,49 @@
Vorkout/connect
# Vorkout/connect
### Makefile cheat sheet
```Makefile
Dev:
venv-api create python virtual environment
venv-client install node modules
install Migrate database and initialize project
Application Api:
start-api Run api server
Application Client:
start-client Run client server
Prod:
...
Code:
check-api Check api code with ruff
format-api Reformat api code with ruff
Help:
...
Testing:
...
```
### Запуск в режиме разработки
Для запуска в режиме разработки нужно
1. Устрановить среду для clint и api
2. Запустить в докере или локально необходимые сервисы (базуб брокер и redis) `make services`
3. Для миграции и создания первого пользователя необходимо запустить `make install`
3. Запустить api `make start-api`
4. Запустить client `make start-client`
### Миграции алембик
1. Стоит внимательно учитывать, адрес какой базы стоит в настройках alembic - локальной или продакшн. Посмотреть это можно в файле [env.py](connect/api/api/db/alembic/env.py). Конфиг для локальной базы
```python
config.set_main_option(
"sqlalchemy.url",
f"mysql+pymysql://root:hackme@localhost:3306/connect_test",
)
```

View File

@@ -0,0 +1,38 @@
"""empty message
Revision ID: 93106fbe7d83
Revises: f1b06efacec0
Create Date: 2025-06-26 16:36:02.270706
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = '93106fbe7d83'
down_revision: Union[str, None] = 'f1b06efacec0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('account_keyring', 'key_value',
existing_type=mysql.VARCHAR(length=255),
type_=sa.String(length=512),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('account_keyring', 'key_value',
existing_type=sa.String(length=512),
type_=mysql.VARCHAR(length=255),
existing_nullable=False)
# ### end Alembic commands ###

View File

@@ -1,19 +1,17 @@
from typing import Optional
import math
from datetime import datetime, timezone
from sqlalchemy import insert, select, func
from sqlalchemy.ext.asyncio import AsyncConnection
from enum import Enum
from typing import Optional
from sqlalchemy import func, insert, select
from sqlalchemy.ext.asyncio import AsyncConnection
from api.db.tables.account import account_table
from api.schemas.account.account import User
from api.schemas.endpoints.account import AllUserResponse, all_user_adapter
from api.schemas.endpoints.account import all_user_adapter, AllUser, AllUserResponse, UserCreate
async def get_user_accaunt_page(connection: AsyncConnection, page, limit) -> Optional[User]:
async def get_user_accaunt_page(connection: AsyncConnection, page, limit) -> Optional[AllUserResponse]:
"""
Получает список ползовелей заданных значениями page, limit.
"""
@@ -47,31 +45,28 @@ async def get_user_accaunt_page(connection: AsyncConnection, page, limit) -> Opt
validated_users = all_user_adapter.validate_python(users_data)
return AllUserResponse(users=validated_users, amount_count=total_count, amount_pages=total_pages)
return AllUserResponse(
users=validated_users,
amount_count=total_count,
amount_pages=total_pages,
current_page=page,
limit=limit,
)
async def get_user_by_id(connection: AsyncConnection, id: int) -> Optional[User]:
async def get_user_by_id(connection: AsyncConnection, user_id: int) -> Optional[AllUser]:
"""
Получает юзера по id.
"""
query = select(account_table).where(account_table.c.id == id)
query = select(account_table).where(account_table.c.id == user_id)
user_db_cursor = await connection.execute(query)
user_db = user_db_cursor.one_or_none()
user = user_db_cursor.mappings().one_or_none()
if not user_db:
if not user:
return None
user_data = {
column.name: (
getattr(user_db, column.name).name
if isinstance(getattr(user_db, column.name), Enum)
else getattr(user_db, column.name)
)
for column in account_table.columns
}
return User.model_validate(user_data)
return AllUser.model_validate(user)
async def get_user_by_login(connection: AsyncConnection, login: str) -> Optional[User]:
@@ -107,7 +102,7 @@ async def update_user_by_id(connection: AsyncConnection, update_values, user) ->
await connection.commit()
async def create_user(connection: AsyncConnection, user: User, creator_id: int) -> Optional[User]:
async def create_user(connection: AsyncConnection, user: UserCreate, creator_id: int) -> Optional[AllUser]:
"""
Создает нове поле в таблице account_table.
"""
@@ -117,14 +112,15 @@ async def create_user(connection: AsyncConnection, user: User, creator_id: int)
email=user.email,
bind_tenant_id=user.bind_tenant_id,
role=user.role.value,
meta=user.meta,
meta=user.meta or {},
creator_id=creator_id,
created_at=datetime.now(timezone.utc),
status=user.status.value,
)
await connection.execute(query)
res = await connection.execute(query)
await connection.commit()
new_user = await get_user_by_id(connection, res.lastrowid)
return user
return new_user

View File

@@ -8,13 +8,14 @@ from api.db.tables.account import account_table, account_keyring_table, KeyType,
from api.schemas.account.account import User
from api.schemas.account.account_keyring import AccountKeyring
from api.schemas.endpoints.account import AllUser
from api.utils.key_id_gen import KeyIdGenerator
from datetime import datetime, timezone
async def get_user(connection: AsyncConnection, login: str) -> Optional[User]:
async def get_user(connection: AsyncConnection, login: str) -> tuple[Optional[AllUser], Optional[AccountKeyring]]:
query = (
select(account_table, account_keyring_table)
.join(account_keyring_table, account_table.c.id == account_keyring_table.c.owner_id)
@@ -45,7 +46,7 @@ async def get_user(connection: AsyncConnection, login: str) -> Optional[User]:
for column in account_keyring_table.columns
}
user = User.model_validate(user_data)
user = AllUser.model_validate(user_data)
password = AccountKeyring.model_validate(password_data)
return user, password

View File

@@ -1,13 +1,14 @@
from typing import Optional
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Optional
from sqlalchemy import insert, select
from sqlalchemy import insert, select, update
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.ext.asyncio import AsyncConnection
from api.db.tables.account import account_keyring_table
from api.db.tables.account import account_keyring_table, KeyStatus, KeyType
from api.schemas.account.account_keyring import AccountKeyring
from api.utils.hasher import hasher
async def get_key_by_id(connection: AsyncConnection, key_id: str) -> Optional[AccountKeyring]:
@@ -67,3 +68,37 @@ async def create_key(connection: AsyncConnection, key: AccountKeyring, key_id: i
await connection.commit()
return key
async def create_password_key(connection: AsyncConnection, password: str | None, owner_id: int):
if password is None:
password = hasher.generate_password()
hashed_password = hasher.hash_data(password)
stmt = mysql_insert(account_keyring_table).values(
owner_id=owner_id,
key_type=KeyType.PASSWORD.value,
key_id="PASSWORD",
key_value=hashed_password,
created_at=datetime.now(timezone.utc),
expiry=datetime.now(timezone.utc) + timedelta(days=365),
status=KeyStatus.ACTIVE,
)
stmt.on_duplicate_key_update(key_value=hashed_password)
await connection.execute(stmt)
await connection.commit()
async def update_password_key(connection: AsyncConnection, owner_id: int, password: str):
stmt = select(account_keyring_table).where(account_keyring_table.c.owner_id == owner_id)
result = await connection.execute(stmt)
keyring = result.one_or_none()
if not keyring:
await create_password_key(connection, password, owner_id)
else:
stmt = (
update(account_keyring_table)
.values(key_value=hasher.hash_data(password), expiry=datetime.now(timezone.utc) + timedelta(days=365))
.where(account_keyring_table.c.owner_id == owner_id)
)
await connection.execute(stmt)
await connection.commit()

View File

@@ -60,7 +60,7 @@ account_keyring_table = Table(
Column("owner_id", UnsignedInt, ForeignKey("account.id"), primary_key=True, nullable=False),
Column("key_type", SQLAEnum(KeyType), primary_key=True, nullable=False),
Column("key_id", String(40), primary_key=True, default=None),
Column("key_value", String(255), nullable=False),
Column("key_value", String(512), nullable=False),
Column("created_at", DateTime(timezone=True), server_default=func.now()),
Column("expiry", DateTime(timezone=True), nullable=True),
Column("status", SQLAEnum(KeyStatus), nullable=False),

View File

@@ -4,29 +4,24 @@ from fastapi import (
HTTPException,
status,
)
from sqlalchemy.ext.asyncio import AsyncConnection
from api.db.connection.session import get_connection_dep
from api.db.logic.account import (
get_user_by_id,
update_user_by_id,
create_user,
get_user_by_login,
get_user_accaunt_page,
get_user_by_id,
get_user_by_login,
update_user_by_id,
)
from api.schemas.account.account import User
from api.db.logic.keyring import create_password_key, update_password_key
from api.db.tables.account import AccountStatus
from api.schemas.account.account import User
from api.schemas.base import bearer_schema
from api.schemas.endpoints.account import UserUpdate, AllUserResponse
from api.schemas.endpoints.account import AllUser, AllUserResponse, UserCreate, UserUpdate
from api.services.auth import get_current_user
from api.services.user_role_validation import db_user_role_validation
from api.services.update_data_validation import update_user_data_changes
from api.services.user_role_validation import db_user_role_validation
api_router = APIRouter(
prefix="/account",
@@ -51,9 +46,11 @@ async def get_all_account(
return user_list
@api_router.get("/{user_id}", dependencies=[Depends(bearer_schema)], response_model=User)
@api_router.get("/{user_id}", dependencies=[Depends(bearer_schema)], response_model=UserUpdate)
async def get_account(
user_id: int, connection: AsyncConnection = Depends(get_connection_dep), current_user=Depends(get_current_user)
user_id: int,
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
authorize_user = await db_user_role_validation(connection, current_user)
@@ -65,26 +62,27 @@ async def get_account(
return user
@api_router.post("", dependencies=[Depends(bearer_schema)], response_model=User)
@api_router.post("", dependencies=[Depends(bearer_schema)], response_model=AllUser)
async def create_account(
user: UserUpdate, connection: AsyncConnection = Depends(get_connection_dep), current_user=Depends(get_current_user)
user: UserCreate,
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
authorize_user = await db_user_role_validation(connection, current_user)
user_validation = await get_user_by_login(connection, user.login)
if user_validation is None:
await create_user(connection, user, authorize_user.id)
user_new = await get_user_by_login(connection, user.login)
return user_new
new_user = await create_user(connection, user, authorize_user.id)
await create_password_key(connection, user.password, new_user.id)
return new_user
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="An account with this information already exists."
)
@api_router.put("/{user_id}", dependencies=[Depends(bearer_schema)], response_model=User)
@api_router.put("/{user_id}", dependencies=[Depends(bearer_schema)], response_model=UserUpdate)
async def update_account(
user_id: int,
user_update: UserUpdate,
@@ -97,12 +95,15 @@ async def update_account(
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
if user_update.password is not None:
await update_password_key(connection, user.id, user_update.password)
update_values = update_user_data_changes(user_update, user)
if update_values is None:
return user
user_update_data = User.model_validate({**user.model_dump(), **update_values})
user_update_data = UserUpdate.model_validate({**user.model_dump(), **update_values})
await update_user_by_id(connection, update_values, user)
@@ -113,7 +114,9 @@ async def update_account(
@api_router.delete("/{user_id}", dependencies=[Depends(bearer_schema)], response_model=User)
async def delete_account(
user_id: int, connection: AsyncConnection = Depends(get_connection_dep), current_user=Depends(get_current_user)
user_id: int,
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
authorize_user = await db_user_role_validation(connection, current_user)

View File

@@ -1,9 +1,9 @@
from typing import Optional, List
from datetime import datetime
from typing import List, Optional
from pydantic import EmailStr, Field, TypeAdapter
from api.db.tables.account import AccountRole, AccountStatus
from api.schemas.base import Base
@@ -12,6 +12,7 @@ class UserUpdate(Base):
name: Optional[str] = Field(None, max_length=100)
login: Optional[str] = Field(None, max_length=100)
email: Optional[EmailStr] = None
password: Optional[str] = None
bind_tenant_id: Optional[str] = Field(None, max_length=40)
role: Optional[AccountRole] = None
meta: Optional[dict] = None
@@ -20,6 +21,17 @@ class UserUpdate(Base):
status: Optional[AccountStatus] = None
class UserCreate(Base):
name: str = Field(max_length=100)
login: str = Field(max_length=100)
email: Optional[EmailStr] = None
password: Optional[str] = None
bind_tenant_id: Optional[str] = Field(None, max_length=40)
role: AccountRole
meta: Optional[dict] = None
status: AccountStatus
class AllUser(Base):
id: int
name: str
@@ -35,6 +47,8 @@ class AllUserResponse(Base):
users: List[AllUser]
amount_count: int
amount_pages: int
current_page: int
limit: int
all_user_adapter = TypeAdapter(List[AllUser])

View File

@@ -1,27 +1,25 @@
from fastapi import Request, HTTPException
from typing import Optional
from fastapi import HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncConnection
from api.db.logic.auth import get_user
# # from backend.schemas.users.token import TokenData
from api.schemas.account.account import User
from api.db.tables.account import AccountStatus
from api.utils.hasher import Hasher
from api.schemas.endpoints.account import AllUser
from api.utils.hasher import hasher
async def get_current_user(request: Request) -> Optional[User]:
async def get_current_user(request: Request) -> str | HTTPException:
if not hasattr(request.state, "current_user"):
return HTTPException(status_code=401, detail="Unauthorized")
return request.state.current_user
async def authenticate_user(connection: AsyncConnection, username: str, password: str) -> Optional[User]:
async def authenticate_user(connection: AsyncConnection, username: str, password: str) -> Optional[AllUser]:
sql_user, sql_password = await get_user(connection, username)
if not sql_user or sql_user.status != AccountStatus.ACTIVE:
return None
hasher = Hasher()
if not hasher.verify_data(password, sql_password.key_value):
return None
return sql_user

View File

@@ -1,4 +1,6 @@
import hashlib
import secrets
# Хешер для работы с паролем.
@@ -14,3 +16,10 @@ class Hasher:
def verify_data(self, password: str, hashed: str) -> bool:
# Проверяет пароль путем сравнения его хеша с сохраненным хешем.
return self.hash_data(password) == hashed
@staticmethod
def generate_password() -> str:
return secrets.token_urlsafe(20)
hasher = Hasher()

View File

@@ -1,32 +1,23 @@
import os
import asyncio
import hashlib
import secrets
import os
from api.db.connection.session import get_connection
from api.db.tables.account import account_table, account_keyring_table, AccountRole, KeyType, KeyStatus
from api.db.tables.account import account_keyring_table, account_table, AccountRole, KeyStatus, KeyType
from api.utils.hasher import hasher
from api.utils.key_id_gen import KeyIdGenerator
INIT_LOCK_FILE = "../init.lock"
DEFAULT_LOGIN = "vorkout"
def hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
def generate_password() -> str:
return secrets.token_urlsafe(20)
async def init():
if os.path.exists(INIT_LOCK_FILE):
print("Sorry, service is already initialized")
return
async with get_connection() as conn:
password = generate_password()
hashed_password = hash_password(password)
password = hasher.generate_password()
hashed_password = hasher.hash_data(password)
create_user_query = account_table.insert().values(
name=DEFAULT_LOGIN,

View File

@@ -1,10 +1,8 @@
[project]
name = "api"
version = "0.0.3"
version = "0.0.4"
description = ""
authors = [
{name = "Vladislav",email = "vlad.dev@heado.ru"}
]
authors = [{ name = "Vladislav", email = "vlad.dev@heado.ru" }]
readme = "README.md"
requires-python = ">=3.11,<4.0"
dependencies = [

View File

@@ -1,5 +1,4 @@
REACT_APP_WEBSOCKET_PROTOCOL=ws
REACT_APP_HTTP_PROTOCOL=http
REACT_APP_API_URL=localhost:8000
REACT_APP_URL=localhost:3000
BROWSER=none
VITE_APP_WEBSOCKET_PROTOCOL=ws
VITE_APP_HTTP_PROTOCOL=http
VITE_APP_API_URL=localhost:8000
VITE_APP_URL=localhost:3000

17
client/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site created using Vite" />
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>VORKOUT</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

17454
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "client",
"version": "0.0.2",
"version": "0.0.4",
"private": true,
"dependencies": {
"@ant-design/icons": "^5.6.1",
@@ -9,7 +9,6 @@
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.126",
"@types/react": "^19.0.11",
"@types/react-dom": "^19.0.4",
"antd": "^5.24.7",
@@ -21,16 +20,13 @@
"react-dom": "^18.3.1",
"react-i18next": "^15.5.1",
"react-router-dom": "^7.5.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4",
"zustand": "^5.0.5"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"eslintConfig": {
"extends": [
@@ -49,5 +45,13 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
"@types/node": "^20.19.1",
"@vitejs/plugin-react": "^4.5.2",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vite-plugin-node-polyfills": "^0.23.0"
}
}

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
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" />
<!--
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.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
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>VORKOUT</title>
</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.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@@ -1,10 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useEffect } from 'react';
import { Route, Routes } from 'react-router-dom';
import MainLayout from './pages/MainLayout';
import ProtectedRoute from './pages/ProtectedRoute';
import LoginPage from './pages/LoginPage';
import { useSetUserSelector } from './store/userStore';
import LoginPage from './pages/LoginPage';
import ProtectedRoute from './pages/ProtectedRoute';
import MainLayout from './pages/MainLayout';
function App() {
const setUser = useSetUserSelector();

View File

@@ -1,11 +1,13 @@
import axios from 'axios';
import { Auth, Tokens } from '../types/auth';
import { User } from '../types/user';
import { AuthService } from '../services/authService';
import axiosRetry from 'axios-retry';
import { useAuthStore } from '../store/authStore';
import { Auth, Tokens } from '@/types/auth';
import { useAuthStore } from '@/store/authStore';
import { AuthService } from '@/services/authService';
import { User, UserCreate, UserUpdate } from '@/types/user';
const baseURL = `${process.env.REACT_APP_HTTP_PROTOCOL}://${process.env.REACT_APP_API_URL}/api/v1`;
const baseURL = `${import.meta.env.VITE_APP_HTTP_PROTOCOL}://${
import.meta.env.VITE_APP_API_URL
}/api/v1`;
const base = axios.create({
baseURL,
@@ -45,6 +47,9 @@ base.interceptors.response.use(
return response;
},
async function (error) {
if (!error.response) {
return Promise.reject(error);
}
console.log('error', error);
const originalRequest = error.response.config;
const urlTokens = error?.request?.responseURL.split('/');
@@ -103,6 +108,23 @@ const api = {
);
return response.data;
},
async getUserById(userId: number): Promise<User> {
const response = await base.get<User>(`/account/${userId}`);
return response.data;
},
async createUser(user: UserCreate): Promise<User> {
const response = await base.post<User>('/account', user);
return response.data;
},
async updateUser(userId: number, user: UserUpdate): Promise<User> {
const response = await base.put<User>(`/account/${userId}`, user);
return response.data;
},
// keyrings
};
export default api;

View File

@@ -2,12 +2,16 @@ import { Drawer } from 'antd';
import { useEffect, useState } from 'react';
import { Avatar, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { useUserSelector } from '@/store/userStore';
interface ContentDrawerProps {
open: boolean;
closeDrawer: () => void;
children: React.ReactNode;
type: 'create' | 'edit';
login?: string;
name?: string;
email?: string | null;
}
export default function ContentDrawer({
@@ -15,7 +19,11 @@ export default function ContentDrawer({
closeDrawer,
children,
type,
login,
name,
email,
}: ContentDrawerProps) {
const user = useUserSelector();
const { t } = useTranslation();
const [width, setWidth] = useState<number | string>('30%');
@@ -30,6 +38,7 @@ export default function ContentDrawer({
window.addEventListener('resize', calculateWidths);
return () => window.removeEventListener('resize', calculateWidths);
}, []);
console.log(login, user?.login, login === user?.login);
const editDrawerTitle = (
<div
@@ -59,16 +68,21 @@ export default function ContentDrawer({
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1 }}>
<Avatar
src="https://cdn-icons-png.flaticon.com/512/219/219986.png"
src={
login ? `https://gamma.heado.ru/go/ava?name=${login}` : undefined
}
size={40}
style={{ flexShrink: 0 }}
/>
<div>
<Typography.Text strong style={{ display: 'block' }}>
Александр Александров
<Typography.Text
strong
style={{ display: 'block', fontSize: '20px' }}
>
{name} {login === user?.login ? t('you') : ''}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 14 }}>
alexandralex@vorkout.ru
{email}
</Typography.Text>
</div>
</div>
@@ -152,7 +166,7 @@ export default function ContentDrawer({
placement="right"
open={open}
width={width}
destroyOnClose={true}
destroyOnHidden={true}
closable={false}
>
{children}

View File

@@ -1,5 +1,9 @@
import { useUserSelector } from '@/store/userStore';
import { Avatar } from 'antd';
import Title from 'antd/es/typography/Title';
import { useState } from 'react';
import ContentDrawer from './ContentDrawer';
import UserEdit from './UserEdit';
interface HeaderProps {
title: string;
@@ -7,6 +11,13 @@ interface HeaderProps {
}
export default function Header({ title, additionalContent }: HeaderProps) {
const [openEdit, setOpenEdit] = useState(false);
const showEditDrawer = () => setOpenEdit(true);
const closeEditDrawer = () => {
setOpenEdit(false);
};
const user = useUserSelector();
return (
<div
style={{
@@ -43,13 +54,24 @@ export default function Header({ title, additionalContent }: HeaderProps) {
alignItems: 'center',
justifyContent: 'center',
}}
onClick={showEditDrawer}
>
<Avatar
size={25.77}
src={`https://cdn-icons-png.flaticon.com/512/219/219986.png`}
src={`https://gamma.heado.ru/go/ava?name=${user?.login}`}
/>
</div>
</div>
<ContentDrawer
login={user?.login}
name={user?.name}
email={user?.email}
open={openEdit}
closeDrawer={closeEditDrawer}
type="edit"
>
{user?.id && <UserEdit closeDrawer={closeEditDrawer} userId={user?.id} />}
</ContentDrawer>
</div>
);
}

View File

@@ -1,3 +1,4 @@
import { useUserSelector } from '@/store/userStore';
import { Divider, Menu, Tooltip } from 'antd';
import React from 'react';
import { useTranslation } from 'react-i18next';
@@ -13,6 +14,7 @@ export default function SiderMenu({
selectedKey,
hangleMenuClick,
}: SiderMenuProps) {
const user = useUserSelector();
const { t } = useTranslation();
const collapseStyle = collapsed
? { fontSize: '12px' }
@@ -74,7 +76,8 @@ export default function SiderMenu({
label: t('settings'),
className: 'no-expand-icon',
children: [
{
user && (user.role === 'OWNER' || user.role === 'ADMIN')
? {
key: '/accounts',
label: !collapsed ? (
<Tooltip title={t('accounts')}>{t('accounts')}</Tooltip>
@@ -82,7 +85,8 @@ export default function SiderMenu({
t('accounts')
),
style: collapseStyle,
},
}
: undefined,
{
key: '/events-list',
label: !collapsed ? (

View File

@@ -8,13 +8,19 @@ import {
UploadFile,
GetProp,
UploadProps,
} from 'antd';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
message,
Spin,
} from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useUserSelector } from "@/store/userStore";
import { UserCreate as NewUserCreate } from "@/types/user";
import { UserService } from "@/services/userService";
import { LoadingOutlined } from "@ant-design/icons";
const { Option } = Select;
type FileType = Parameters<GetProp<UploadProps, 'beforeUpload'>>[0];
type FileType = Parameters<GetProp<UploadProps, "beforeUpload">>[0];
const getBase64 = (file: FileType): Promise<string> =>
new Promise((resolve, reject) => {
@@ -24,10 +30,17 @@ const getBase64 = (file: FileType): Promise<string> =>
reader.onerror = (error) => reject(error);
});
export default function UserCreate() {
interface UserCreateProps {
closeDrawer: () => void;
getUsers: () => Promise<void>;
}
export default function UserCreate({ closeDrawer, getUsers }: UserCreateProps) {
const user = useUserSelector();
const { t } = useTranslation();
const [previewOpen, setPreviewOpen] = useState(false);
const [previewImage, setPreviewImage] = useState('');
const [previewImage, setPreviewImage] = useState("");
const [loading, setLoading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
@@ -40,40 +53,49 @@ export default function UserCreate() {
setPreviewOpen(true);
};
const handleChange: UploadProps['onChange'] = ({ fileList: newFileList }) =>
const handleChange: UploadProps["onChange"] = ({ fileList: newFileList }) =>
setFileList(newFileList);
const onFinish = async (values: NewUserCreate) => {
setLoading(true);
await UserService.createUser(values);
await getUsers();
closeDrawer();
setLoading(false);
message.info(t("createdAccountMessage"), 4);
};
const customUploadButton = (
<div>
<div
style={{
height: '102px',
width: '102px',
backgroundColor: '#E2E2E2',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: "102px",
width: "102px",
backgroundColor: "#E2E2E2",
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: 8,
marginTop: 30,
cursor: 'pointer',
cursor: "pointer",
}}
>
<img
src="./icons/drawer/add_photo_alternate.svg"
alt="add_photo_alternate"
style={{ height: '18px', width: '18px' }}
style={{ height: "18px", width: "18px" }}
/>
</div>
<span style={{ fontSize: '14px', color: '#8c8c8c' }}>
{t('selectPhoto')}
<span style={{ fontSize: "14px", color: "#8c8c8c" }}>
{t("selectPhoto")}
</span>
</div>
);
const photoToUpload = (
<div style={{ height: '102px' }}>
<div style={{ height: "102px" }}>
<Upload
listType="picture-circle"
fileList={fileList}
@@ -85,11 +107,11 @@ export default function UserCreate() {
</Upload>
{previewImage && (
<Image
wrapperStyle={{ display: 'none' }}
wrapperStyle={{ display: "none" }}
preview={{
visible: previewOpen,
onVisibleChange: (visible) => setPreviewOpen(visible),
afterOpenChange: (visible) => !visible && setPreviewImage(''),
afterOpenChange: (visible) => !visible && setPreviewImage(""),
}}
src={previewImage}
/>
@@ -100,24 +122,24 @@ export default function UserCreate() {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
display: "flex",
flexDirection: "column",
height: "100%",
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '36px',
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: "36px",
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
{photoToUpload}
@@ -126,83 +148,85 @@ export default function UserCreate() {
<Form
name="user-edit-form"
layout="vertical"
// onFinish={onFinish}
onFinish={onFinish}
initialValues={{
name: '',
login: '',
password: '',
email: '',
tenant: '',
role: '',
status: '',
name: "",
login: "",
password: "",
email: "",
bindTenantId: "",
role: "",
status: "",
}}
style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
style={{ flex: 1, display: "flex", flexDirection: "column" }}
>
<Form.Item
label={t('name')}
label={t("name")}
name="name"
rules={[{ required: true, message: t('nameMessage') }]}
rules={[{ required: true, message: t("nameMessage") }]}
>
<Input />
</Form.Item>
<Form.Item
label={t('login')}
label={t("login")}
name="login"
rules={[{ required: true, message: t('loginMessage') }]}
rules={[{ required: true, message: t("loginMessage") }]}
>
<Input />
</Form.Item>
<Form.Item
label={t('password')}
label={t("password")}
name="password"
rules={[{ required: true, message: t('passwordMessage') }]}
rules={[{ message: t("passwordMessage") }]}
>
<Input.Password />
</Form.Item>
<Form.Item
label={t('email')}
label={t("email")}
name="email"
rules={[
{ required: true, message: t('emailMessage') },
{ type: 'email', message: t('emailErrorMessage') },
{ message: t("emailMessage") },
{ type: "email", message: t("emailErrorMessage") },
]}
>
<Input />
</Form.Item>
<Form.Item
label={t('tenant')}
name="tenant"
rules={[{ required: true, message: t('tenantMessage') }]}
label={t("tenant")}
name="bindTenantId"
rules={[{ message: t("tenantMessage") }]}
>
<Input />
</Form.Item>
<Form.Item
label={t('role')}
label={t("role")}
name="role"
rules={[{ required: true, message: t('roleMessage') }]}
rules={[{ required: true, message: t("roleMessage") }]}
>
<Select placeholder={t('roleMessage')}>
<Option value="Директор магазина">Директор магазина</Option>
<Option value="Менеджер">Менеджер</Option>
<Option value="Кассир">Кассир</Option>
<Select placeholder={t("roleMessage")}>
{user && user.role === "OWNER" ? (
<Option value="ADMIN">{t("ADMIN")}</Option>
) : undefined}
<Option value="EDITOR">{t("EDITOR")}</Option>
<Option value="VIEWER">{t("VIEWER")}</Option>
</Select>
</Form.Item>
<Form.Item
label={t('status')}
label={t("status")}
name="status"
rules={[{ required: true, message: t('statusMessage') }]}
rules={[{ required: true, message: t("statusMessage") }]}
>
<Select placeholder={t('statusMessage')}>
<Option value="ACTIVE">Активен</Option>
<Option value="DISABLED">Неактивен</Option>
<Option value="BLOCKED">Заблокирован</Option>
<Option value="DELETED">Удален</Option>
<Select placeholder={t("statusMessage")}>
<Option value="ACTIVE">{t("ACTIVE")}</Option>
<Option value="DISABLED">{t("DISABLED")}</Option>
<Option value="BLOCKED">{t("BLOCKED")}</Option>
<Option value="DELETED">{t("DELETED")}</Option>
</Select>
</Form.Item>
@@ -213,14 +237,23 @@ export default function UserCreate() {
type="primary"
htmlType="submit"
block
style={{ color: '#000' }}
style={{ color: "#000" }}
>
{loading ? (
<>
<Spin indicator={<LoadingOutlined spin />} size="small"></Spin>{" "}
{t("saving")}
</>
) : (
<>
<img
src="/icons/drawer/reg.svg"
alt="save"
style={{ height: '18px', width: '18px' }}
/>{' '}
{t('addAccount')}
style={{ height: "18px", width: "18px" }}
/>{" "}
{t("addAccount")}
</>
)}
</Button>
</Form.Item>
</Form>

View File

@@ -1,47 +1,101 @@
import { Button, Form, Input, Select } from 'antd';
import { UserService } from '@/services/userService';
import { useUserSelector } from '@/store/userStore';
import { UserUpdate } from '@/types/user';
import { LoadingOutlined } from '@ant-design/icons';
import { Button, Form, Input, message, Select, Spin } from 'antd';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
const { Option } = Select;
export default function UserEdit() {
interface UserEditProps {
userId?: number;
closeDrawer: () => void;
}
export default function UserEdit({ userId, closeDrawer }: UserEditProps) {
const currentUser = useUserSelector();
const [form] = Form.useForm();
const { t } = useTranslation();
const [user, setUser] = useState<UserUpdate>({
id: 0,
name: '',
login: '',
email: '',
password: '',
bindTenantId: '',
role: 'VIEWER',
meta: {},
createdAt: '',
status: 'ACTIVE',
});
const [loading, setLoading] = useState(false);
useEffect(() => {
async function getUser() {
if (typeof userId === 'undefined') {
return;
}
const user = await UserService.getUserById(userId);
setUser(user);
form.setFieldsValue({ ...user });
}
getUser();
}, []);
const onFinish = async (values: UserUpdate) => {
setLoading(true);
let updatedUser: Partial<UserUpdate> = {};
(Object.keys(values) as Array<keyof UserUpdate>).forEach((key) => {
if (values[key] !== user[key]) {
updatedUser = { ...updatedUser, [key]: values[key] };
}
});
if (Object.keys(updatedUser).length > 0) {
console.log('updateUser', userId, updatedUser);
await UserService.updateUser(userId!, updatedUser);
}
setLoading(false);
message.info(t('editAccountMessage'), 4);
closeDrawer();
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Form
form={form}
name="user-edit-form"
layout="vertical"
// onFinish={onFinish}
initialValues={{
name: 'Александр Александров',
login: 'alexandralex@vorkout.ru',
password: 'jKUUl776GHd',
email: 'alexandralex@vorkout.ru',
tenant: 'text',
role: 'Директор магазина',
status: 'Активен',
}}
onFinish={onFinish}
initialValues={{ ...user }}
style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
>
<Form.Item
label={t('name')}
name="name"
rules={[{ required: true, message: t('nameMessage') }]}
rules={[{ message: t('nameMessage') }]}
>
<Input />
</Form.Item>
{user?.id === currentUser?.id ? undefined : (
<Form.Item
label={t('login')}
name="login"
rules={[{ required: true, message: t('loginMessage') }]}
rules={[{ message: t('loginMessage') }]}
>
<Input />
</Form.Item>
)}
<Form.Item
label={t('password')}
name="password"
rules={[{ required: true, message: t('passwordMessage') }]}
rules={[{ message: t('passwordMessage') }]}
>
<Input.Password />
</Form.Item>
@@ -59,23 +113,27 @@ export default function UserEdit() {
<Form.Item
label={t('tenant')}
name="tenant"
name="bindTenantId"
rules={[{ required: true, message: t('tenantMessage') }]}
>
<Input />
</Form.Item>
{user?.id === currentUser?.id ? undefined : (
<Form.Item
label={t('role')}
name="role"
rules={[{ required: true, message: t('roleMessage') }]}
>
<Select placeholder={t('roleMessage')}>
<Option value="Директор магазина">Директор магазина</Option>
<Option value="Менеджер">Менеджер</Option>
<Option value="Кассир">Кассир</Option>
{currentUser && currentUser.role === 'OWNER' ? (
<Option value="ADMIN">{t('ADMIN')}</Option>
) : undefined}
<Option value="EDITOR">{t('EDITOR')}</Option>
<Option value="VIEWER">{t('VIEWER')}</Option>
</Select>
</Form.Item>
)}
<Form.Item
label={t('status')}
@@ -83,10 +141,10 @@ export default function UserEdit() {
rules={[{ required: true, message: t('statusMessage') }]}
>
<Select placeholder={t('statusMessage')}>
<Option value="ACTIVE">Активен</Option>
<Option value="DISABLED">Неактивен</Option>
<Option value="BLOCKED">Заблокирован</Option>
<Option value="DELETED">Удален</Option>
<Option value="ACTIVE">{t('ACTIVE')}</Option>
<Option value="DISABLED">{t('DISABLED')}</Option>
<Option value="BLOCKED">{t('BLOCKED')}</Option>
<Option value="DELETED">{t('DELETED')}</Option>
</Select>
</Form.Item>
@@ -99,12 +157,21 @@ export default function UserEdit() {
block
style={{ color: '#000' }}
>
{loading ? (
<>
<Spin indicator={<LoadingOutlined spin />} size="small"></Spin>{' '}
{t('saving')}
</>
) : (
<>
<img
src="/icons/drawer/save.svg"
alt="save"
style={{ height: '18px', width: '18px' }}
/>{' '}
{t('save')}
</>
)}
</Button>
</Form.Item>
</Form>

View File

@@ -1,8 +1,9 @@
import './i18n';
import '@/config/i18n';
import { ConfigProvider } from 'antd';
import { useTranslation } from 'react-i18next';
import { BrowserRouter } from 'react-router-dom';
import { theme } from './customTheme';
import { theme } from '@/config/customTheme';
import en from 'antd/locale/en_US';
import ru from 'antd/locale/ru_RU';
@@ -18,7 +19,7 @@ export default function AppWrapper({ children }: any) {
return (
<ConfigProvider locale={antdLocales[currentLang]} theme={theme}>
{children}
<BrowserRouter>{children}</BrowserRouter>
</ConfigProvider>
);
}

View File

@@ -37,6 +37,20 @@ i18n
addAccount: 'Add account',
save: 'Save changes',
newAccount: 'New account',
ACTIVE: 'Active',
DISABLED: 'Disabled',
BLOCKED: 'Blocked',
DELETED: 'Deleted',
OWNER: 'Owner',
ADMIN: 'Admin',
EDITOR: 'Editor',
VIEWER: 'Viewer',
nameLogin: 'Name, login',
createdAt: 'Created',
saving: 'Saving...',
createdAccountMessage: 'User successfully created!',
editAccountMessage: 'User successfully updated!',
you: '(You)',
},
},
ru: {
@@ -66,6 +80,20 @@ i18n
addAccount: 'Добавить аккаунт',
save: 'Сохранить изменения',
newAccount: 'Новая учетная запись',
ACTIVE: 'Активен',
DISABLED: 'Выключен',
BLOCKED: 'Заблокирован',
DELETED: 'Удален',
OWNER: 'Владелец',
ADMIN: 'Админ',
EDITOR: 'Редактор',
VIEWER: 'Наблюдатель',
nameLogin: 'Имя, Логин',
createdAt: 'Создано',
saving: 'Сохранение...',
createdAccountMessage: 'Пользователь успешно создан!',
editAccountMessage: 'Пользователь успешно обновлен!',
you: '(Вы)',
},
},
},

12
client/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_WEBSOCKET_PROTOCOL: string;
readonly VITE_APP_HTTP_PROTOCOL: string;
readonly VITE_APP_API_URL: string;
readonly VITE_APP_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -1,9 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { BrowserRouter } from 'react-router-dom';
import AppWrapper from './config/AppWrapper';
import '@/index.css';
import App from '@/App';
import AppWrapper from '@/config/AppWrapper';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
@@ -11,8 +10,6 @@ const root = ReactDOM.createRoot(
root.render(
<AppWrapper>
<BrowserRouter>
<App />
</BrowserRouter>
</AppWrapper>
);

View File

@@ -1,42 +1,219 @@
import Header from '../components/Header';
import { useState } from 'react';
import ContentDrawer from '../components/ContentDrawer';
import UserCreate from '../components/UserCreate';
import { useTranslation } from 'react-i18next';
import { Button } from 'antd';
import { UserService } from '../services/userService';
import { User } from '../types/user';
import { AuthService } from '../services/authService';
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AccountStatus, AllUser, AllUserResponse } from "@/types/user";
import Header from "@/components/Header";
import ContentDrawer from "@/components/ContentDrawer";
import UserCreate from "@/components/UserCreate";
import { Avatar, Table } from "antd";
import { TableProps } from "antd/lib";
import { UserService } from "@/services/userService";
import UserEdit from "@/components/UserEdit";
import { useSearchParams } from "react-router-dom";
export default function AccountsPage() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [openCreate, setOpenCreate] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const showDrawer = () => setOpen(true);
const closeDrawer = () => setOpen(false);
const [activeAccount, setActiveAccount] = useState<
{ login: string; id: number; name: string; email: string } | undefined
>(undefined);
const [accounts, setAccounts] = useState<User[]>([]);
const showCreateDrawer = () => setOpenCreate(true);
const closeCreateDrawer = () => {
setActiveAccount(undefined);
setOpenCreate(false);
};
const [openEdit, setOpenEdit] = useState(false);
const showEditDrawer = () => setOpenEdit(true);
const closeEditDrawer = () => {
setActiveAccount(undefined);
setOpenEdit(false);
};
const [accounts, setAccounts] = useState<AllUserResponse>({
amountCount: 0,
amountPages: 0,
users: [],
currentPage: 1,
limit: 10,
});
async function getUsers() {
const page = Number(searchParams.get("page") || "1");
const limit = Number(searchParams.get("limit") || "10");
setSearchParams({
page: page.toString(),
limit: limit.toString(),
});
const data = await UserService.getUsers(page, limit);
console.log("searchParams", searchParams);
setAccounts(data);
}
useEffect(() => {
getUsers();
}, []);
const statusColor = {
ACTIVE: "#27AE60",
DISABLED: "#606060",
BLOCKED: "#FF0000",
DELETED: "#B30000",
};
const columns: TableProps<AllUser>["columns"] = [
{
title: "#",
dataIndex: "id",
key: "id",
},
{
title: t("nameLogin"),
dataIndex: "nameLogin",
key: "nameLogin",
render: (text, record) => (
<div
onClick={() => {
setActiveAccount({
login: record.login,
id: record.id,
name: record.name,
email: record.email || "",
});
showEditDrawer();
}}
style={{
display: "flex",
alignItems: "center",
gap: "16px",
cursor: "pointer",
}}
>
<div
style={{
height: "32px",
width: "32px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Avatar
size={32}
src={`https://gamma.heado.ru/go/ava?name=${record.login}`}
/>
</div>
<div style={{ display: "flex", flexDirection: "column" }}>
<div>{record.name}</div>
<div style={{ color: "#606060" }}>{record.login}</div>
</div>
</div>
),
},
{
title: "E-mail",
dataIndex: "email",
key: "email",
},
{
title: t("tenant"),
dataIndex: "bindTenantId",
key: "tenant",
},
{
title: t("role"),
dataIndex: "role",
key: "role",
render: (text) => <div>{t(text)}</div>,
},
{
title: t("createdAt"),
dataIndex: "createdAt",
key: "createdAt",
render: (text) => (
<div>
{new Date(text).toLocaleString("ru", {
year: "2-digit",
month: "2-digit",
day: "2-digit",
})}
</div>
),
},
{
title: t("status"),
dataIndex: "status",
key: "status",
render: (text) => (
<div style={{ color: statusColor[text as AccountStatus] }}>
{t(text)}
</div>
),
},
];
const onTableChange: TableProps<AllUser>["onChange"] = (pagination) => {
console.log(pagination);
UserService.getUsers(
pagination.current as number,
pagination.pageSize
).then((data) => {
setAccounts(data);
setSearchParams({
page: data.currentPage.toString(),
limit: data.limit.toString(),
});
});
};
return (
<>
<Header
title={t('accounts')}
title={t("accounts")}
additionalContent={
<img
src="./icons/header/add_2.svg"
alt="add"
style={{
height: '18px',
width: '18px',
cursor: 'pointer',
height: "18px",
width: "18px",
cursor: "pointer",
}}
onClick={showDrawer}
onClick={showCreateDrawer}
/>
}
/>
<Table
size="small"
onChange={onTableChange}
columns={columns}
dataSource={accounts.users}
pagination={{
pageSize: accounts.limit,
current: accounts.currentPage,
total: accounts.amountCount,
}}
rowKey={"id"}
/>
<ContentDrawer open={open} closeDrawer={closeDrawer} type="create">
<UserCreate />
<ContentDrawer
open={openCreate}
closeDrawer={closeCreateDrawer}
type="create"
>
<UserCreate getUsers={getUsers} closeDrawer={closeCreateDrawer} />
</ContentDrawer>
<ContentDrawer
login={activeAccount?.login}
name={activeAccount?.name}
email={activeAccount?.email}
open={openEdit}
closeDrawer={closeEditDrawer}
type="edit"
>
<UserEdit userId={activeAccount?.id} closeDrawer={closeEditDrawer} />
</ContentDrawer>
</>
);

View File

@@ -1,5 +1,5 @@
import Header from '@/components/Header';
import { useTranslation } from 'react-i18next';
import Header from '../components/Header';
export default function ConfigurationPage() {
const { t } = useTranslation();

View File

@@ -1,5 +1,5 @@
import Header from '@/components/Header';
import { useTranslation } from 'react-i18next';
import Header from '../components/Header';
export default function EventsListPage() {
const { t } = useTranslation();

View File

@@ -1,13 +1,13 @@
import React from 'react';
import { Form, Input, Button, Typography } from 'antd';
import { Form, Input, Button, Typography, message } from 'antd';
import {
EyeInvisibleOutlined,
EyeTwoTone,
UserOutlined,
} from '@ant-design/icons';
import { AuthService } from '../services/authService';
import { Auth } from '../types/auth';
import { useNavigate } from 'react-router-dom';
import { AuthService } from '@/services/authService';
import { Auth } from '@/types/auth';
const { Text, Link } = Typography;
@@ -45,7 +45,11 @@ export default function LoginPage() {
/>
</div>
<Form name="login" onFinish={onFinish} layout="vertical">
<Form
name="login"
onFinish={onFinish}
layout="vertical"
>
<Form.Item
name="login"
rules={[{ required: true, message: 'Введите login' }]}

View File

@@ -2,8 +2,8 @@
import React, { useEffect, useState } from 'react';
import { Layout } from 'antd';
import Sider from 'antd/es/layout/Sider';
import SiderMenu from '../components/SiderMenu';
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import SiderMenu from '@/components/SiderMenu';
import ProcessDiagramPage from './ProcessDiagramPage';
import RunningProcessesPage from './RunningProcessesPage';
import AccountsPage from './AccountsPage';

View File

@@ -1,5 +1,5 @@
import Header from '@/components/Header';
import { useTranslation } from 'react-i18next';
import Header from '../components/Header';
export default function ProcessDiagramPage() {
const { t } = useTranslation();

View File

@@ -1,8 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */
// ProtectedRoute.js
import { Outlet, useNavigate } from 'react-router-dom';
import React, { useEffect } from 'react';
import { useUserSelector } from '../store/userStore';
import { useUserSelector } from '@/store/userStore';
const ProtectedRoute = (): React.JSX.Element => {
const navigate = useNavigate();

View File

@@ -1,5 +1,5 @@
import Header from '@/components/Header';
import { useTranslation } from 'react-i18next';
import Header from '../components/Header';
export default function RunningProcessesPage() {
const { t } = useTranslation();

View File

@@ -1,8 +1,8 @@
import api from '../api/api';
import { useAuthStore } from '../store/authStore';
import { useUserStore } from '../store/userStore';
import { Auth } from '../types/auth';
import { UserService } from './userService';
import api from "@/api/api";
import { useAuthStore } from "@/store/authStore";
import { Auth } from "@/types/auth";
import { UserService } from "./userService";
import { useUserStore } from "@/store/userStore";
export class AuthService {
static async login(auth: Auth) {

View File

@@ -1,5 +1,5 @@
import api from '../api/api';
import { User } from '../types/user';
import api from '@/api/api';
import { AllUserResponse, User, UserCreate, UserUpdate } from '@/types/user';
export class UserService {
static async getProfile(): Promise<User> {
@@ -9,8 +9,30 @@ export class UserService {
return user;
}
static async getUsers(page: number = 1, limit: number = 10): Promise<any> {
const users = api.getUsers(page, limit);
return users;
static async getUsers(
page: number = 1,
limit: number = 10
): Promise<AllUserResponse> {
console.log('getUsers');
const allUsers = api.getUsers(page, limit);
return allUsers;
}
static async getUserById(userId: number): Promise<User> {
console.log('getUserById');
const user = api.getUserById(userId);
return user;
}
static async createUser(user: UserCreate): Promise<User> {
console.log('createUser');
const createdUser = api.createUser(user);
return createdUser;
}
static async updateUser(userId: number, user: UserUpdate): Promise<User> {
console.log('updateUser');
const updatedUser = api.updateUser(userId, user);
return updatedUser;
}
}

View File

@@ -1,6 +1,6 @@
import { User } from '@/types/user';
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { User } from '../types/user';
const userInfo = localStorage.getItem('userInfo');

View File

@@ -191,6 +191,10 @@ export interface components {
amountCount: number;
/** Amountpages */
amountPages: number;
/** Currentpage */
currentPage: number;
/** Limit */
limit: number;
};
/** Auth */
Auth: {
@@ -247,6 +251,25 @@ export interface components {
createdAt: string;
status: components["schemas"]["AccountStatus"];
};
/** UserCreate */
UserCreate: {
/** Name */
name?: string | null;
/** Login */
login?: string | null;
/** Email */
email?: string | null;
/** Password */
password?: string | null;
/** Bindtenantid */
bindTenantId?: string | null;
role?: components["schemas"]["AccountRole"] | null;
/** Meta */
meta?: {
[key: string]: unknown;
} | null;
status?: components["schemas"]["AccountStatus"] | null;
};
/** UserUpdate */
UserUpdate: {
/** Id */
@@ -257,6 +280,8 @@ export interface components {
login?: string | null;
/** Email */
email?: string | null;
/** Password */
password?: string | null;
/** Bindtenantid */
bindTenantId?: string | null;
role?: components["schemas"]["AccountRole"] | null;
@@ -435,7 +460,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": components["schemas"]["UserUpdate"];
"application/json": components["schemas"]["UserCreate"];
};
};
responses: {
@@ -445,7 +470,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["User"];
"application/json": components["schemas"]["AllUser"];
};
};
/** @description Validation Error */
@@ -476,7 +501,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["User"];
"application/json": components["schemas"]["UserUpdate"];
};
};
/** @description Validation Error */
@@ -511,7 +536,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["User"];
"application/json": components["schemas"]["UserUpdate"];
};
};
/** @description Validation Error */

View File

@@ -1,3 +1,9 @@
import { components } from "./openapi-types"
import { components } from './openapi-types';
export type User = components["schemas"]["User"];
export type User = components['schemas']['User'];
export type AllUserResponse = components['schemas']['AllUserResponse'];
export type AllUser = components['schemas']['AllUser'];
export type AccountStatus = components['schemas']['AccountStatus'];
export type AccountRole = components['schemas']['AccountRole'];
export type UserUpdate = components['schemas']['UserUpdate'];
export type UserCreate = components['schemas']['UserCreate'];

View File

@@ -1,11 +1,7 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
@@ -18,9 +14,11 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

23
client/vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
open: false,
},
build: {
outDir: 'build',
},
preview: {
port: 3000,
open: false,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});

View File

@@ -32,11 +32,11 @@ startretries=5
[program:client]
environment=
REACT_APP_WEBSOCKET_PROTOCOL=ws,
REACT_APP_HTTP_PROTOCOL=http,
REACT_APP_API_URL=localhost:8000,
REACT_APP_URL=localhost:3000
command=bash -c 'cd client; npm run build; serve -s build'
VITE_APP_WEBSOCKET_PROTOCOL=ws,
VITE_APP_HTTP_PROTOCOL=http,
VITE_APP_API_URL=localhost:8000,
VITE_APP_URL=localhost:3000
command=bash -c 'cd client; npm run build; npm run preview'
numprocs=1
process_name=node-%(process_num)d
stdout_logfile=client.out.log