81 Commits

Author SHA1 Message Date
6db2821781 refactor(react-flow): add existing nodes validate 2025-08-07 13:02:52 +05:00
3f87970653 Merge branch 'master' into VORKOUT-16 2025-08-06 12:20:28 +05:00
ee4051f523 feat(nodes): add start node and create new node function 2025-08-06 12:19:55 +05:00
cf6e09d8c1 Merge pull request 'feat: CRUD ProcessSchema' (#16) from VORKOUT-17 into master
Reviewed-on: #16
Reviewed-by: cyrussmeat <dr.cyrill@gmail.com>
Reviewed-by: Vladislav Syrochkin <vsyroc@gmail.com>
2025-08-05 21:37:07 +05:00
TheNoxium
d9e88d66f7 fix: rename list events 2025-08-04 16:15:35 +05:00
TheNoxium
3c300f7c7a fix: rename process schema 2025-08-02 22:05:29 +05:00
TheNoxium
c66c21eb14 feat:dto accaunt endpoint, dto list events endpoint 2025-08-02 21:42:06 +05:00
TheNoxium
a9ecaadad6 feat: porcess-schema endpoint DTO 2025-07-31 21:53:33 +05:00
TheNoxium
e50bb32470 fix:api name "process-schema" 2025-07-31 20:33:45 +05:00
TheNoxium
2030d54b2c fix: ruff format 2025-07-30 14:04:59 +05:00
TheNoxium
b82960faf3 fix: name,updated data, db data mapping 2025-07-29 22:57:31 +05:00
TheNoxium
e0887c240f feat: CRUD ProcessSchema 2025-07-27 22:17:36 +05:00
65ed6b9561 feat(client): add filter on exist edges 2025-07-22 10:59:41 +05:00
b503bc0bef feat(client): init store for nodes 2025-07-22 08:08:15 +05:00
5cdb6e460f feat(client): add React flow drawer to processDiagramPage 2025-07-22 08:07:52 +05:00
5966b7d6f1 feat(client): move components 2025-07-22 08:05:20 +05:00
8b0659fc89 feat(client): add node translations 2025-07-22 08:04:48 +05:00
51344ef218 feat(client): add new translation 2025-07-10 18:52:44 +05:00
51171377e2 feat(client): add edge name generator 2025-07-10 18:52:11 +05:00
943f4ded2d feat(client): add appropriation node and node edit drawer 2025-07-09 16:17:49 +05:00
443293d420 feat(client): add base styles for edges and handles 2025-07-07 23:41:29 +05:00
91c8efe13f feat(client): add react-flow css and base if else node 2025-07-04 20:57:35 +05:00
5e3c3b4672 Merge pull request 'VORKOUT-14: format api with ruff' (#15) from VORKOUT-14 into master
Reviewed-on: #15
2025-07-04 20:09:54 +05:00
60160d46be refactor(api): format with ruff 2025-07-04 20:08:50 +05:00
d03600b23d Merge pull request 'feat: CRUD ListEvent' (#14) from VORKOUT-14 into master
Reviewed-on: #14
Reviewed-by: Vladislav Syrochkin <vlad.dev@heado.ru>
2025-07-04 18:06:03 +05:00
82fcd22faf Merge branch 'master' into VORKOUT-14 2025-07-04 18:05:45 +05:00
TheNoxium
2690843954 fix(api): schemas, name error 2025-07-04 11:30:09 +05:00
e10430310f feat(client): add xyflow/react 2025-07-03 14:49:43 +05:00
2493cd0d9f fix(api): fix shadows an attribute in parent in ListEvents for field schema 2025-07-02 14:14:03 +05:00
e6589a0950 chore: update client and api patch version to 0.0.5 2025-07-02 12:25:11 +05:00
a0fcc324fa Merge pull request 'VORKOUT-8' (#13) from VORKOUT-8 into master
Reviewed-on: #13
Reviewed-by: cyrussmeat <dr.cyrill@gmail.com>
2025-07-02 12:23:43 +05:00
2a35386c1d refactor(api): format with ruff 2025-07-02 12:23:22 +05:00
TheNoxium
f65f4caf5c fix: description 2025-07-02 11:48:23 +05:00
TheNoxium
a90a802659 fix: update data 2025-07-02 11:43:24 +05:00
TheNoxium
3d5d717962 fix: removed id for update data 2025-07-02 11:37:10 +05:00
TheNoxium
0bff556b10 fix: page schems, name 2025-07-01 22:31:16 +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
TheNoxium
f550b86c68 feat: CRUD ListEvent 2025-06-30 15:37:07 +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
92203351ff feat(client): regenerate openapi types 2025-06-16 12:56:30 +05:00
ee92428ec3 refactor(api): refactor refresh logic 2025-06-16 12:46:14 +05:00
c87581c9e2 feat(client): add auth logic 2025-06-16 12:35:02 +05:00
79cb434ebd feat: add stores 2025-06-16 12:32:45 +05:00
5def1a9bb1 feat: add authService and rename userService 2025-06-16 12:32:08 +05:00
d55a99aafd feat: add login page and auth logic 2025-06-10 17:50:28 +05:00
599bf22bda feat: add axios-retry 2025-06-10 17:48:57 +05:00
e114f963ab refactor: middleware and refresh 2025-06-10 17:47:46 +05:00
86 changed files with 5855 additions and 16558 deletions

View File

@@ -12,36 +12,35 @@ services:
start-api: start-api:
cd api && \ cd api && \
source .venv/bin/activate && \ poetry run python -m ${API_APPLICATION_NAME}
python -m ${API_APPLICATION_NAME}
start-client: start-client:
cd client && \ cd client && \
npm start npm run dev
migrate: migrate:
cd api && \ cd api && \
source .venv/bin/activate && \
cd $(API_APPLICATION_NAME)/db && \ cd $(API_APPLICATION_NAME)/db && \
PYTHONPATH='../..' ALEMBIC_MIGRATIONS=True alembic upgrade $(args) PYTHONPATH='../..' ALEMBIC_MIGRATIONS=True poetry run alembic upgrade $(args)
downgrade: downgrade:
cd api && \ cd api && \
source .venv/bin/activate && \
cd $(API_APPLICATION_NAME)/db && \ cd $(API_APPLICATION_NAME)/db && \
PYTHONPATH='../..' alembic downgrade -1 PYTHONPATH='../..' poetry run alembic downgrade -1
revision: revision:
cd api && \ cd api && \
source .venv/bin/activate && \
cd $(API_APPLICATION_NAME)/db && \ cd $(API_APPLICATION_NAME)/db && \
PYTHONPATH='../..' ALEMBIC_MIGRATIONS=True alembic revision --autogenerate PYTHONPATH='../..' ALEMBIC_MIGRATIONS=True poetry run alembic revision --autogenerate
venv-api: venv-api:
cd api && \ cd api && \
poetry env activate \
poetry install poetry install
venv-client:
cd client && \
npm install
install: install:
make migrate head && \ make migrate head && \
cd api && \ cd api && \
@@ -58,3 +57,8 @@ format-api:
check-api: check-api:
cd api && \ cd api && \
poetry run ruff format . --check 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

@@ -73,6 +73,7 @@ if __name__ == "__main__":
log_level="info", log_level="info",
) )
app.add_middleware(MiddlewareAccessTokenValidadtion)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=origins, allow_origins=origins,
@@ -80,5 +81,3 @@ app.add_middleware(
allow_methods=["GET", "POST", "OPTIONS", "DELETE", "PUT"], allow_methods=["GET", "POST", "OPTIONS", "DELETE", "PUT"],
allow_headers=["*"], allow_headers=["*"],
) )
app.add_middleware(MiddlewareAccessTokenValidadtion)

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,77 +1,136 @@
from typing import Optional
import math import math
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import insert, select, func
from sqlalchemy.ext.asyncio import AsyncConnection
from enum import Enum from enum import Enum
from typing import Optional
from sqlalchemy import insert, select, func, or_, and_, asc, desc
from sqlalchemy.ext.asyncio import AsyncConnection
from api.db.tables.account import account_table from api.db.tables.account import account_table
from api.schemas.account.account import User 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, UserFilterDTO
async def get_user_accaunt_page(connection: AsyncConnection, page, limit) -> Optional[User]: async def get_user_account_page_DTO(
connection: AsyncConnection, filter_dto: UserFilterDTO
) -> Optional[AllUserResponse]:
""" """
Получает список ползовелей заданных значениями page, limit. Получает список пользователей с пагинацией, фильтрацией и сортировкой через DTO объект.
Поддерживает:
- пагинацию
- поиск
- фильтрацию по полям
- сортировку
""" """
first_user = page * limit - (limit) page = filter_dto.pagination.get("page", 1)
limit = filter_dto.pagination.get("limit", 10)
offset = (page - 1) * limit
query = ( query = select(
select(
account_table.c.id, account_table.c.id,
account_table.c.name, account_table.c.name,
account_table.c.login, account_table.c.login,
account_table.c.email, account_table.c.email,
account_table.c.bind_tenant_id, account_table.c.bind_tenant_id,
account_table.c.role, account_table.c.role,
account_table.c.meta,
account_table.c.creator_id,
account_table.c.created_at, account_table.c.created_at,
account_table.c.status, account_table.c.status,
) )
.order_by(account_table.c.id)
.offset(first_user) # Поиск
.limit(limit) if filter_dto.search:
search_term = f"%{filter_dto.search}%"
query = query.where(
or_(
account_table.c.name.ilike(search_term),
account_table.c.login.ilike(search_term),
account_table.c.email.ilike(search_term),
)
) )
# Фильтрацию
filter_conditions = []
if filter_dto.filters:
for field, values in filter_dto.filters.items():
column = getattr(account_table.c, field, None)
if column is not None and values:
if len(values) == 1:
filter_conditions.append(column == values[0])
else:
filter_conditions.append(column.in_(values))
if filter_conditions:
query = query.where(and_(*filter_conditions))
# Сортировка
if filter_dto.order:
order_field = filter_dto.order.get("field", "id")
order_direction = filter_dto.order.get("direction", "asc")
column = getattr(account_table.c, order_field, None)
if column is not None:
if order_direction.lower() == "desc":
query = query.order_by(desc(column))
else:
query = query.order_by(asc(column))
else:
query = query.order_by(account_table.c.id)
query = query.offset(offset).limit(limit)
count_query = select(func.count()).select_from(account_table) count_query = select(func.count()).select_from(account_table)
if filter_dto.search:
search_term = f"%{filter_dto.search}%"
count_query = count_query.where(
or_(
account_table.c.name.ilike(search_term),
account_table.c.login.ilike(search_term),
account_table.c.email.ilike(search_term),
)
)
if filter_conditions:
count_query = count_query.where(and_(*filter_conditions))
result = await connection.execute(query) result = await connection.execute(query)
count_result = await connection.execute(count_query) count_result = await connection.execute(count_query)
users_data = result.mappings().all() users_data = result.mappings().all()
total_count = count_result.scalar() total_count = count_result.scalar()
if not total_count:
return None
total_pages = math.ceil(total_count / limit) total_pages = math.ceil(total_count / limit)
validated_users = all_user_adapter.validate_python(users_data) 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. Получает юзера по 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_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 return None
user_data = { return User.model_validate(user)
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)
async def get_user_by_login(connection: AsyncConnection, login: str) -> Optional[User]: async def get_user_by_login(connection: AsyncConnection, login: str) -> Optional[User]:
@@ -81,20 +140,10 @@ async def get_user_by_login(connection: AsyncConnection, login: str) -> Optional
query = select(account_table).where(account_table.c.login == login) query = select(account_table).where(account_table.c.login == login)
user_db_cursor = await connection.execute(query) user_db_cursor = await connection.execute(query)
user_db = user_db_cursor.one_or_none() user_data = user_db_cursor.mappings().one_or_none()
if not user_data:
if not user_db:
return None 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 User.model_validate(user_data)
@@ -107,7 +156,7 @@ async def update_user_by_id(connection: AsyncConnection, update_values, user) ->
await connection.commit() 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. Создает нове поле в таблице account_table.
""" """
@@ -117,14 +166,15 @@ async def create_user(connection: AsyncConnection, user: User, creator_id: int)
email=user.email, email=user.email,
bind_tenant_id=user.bind_tenant_id, bind_tenant_id=user.bind_tenant_id,
role=user.role.value, role=user.role.value,
meta=user.meta, meta=user.meta or {},
creator_id=creator_id, creator_id=creator_id,
created_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc),
status=user.status.value, status=user.status.value,
) )
await connection.execute(query) res = await connection.execute(query)
await connection.commit() 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 import User
from api.schemas.account.account_keyring import AccountKeyring from api.schemas.account.account_keyring import AccountKeyring
from api.schemas.endpoints.account import AllUser
from api.utils.key_id_gen import KeyIdGenerator from api.utils.key_id_gen import KeyIdGenerator
from datetime import datetime, timezone 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 = ( query = (
select(account_table, account_keyring_table) select(account_table, account_keyring_table)
.join(account_keyring_table, account_table.c.id == account_keyring_table.c.owner_id) .join(account_keyring_table, account_table.c.id == account_keyring_table.c.owner_id)
@@ -45,18 +46,17 @@ async def get_user(connection: AsyncConnection, login: str) -> Optional[User]:
for column in account_keyring_table.columns 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) password = AccountKeyring.model_validate(password_data)
return user, password return user, password
async def upgrade_old_refresh_token(connection: AsyncConnection, user, refresh_token) -> Optional[User]: async def upgrade_old_refresh_token(connection: AsyncConnection, refresh_token) -> Optional[User]:
new_status = KeyStatus.EXPIRED new_status = KeyStatus.EXPIRED
update_query = ( update_query = (
update(account_keyring_table) update(account_keyring_table)
.where( .where(
account_table.c.id == user.id,
account_keyring_table.c.status == KeyStatus.ACTIVE, account_keyring_table.c.status == KeyStatus.ACTIVE,
account_keyring_table.c.key_type == KeyType.REFRESH_TOKEN, account_keyring_table.c.key_type == KeyType.REFRESH_TOKEN,
account_keyring_table.c.key_value == refresh_token, account_keyring_table.c.key_value == refresh_token,

View File

@@ -1,13 +1,14 @@
from typing import Optional from datetime import datetime, timedelta, timezone
from datetime import datetime, timezone
from enum import Enum 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 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.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]: async def get_key_by_id(connection: AsyncConnection, key_id: str) -> Optional[AccountKeyring]:
@@ -17,20 +18,11 @@ async def get_key_by_id(connection: AsyncConnection, key_id: str) -> Optional[Ac
query = select(account_keyring_table).where(account_keyring_table.c.key_id == key_id) query = select(account_keyring_table).where(account_keyring_table.c.key_id == key_id)
user_db_cursor = await connection.execute(query) user_db_cursor = await connection.execute(query)
user_db = user_db_cursor.one_or_none()
if not user_db: user_data = user_db_cursor.mappings().one_or_none()
if not user_data:
return None 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_keyring_table.columns
}
return AccountKeyring.model_validate(user_data) return AccountKeyring.model_validate(user_data)
@@ -67,3 +59,37 @@ async def create_key(connection: AsyncConnection, key: AccountKeyring, key_id: i
await connection.commit() await connection.commit()
return key 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

@@ -0,0 +1,279 @@
from typing import Optional
import math
from datetime import datetime, timezone
from sqlalchemy import insert, select, func, or_, and_, asc, desc
from sqlalchemy.ext.asyncio import AsyncConnection
from api.db.tables.events import list_events_table
from api.schemas.events.list_events import ListEvent
from api.schemas.endpoints.list_events import all_list_event_adapter, AllListEventResponse, ListEventFilterDTO
async def get_list_events_page_DTO(
connection: AsyncConnection, filter_dto: ListEventFilterDTO
) -> Optional[AllListEventResponse]:
"""
Получает список событий с фильтрацией через DTO объект.
Поддерживает:
- пагинацию
- полнотекстовый поиск (пропускает name при русских буквах)
- фильтрацию по полям
- сортировку
"""
page = filter_dto.pagination.get("page", 1)
limit = filter_dto.pagination.get("limit", 10)
offset = (page - 1) * limit
query = select(
list_events_table.c.id,
list_events_table.c.name,
list_events_table.c.title,
list_events_table.c.creator_id,
list_events_table.c.created_at,
list_events_table.c.schema.label("schema_"),
list_events_table.c.state,
list_events_table.c.status,
)
if filter_dto.search:
search_term = f"%{filter_dto.search}%"
has_russian = any("\u0400" <= char <= "\u04ff" for char in filter_dto.search)
if has_russian:
query = query.where(list_events_table.c.title.ilike(search_term))
else:
query = query.where(
or_(list_events_table.c.title.ilike(search_term), list_events_table.c.name.ilike(search_term))
)
filter_conditions = []
if filter_dto.filters:
for field, values in filter_dto.filters.items():
column = getattr(list_events_table.c, field, None)
if column is not None and values:
if len(values) == 1:
filter_conditions.append(column == values[0])
else:
filter_conditions.append(column.in_(values))
if filter_conditions:
query = query.where(and_(*filter_conditions))
if filter_dto.order:
order_field = filter_dto.order.get("field", "id")
order_direction = filter_dto.order.get("direction", "asc")
if order_field.startswith("schema."):
json_field = order_field[7:]
column = list_events_table.c.schema[json_field].astext
else:
column = getattr(list_events_table.c, order_field, None)
if column is not None:
if order_direction.lower() == "desc":
query = query.order_by(desc(column))
else:
query = query.order_by(asc(column))
else:
query = query.order_by(list_events_table.c.id)
query = query.offset(offset).limit(limit)
count_query = select(func.count()).select_from(list_events_table)
if filter_dto.search:
search_term = f"%{filter_dto.search}%"
has_russian = any("\u0400" <= char <= "\u04ff" for char in filter_dto.search)
if has_russian:
count_query = count_query.where(list_events_table.c.title.ilike(search_term))
else:
count_query = count_query.where(
or_(list_events_table.c.title.ilike(search_term), list_events_table.c.name.ilike(search_term))
)
if filter_conditions:
count_query = count_query.where(and_(*filter_conditions))
result = await connection.execute(query)
count_result = await connection.execute(count_query)
events_data = result.mappings().all()
total_count = count_result.scalar()
if not total_count:
return None
total_pages = math.ceil(total_count / limit)
validated_events = all_list_event_adapter.validate_python(events_data)
return AllListEventResponse(
list_event=validated_events,
amount_count=total_count,
amount_pages=total_pages,
current_page=page,
limit=limit,
)
async def get_list_events_page_by_creator_id(
connection: AsyncConnection, creator_id: int, page: int, limit: int
) -> Optional[AllListEventResponse]:
"""
Получает список событий заданного создателя по значениям page и limit и creator_id.
"""
first_event = page * limit - limit
query = (
select(
list_events_table.c.id,
list_events_table.c.name,
list_events_table.c.title,
list_events_table.c.creator_id,
list_events_table.c.created_at,
list_events_table.c.schema,
list_events_table.c.state,
list_events_table.c.status,
)
.where(list_events_table.c.creator_id == creator_id) # Фильтрация по creator_id
.order_by(list_events_table.c.id)
.offset(first_event)
.limit(limit)
)
count_query = (
select(func.count())
.select_from(list_events_table)
.where(list_events_table.c.creator_id == creator_id) # Фильтрация по creator_id
)
result = await connection.execute(query)
count_result = await connection.execute(count_query)
events_data = result.mappings().all()
total_count = count_result.scalar()
total_pages = math.ceil(total_count / limit)
# Здесь предполагается, что all_list_event_adapter.validate_python корректно обрабатывает данные
validated_list_event = all_list_event_adapter.validate_python(events_data)
return AllListEventResponse(
list_event=validated_list_event,
amount_count=total_count,
amount_pages=total_pages,
current_page=page,
limit=limit,
)
async def get_list_events_page(connection: AsyncConnection, page, limit) -> Optional[AllListEventResponse]:
"""
Получает список событий заданного создателя по значениям page и limit.
"""
first_event = page * limit - (limit)
query = (
select(
list_events_table.c.id,
list_events_table.c.name,
list_events_table.c.title,
list_events_table.c.creator_id,
list_events_table.c.created_at,
list_events_table.c.schema,
list_events_table.c.state,
list_events_table.c.status,
)
.order_by(list_events_table.c.id)
.offset(first_event)
.limit(limit)
)
count_query = select(func.count()).select_from(list_events_table)
result = await connection.execute(query)
count_result = await connection.execute(count_query)
events_data = result.mappings().all()
total_count = count_result.scalar()
total_pages = math.ceil(total_count / limit)
# Здесь предполагается, что all_list_event_adapter.validate_python корректно обрабатывает данные
validated_list_event = all_list_event_adapter.validate_python(events_data)
return AllListEventResponse(
list_event=validated_list_event,
amount_count=total_count,
amount_pages=total_pages,
current_page=page,
limit=limit,
)
async def get_list_events_by_name(connection: AsyncConnection, name: str) -> Optional[ListEvent]:
"""
Получает list events по name.
"""
query = select(list_events_table).where(list_events_table.c.name == name)
list_events_db_cursor = await connection.execute(query)
list_events_data = list_events_db_cursor.mappings().one_or_none()
if not list_events_data:
return None
return ListEvent.model_validate(list_events_data)
async def get_list_events_by_id(connection: AsyncConnection, id: int) -> Optional[ListEvent]:
"""
Получает listevent по id.
"""
query = select(list_events_table).where(list_events_table.c.id == id)
list_events_db_cursor = await connection.execute(query)
list_events_data = list_events_db_cursor.mappings().one_or_none()
if not list_events_data:
return None
return ListEvent.model_validate(list_events_data)
async def update_list_events_by_id(connection: AsyncConnection, update_values, list_events):
"""
Вносит изменеия в нужное поле таблицы list_events_table.
"""
await connection.execute(
list_events_table.update().where(list_events_table.c.id == list_events.id).values(**update_values)
)
await connection.commit()
async def create_list_events(
connection: AsyncConnection, list_events: ListEvent, creator_id: int
) -> Optional[ListEvent]:
"""
Создает нове поле в таблице list_events_table.
"""
query = insert(list_events_table).values(
name=list_events.name,
title=list_events.title, # добавлено поле title
creator_id=creator_id,
created_at=datetime.now(timezone.utc),
schema=list_events.schema_, # добавлено поле schema
state=list_events.state.value, # добавлено поле state
status=list_events.status.value, # добавлено поле status
)
await connection.execute(query)
await connection.commit()
return list_events

View File

@@ -0,0 +1,175 @@
from typing import Optional
import math
from datetime import datetime, timezone
from sqlalchemy import insert, select, func, or_, and_, asc, desc
from sqlalchemy.ext.asyncio import AsyncConnection
from api.db.tables.process import process_schema_table
from api.schemas.process.process_schema import ProcessSchema
from api.schemas.endpoints.process_schema import (
all_process_schema_adapter,
AllProcessSchemaResponse,
ProcessSchemaFilterDTO,
)
async def get_process_schema_page_DTO(
connection: AsyncConnection, filter_dto: ProcessSchemaFilterDTO
) -> Optional[AllProcessSchemaResponse]:
"""
Получает список схем процессов с комплексной фильтрацией через DTO объект.
Поддерживает:
- пагинацию
- поиск
- фильтрацию по полям
- сортировку
"""
page = filter_dto.pagination.get("page", 1)
limit = filter_dto.pagination.get("limit", 10)
offset = (page - 1) * limit
query = select(
process_schema_table.c.id,
process_schema_table.c.title,
process_schema_table.c.description,
process_schema_table.c.owner_id,
process_schema_table.c.creator_id,
process_schema_table.c.created_at,
process_schema_table.c.settings,
process_schema_table.c.status,
)
if filter_dto.search:
search_term = f"%{filter_dto.search}%"
query = query.where(
or_(process_schema_table.c.title.ilike(search_term), process_schema_table.c.description.ilike(search_term))
)
if filter_dto.filters:
filter_conditions = []
for field, values in filter_dto.filters.items():
column = getattr(process_schema_table.c, field, None)
if column is not None and values:
if len(values) == 1:
filter_conditions.append(column == values[0])
else:
filter_conditions.append(column.in_(values))
if filter_conditions:
query = query.where(and_(*filter_conditions))
if filter_dto.order:
order_field = filter_dto.order.get("field", "id")
order_direction = filter_dto.order.get("direction", "asc")
column = getattr(process_schema_table.c, order_field, None)
if column is not None:
if order_direction.lower() == "desc":
query = query.order_by(desc(column))
else:
query = query.order_by(asc(column))
else:
query = query.order_by(process_schema_table.c.id)
query = query.offset(offset).limit(limit)
count_query = select(func.count()).select_from(process_schema_table)
if filter_dto.search:
search_term = f"%{filter_dto.search}%"
count_query = count_query.where(
or_(process_schema_table.c.title.ilike(search_term), process_schema_table.c.description.ilike(search_term))
)
if filter_dto.filters and filter_conditions:
count_query = count_query.where(and_(*filter_conditions))
result = await connection.execute(query)
count_result = await connection.execute(count_query)
events_data = result.mappings().all()
total_count = count_result.scalar()
if not total_count:
return None
total_pages = math.ceil(total_count / limit)
validated_process_schema = all_process_schema_adapter.validate_python(events_data)
return AllProcessSchemaResponse(
process_schema=validated_process_schema,
amount_count=total_count,
amount_pages=total_pages,
current_page=page,
limit=limit,
)
async def get_process_schema_by_title(connection: AsyncConnection, title: str) -> Optional[ProcessSchema]:
"""
Получает process schema по title.
"""
query = select(process_schema_table).where(process_schema_table.c.title == title)
process_schema_db_cursor = await connection.execute(query)
process_schema_data = process_schema_db_cursor.mappings().one_or_none()
if not process_schema_data:
return None
return ProcessSchema.model_validate(process_schema_data)
async def get_process_schema_by_id(connection: AsyncConnection, id: int) -> Optional[ProcessSchema]:
"""
Получает process_schema по id.
"""
query = select(process_schema_table).where(process_schema_table.c.id == id)
process_schema_db_cursor = await connection.execute(query)
process_schema_data = process_schema_db_cursor.mappings().one_or_none()
if not process_schema_data:
return None
return ProcessSchema.model_validate(process_schema_data)
async def update_process_schema_by_id(connection: AsyncConnection, update_values, process_schema):
"""
Вносит изменеия в нужное поле таблицы process_schema_table.
"""
await connection.execute(
process_schema_table.update().where(process_schema_table.c.id == process_schema.id).values(**update_values)
)
await connection.commit()
async def create_process_schema(
connection: AsyncConnection, process_schema: ProcessSchema, creator_id: int
) -> Optional[ProcessSchema]:
"""
Создает нове поле в таблице process_schema_table.
"""
query = insert(process_schema_table).values(
title=process_schema.title,
description=process_schema.description,
owner_id=process_schema.owner_id,
creator_id=creator_id,
created_at=datetime.now(timezone.utc),
settings=process_schema.settings,
status=process_schema.status.value,
)
await connection.execute(query)
await connection.commit()
return process_schema

View File

@@ -3,8 +3,6 @@ import enum
from sqlalchemy import Table, Column, String, Enum as SQLAEnum, JSON, ForeignKey, DateTime, Index from sqlalchemy import Table, Column, String, Enum as SQLAEnum, JSON, ForeignKey, DateTime, Index
from sqlalchemy.sql import func from sqlalchemy.sql import func
from enum import Enum
from api.db.sql_types import UnsignedInt from api.db.sql_types import UnsignedInt
from api.db import metadata from api.db import metadata
@@ -60,7 +58,7 @@ account_keyring_table = Table(
Column("owner_id", UnsignedInt, ForeignKey("account.id"), primary_key=True, nullable=False), Column("owner_id", UnsignedInt, ForeignKey("account.id"), primary_key=True, nullable=False),
Column("key_type", SQLAEnum(KeyType), 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_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("created_at", DateTime(timezone=True), server_default=func.now()),
Column("expiry", DateTime(timezone=True), nullable=True), Column("expiry", DateTime(timezone=True), nullable=True),
Column("status", SQLAEnum(KeyStatus), nullable=False), Column("status", SQLAEnum(KeyStatus), nullable=False),

View File

@@ -1,8 +1,7 @@
import enum import enum
from sqlalchemy import Table, Column, Integer, String, Enum as SQLAEnum, JSON, ForeignKey, DateTime, Index from sqlalchemy import Table, Column, String, Enum as SQLAEnum, JSON, ForeignKey, DateTime
from sqlalchemy.sql import func from sqlalchemy.sql import func
from enum import Enum, auto
from api.db.sql_types import UnsignedInt from api.db.sql_types import UnsignedInt

View File

@@ -3,7 +3,6 @@ import enum
from sqlalchemy import ( from sqlalchemy import (
Table, Table,
Column, Column,
Integer,
String, String,
Text, Text,
Enum as SQLAEnum, Enum as SQLAEnum,
@@ -14,7 +13,7 @@ from sqlalchemy import (
PrimaryKeyConstraint, PrimaryKeyConstraint,
) )
from sqlalchemy.sql import func from sqlalchemy.sql import func
from enum import Enum, auto from enum import Enum
from api.db.sql_types import UnsignedInt from api.db.sql_types import UnsignedInt

View File

@@ -2,8 +2,10 @@ from api.endpoints.auth import api_router as auth_router
from api.endpoints.profile import api_router as profile_router from api.endpoints.profile import api_router as profile_router
from api.endpoints.account import api_router as account_router from api.endpoints.account import api_router as account_router
from api.endpoints.keyring import api_router as keyring_router from api.endpoints.keyring import api_router as keyring_router
from api.endpoints.list_events import api_router as listevents_router
from api.endpoints.process_schema import api_router as processschema_router
list_of_routes = [auth_router, profile_router, account_router, keyring_router] list_of_routes = [auth_router, profile_router, account_router, keyring_router, listevents_router, processschema_router]
__all__ = [ __all__ = [
"list_of_routes", "list_of_routes",

View File

@@ -1,32 +1,23 @@
from fastapi import ( from fastapi import APIRouter, Depends, HTTPException, status, Query
APIRouter,
Depends,
HTTPException,
status,
)
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncConnection from sqlalchemy.ext.asyncio import AsyncConnection
from api.db.connection.session import get_connection_dep from api.db.connection.session import get_connection_dep
from api.db.logic.account import ( from api.db.logic.account import (
get_user_by_id,
update_user_by_id,
create_user, create_user,
get_user_account_page_DTO,
get_user_by_id,
get_user_by_login, get_user_by_login,
get_user_accaunt_page, update_user_by_id,
) )
from api.db.logic.keyring import create_password_key, update_password_key
from api.schemas.account.account import User
from api.db.tables.account import AccountStatus from api.db.tables.account import AccountStatus
from api.schemas.account.account import User
from api.schemas.base import bearer_schema from api.schemas.base import bearer_schema
from api.schemas.endpoints.account import UserUpdate, AllUserResponse from api.schemas.endpoints.account import AllUserResponse, UserCreate, UserUpdate, UserFilterDTO
from api.services.auth import get_current_user from api.services.auth import get_current_user
from api.services.user_role_validation import db_user_role_validation from api.services.user_role_validation import db_user_role_validation
from api.services.update_data_validation import update_user_data_changes
api_router = APIRouter( api_router = APIRouter(
prefix="/account", prefix="/account",
@@ -36,14 +27,33 @@ api_router = APIRouter(
@api_router.get("", dependencies=[Depends(bearer_schema)], response_model=AllUserResponse) @api_router.get("", dependencies=[Depends(bearer_schema)], response_model=AllUserResponse)
async def get_all_account( async def get_all_account(
page: int = 1, page: int = Query(1, description="Page number", gt=0),
limit: int = 10, limit: int = Query(10, description="КNumber of items per page", gt=0),
search: Optional[str] = Query(None, description="Search term to filter by name or login or email"),
status_filter: Optional[List[str]] = Query(None, description="Filter by status"),
role_filter: Optional[List[str]] = Query(None, description="Filter by role"),
creator_id: Optional[int] = Query(None, description="Filter by creator id"),
order_field: Optional[str] = Query("id", description="Field to sort by"),
order_direction: Optional[str] = Query("asc", description="Sort direction (asc/desc)"),
connection: AsyncConnection = Depends(get_connection_dep), connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
): ):
authorize_user = await db_user_role_validation(connection, current_user) authorize_user = await db_user_role_validation(connection, current_user)
user_list = await get_user_accaunt_page(connection, page, limit) filters = {
**({"status": status_filter} if status_filter else {}),
**({"role": role_filter} if role_filter else {}),
**({"creator_id": [str(creator_id)]} if creator_id else {}),
}
filter_dto = UserFilterDTO(
pagination={"page": page, "limit": limit},
search=search,
order={"field": order_field, "direction": order_direction},
filters=filters if filters else None,
)
user_list = await get_user_account_page_DTO(connection, filter_dto)
if user_list is None: if user_list is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Accounts not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Accounts not found")
@@ -53,7 +63,9 @@ async def get_all_account(
@api_router.get("/{user_id}", dependencies=[Depends(bearer_schema)], response_model=User) @api_router.get("/{user_id}", dependencies=[Depends(bearer_schema)], response_model=User)
async def get_account( 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) authorize_user = await db_user_role_validation(connection, current_user)
@@ -67,24 +79,25 @@ async def get_account(
@api_router.post("", dependencies=[Depends(bearer_schema)], response_model=User) @api_router.post("", dependencies=[Depends(bearer_schema)], response_model=User)
async def create_account( 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) authorize_user = await db_user_role_validation(connection, current_user)
user_validation = await get_user_by_login(connection, user.login) user_validation = await get_user_by_login(connection, user.login)
if user_validation is None: if user_validation is None:
await create_user(connection, user, authorize_user.id) new_user = await create_user(connection, user, authorize_user.id)
user_new = await get_user_by_login(connection, user.login) await create_password_key(connection, user.password, new_user.id)
return user_new return new_user
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="An account with this information already exists." 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( async def update_account(
user_id: int, user_id: int,
user_update: UserUpdate, user_update: UserUpdate,
@@ -97,14 +110,15 @@ async def update_account(
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
update_values = update_user_data_changes(user_update, user) if user_update.password is not None:
await update_password_key(connection, user.id, user_update.password)
if update_values is None: updated_values = user_update.model_dump(by_alias=True, exclude_none=True)
if not updated_values:
return user return user
user_update_data = User.model_validate({**user.model_dump(), **update_values}) await update_user_by_id(connection, updated_values, user)
await update_user_by_id(connection, update_values, user)
user = await get_user_by_id(connection, user_id) user = await get_user_by_id(connection, user_id)
@@ -113,7 +127,9 @@ async def update_account(
@api_router.delete("/{user_id}", dependencies=[Depends(bearer_schema)], response_model=User) @api_router.delete("/{user_id}", dependencies=[Depends(bearer_schema)], response_model=User)
async def delete_account( 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) authorize_user = await db_user_role_validation(connection, current_user)
@@ -123,12 +139,12 @@ async def delete_account(
user_update = UserUpdate(status=AccountStatus.DELETED.value) user_update = UserUpdate(status=AccountStatus.DELETED.value)
update_values = update_user_data_changes(user_update, user) updated_values = user_update.model_dump(by_alias=True, exclude_none=True)
if update_values is None: if not updated_values:
return user return user
await update_user_by_id(connection, update_values, user) await update_user_by_id(connection, updated_values, user)
user = await get_user_by_id(connection, user_id) user = await get_user_by_id(connection, user_id)

View File

@@ -4,9 +4,9 @@ from fastapi import (
APIRouter, APIRouter,
Depends, Depends,
HTTPException, HTTPException,
Request,
Response, Response,
status, status,
Request,
) )
from loguru import logger from loguru import logger
@@ -22,7 +22,7 @@ from api.services.auth import authenticate_user
from api.db.logic.auth import add_new_refresh_token, upgrade_old_refresh_token from api.db.logic.auth import add_new_refresh_token, upgrade_old_refresh_token
from api.schemas.endpoints.auth import Auth, Access from api.schemas.endpoints.auth import Auth, Tokens
api_router = APIRouter( api_router = APIRouter(
prefix="/auth", prefix="/auth",
@@ -33,7 +33,7 @@ api_router = APIRouter(
class Settings(BaseModel): class Settings(BaseModel):
authjwt_secret_key: str = get_settings().SECRET_KEY authjwt_secret_key: str = get_settings().SECRET_KEY
# Configure application to store and get JWT from cookies # Configure application to store and get JWT from cookies
authjwt_token_location: set = {"headers", "cookies"} authjwt_token_location: set = {"headers"}
authjwt_cookie_domain: str = get_settings().DOMAIN authjwt_cookie_domain: str = get_settings().DOMAIN
# Only allow JWT cookies to be sent over https # Only allow JWT cookies to be sent over https
@@ -48,7 +48,7 @@ def get_config():
return Settings() return Settings()
@api_router.post("", response_model=Access) @api_router.post("", response_model=Tokens)
async def login_for_access_token( async def login_for_access_token(
user: Auth, user: Auth,
response: Response, response: Response,
@@ -69,7 +69,6 @@ async def login_for_access_token(
) )
access_token_expires = timedelta(minutes=get_settings().ACCESS_TOKEN_EXPIRE_MINUTES) access_token_expires = timedelta(minutes=get_settings().ACCESS_TOKEN_EXPIRE_MINUTES)
refresh_token_expires = timedelta(days=get_settings().REFRESH_TOKEN_EXPIRE_DAYS) refresh_token_expires = timedelta(days=get_settings().REFRESH_TOKEN_EXPIRE_DAYS)
logger.debug(f"refresh_token_expires {refresh_token_expires}") logger.debug(f"refresh_token_expires {refresh_token_expires}")
@@ -81,35 +80,27 @@ async def login_for_access_token(
await add_new_refresh_token(connection, refresh_token, refresh_token_expires_time, user) await add_new_refresh_token(connection, refresh_token, refresh_token_expires_time, user)
Authorize.set_refresh_cookies(refresh_token) return Tokens(access_token=access_token, refresh_token=refresh_token)
return Access(access_token=access_token)
@api_router.post("/refresh", response_model=Access) @api_router.post("/refresh", response_model=Tokens)
async def refresh( async def refresh(
request: Request, connection: AsyncConnection = Depends(get_connection_dep), Authorize: AuthJWT = Depends() request: Request,
): connection: AsyncConnection = Depends(get_connection_dep),
refresh_token = request.cookies.get("refresh_token_cookie") Authorize: AuthJWT = Depends(),
# print("Refresh Token:", refresh_token) ) -> Tokens:
if not refresh_token:
raise HTTPException(status_code=401, detail="Refresh token is missing")
try: try:
Authorize.jwt_refresh_token_required() Authorize.jwt_refresh_token_required()
current_user = Authorize.get_jwt_subject() current_user = Authorize.get_jwt_subject()
except Exception:
except Exception as e: refresh_token = request.headers.get("Authorization").split(" ")[1]
await upgrade_old_refresh_token(connection, current_user, refresh_token) await upgrade_old_refresh_token(connection, refresh_token)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token", detail="Invalid refresh token",
) )
access_token_expires = timedelta(minutes=get_settings().ACCESS_TOKEN_EXPIRE_MINUTES) access_token_expires = timedelta(minutes=get_settings().ACCESS_TOKEN_EXPIRE_MINUTES)
new_access_token = Authorize.create_access_token(subject=current_user, expires_time=access_token_expires) new_access_token = Authorize.create_access_token(subject=current_user, expires_time=access_token_expires)
return Access(access_token=new_access_token) return Tokens(access_token=new_access_token)

View File

@@ -24,7 +24,6 @@ from api.schemas.account.account_keyring import AccountKeyring
from api.services.auth import get_current_user from api.services.auth import get_current_user
from api.services.user_role_validation import db_user_role_validation from api.services.user_role_validation import db_user_role_validation
from api.services.update_data_validation import update_key_data_changes
api_router = APIRouter( api_router = APIRouter(
@@ -87,14 +86,12 @@ async def update_keyring(
if keyring is None: if keyring is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="keyring not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="keyring not found")
update_values = update_key_data_changes(keyring_update, keyring) updated_values = keyring_update.model_dump(by_alias=True, exclude_none=True)
if update_values is None: if not updated_values:
return keyring return keyring
keyring_update_data = AccountKeyring.model_validate({**keyring.model_dump(), **update_values}) await update_key_by_id(connection, updated_values, keyring)
await update_key_by_id(connection, update_values, keyring)
keyring = await get_key_by_id(connection, key_id) keyring = await get_key_by_id(connection, key_id)
@@ -116,12 +113,12 @@ async def delete_keyring(
keyring_update = AccountKeyringUpdate(status=KeyStatus.DELETED.value) keyring_update = AccountKeyringUpdate(status=KeyStatus.DELETED.value)
update_values = update_key_data_changes(keyring_update, keyring) updated_values = keyring_update.model_dump(by_alias=True, exclude_none=True)
if update_values is None: if not updated_values:
return keyring return keyring
await update_key_by_id(connection, update_values, keyring) await update_key_by_id(connection, updated_values, keyring)
keyring = await get_key_by_id(connection, key_id) keyring = await get_key_by_id(connection, key_id)

View File

@@ -0,0 +1,179 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncConnection
from api.db.connection.session import get_connection_dep
from api.db.logic.account import get_user_by_login
from api.db.logic.list_events import (
get_list_events_by_name,
get_list_events_by_id,
create_list_events,
update_list_events_by_id,
get_list_events_page_DTO,
)
from api.schemas.events.list_events import ListEvent
from api.db.tables.events import EventStatus
from api.schemas.base import bearer_schema
from api.schemas.endpoints.list_events import ListEventUpdate, AllListEventResponse, ListEventFilterDTO
from api.services.auth import get_current_user
from api.services.user_role_validation import (
db_user_role_validation_for_list_events_and_process_schema_by_list_event_id,
db_user_role_validation_for_list_events_and_process_schema,
)
api_router = APIRouter(
prefix="/list_events",
tags=["list events"],
)
@api_router.get("", dependencies=[Depends(bearer_schema)], response_model=AllListEventResponse)
async def get_all_list_events(
page: int = Query(1, description="Page number", gt=0),
limit: int = Query(10, description="Number of items per page", gt=0),
search: Optional[str] = Query(None, description="Search term to filter by title or name"),
order_field: Optional[str] = Query("id", description="Field to sort by"),
order_direction: Optional[str] = Query("asc", description="Sort direction (asc/desc)"),
status_filter: Optional[List[str]] = Query(None, description="Filter by status"),
state_filter: Optional[List[str]] = Query(None, description="Filter by state"),
creator_id: Optional[int] = Query(None, description="Filter by creator id"),
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
filters = {
**({"status": status_filter} if status_filter else {}),
**({"state": state_filter} if state_filter else {}),
**({"creator_id": [str(creator_id)]} if creator_id else {}),
}
filter_dto = ListEventFilterDTO(
pagination={"page": page, "limit": limit},
search=search,
order={"field": order_field, "direction": order_direction},
filters=filters if filters else None,
)
authorize_user, page_flag = await db_user_role_validation_for_list_events_and_process_schema(
connection, current_user
)
if not page_flag:
if filter_dto.filters is None:
filter_dto.filters = {}
filter_dto.filters["creator_id"] = [str(authorize_user.id)]
list_events_page = await get_list_events_page_DTO(connection, filter_dto)
if list_events_page is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List events not found")
return list_events_page
@api_router.get("/{list_events_id}", dependencies=[Depends(bearer_schema)], response_model=ListEvent)
async def get_list_events(
list_events_id: int,
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
list_events_validation = await get_list_events_by_id(connection, list_events_id)
if list_events_validation is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List events not found")
authorize_user = await db_user_role_validation_for_list_events_and_process_schema_by_list_event_id(
connection, current_user, list_events_validation.creator_id
)
if list_events_id is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List events not found")
return list_events_validation
@api_router.post("", dependencies=[Depends(bearer_schema)], response_model=ListEvent)
async def create_list_events(
list_events: ListEventUpdate,
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
user_validation = await get_user_by_login(connection, current_user)
list_events_validation = await get_list_events_by_name(connection, list_events.name)
if list_events_validation is None:
await create_list_events(connection, list_events, user_validation.id)
list_events_new = await get_list_events_by_name(connection, list_events.name)
return list_events_new
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="An List events with this information already exists."
)
@api_router.put("/{list_events_id}", dependencies=[Depends(bearer_schema)], response_model=ListEvent)
async def update_list_events(
list_events_id: int,
list_events_update: ListEventUpdate,
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
list_events_validation = await get_list_events_by_id(connection, list_events_id)
if list_events_validation is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List events not found")
authorize_user = await db_user_role_validation_for_list_events_and_process_schema_by_list_event_id(
connection, current_user, list_events_validation.creator_id
)
updated_values = list_events_update.model_dump(by_alias=True, exclude_none=True)
if not updated_values:
return list_events_validation
await update_list_events_by_id(connection, updated_values, list_events_validation)
list_events = await get_list_events_by_id(connection, list_events_id)
return list_events
@api_router.delete("/{list_events_id}", dependencies=[Depends(bearer_schema)], response_model=ListEvent)
async def delete_list_events(
list_events_id: int,
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
list_events_validation = await get_list_events_by_id(connection, list_events_id)
if list_events_validation is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List events not found")
authorize_user = await db_user_role_validation_for_list_events_and_process_schema_by_list_event_id(
connection, current_user, list_events_validation.creator_id
)
list_events_update = ListEventUpdate(status=EventStatus.DELETED.value)
updated_values = list_events_update.model_dump(by_alias=True, exclude_none=True)
if not updated_values:
return list_events_validation
await update_list_events_by_id(connection, updated_values, list_events_validation)
list_events = await get_list_events_by_id(connection, list_events_id)
return list_events

View File

@@ -0,0 +1,178 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncConnection
from api.db.connection.session import get_connection_dep
from api.db.logic.account import get_user_by_login
from api.db.logic.process_schema import (
get_process_schema_by_title,
create_process_schema,
get_process_schema_by_id,
update_process_schema_by_id,
get_process_schema_page_DTO,
)
from api.schemas.process.process_schema import ProcessSchema
from api.db.tables.process import ProcessStatus
from api.schemas.base import bearer_schema
from api.schemas.endpoints.process_schema import ProcessSchemaUpdate, AllProcessSchemaResponse, ProcessSchemaFilterDTO
from api.services.auth import get_current_user
from api.services.user_role_validation import (
db_user_role_validation_for_list_events_and_process_schema_by_list_event_id,
db_user_role_validation_for_list_events_and_process_schema,
)
api_router = APIRouter(
prefix="/process_schema",
tags=["process schema"],
)
@api_router.get("", dependencies=[Depends(bearer_schema)], response_model=AllProcessSchemaResponse)
async def get_all_process_schema(
page: int = Query(1, description="Page number", gt=0),
limit: int = Query(10, description="Number of items per page", gt=0),
search: Optional[str] = Query(None, description="Search term to filter by title or description"),
order_field: Optional[str] = Query("id", description="Field to sort by"),
order_direction: Optional[str] = Query("asc", description="Sort direction (asc/desc)"),
status_filter: Optional[List[str]] = Query(None, description="Filter by status"),
owner_id: Optional[List[str]] = Query(None, description="Filter by owner id"),
connection: AsyncConnection = Depends(get_connection_dep),
creator_id: Optional[int] = Query(None, description="Filter by creator id"),
current_user=Depends(get_current_user),
):
filters = {
**({"status": status_filter} if status_filter else {}),
**({"owner_id": owner_id} if owner_id else {}),
**({"creator_id": [str(creator_id)]} if creator_id else {}),
}
filter_dto = ProcessSchemaFilterDTO(
pagination={"page": page, "limit": limit},
search=search,
order={"field": order_field, "direction": order_direction},
filters=filters if filters else None,
)
authorize_user, page_flag = await db_user_role_validation_for_list_events_and_process_schema(
connection, current_user
)
if not page_flag:
if filter_dto.filters is None:
filter_dto.filters = {}
filter_dto.filters["creator_id"] = [str(authorize_user.id)]
process_schema_page = await get_process_schema_page_DTO(connection, filter_dto)
if process_schema_page is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Process schema not found")
return process_schema_page
@api_router.get("/{process_schema_id}", dependencies=[Depends(bearer_schema)], response_model=ProcessSchema)
async def get_process_schema(
process_schema_id: int,
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
process_schema_validation = await get_process_schema_by_id(connection, process_schema_id)
if process_schema_validation is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Process schema not found")
authorize_user = await db_user_role_validation_for_list_events_and_process_schema_by_list_event_id(
connection, current_user, process_schema_validation.creator_id
)
if process_schema_id is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Process schema not found")
return process_schema_validation
@api_router.post("", dependencies=[Depends(bearer_schema)], response_model=ProcessSchema)
async def create_processschema(
process_schema: ProcessSchemaUpdate,
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
user_validation = await get_user_by_login(connection, current_user)
process_schema_validation = await get_process_schema_by_title(connection, process_schema.title)
if process_schema_validation is None:
await create_process_schema(connection, process_schema, user_validation.id)
process_schema_new = await get_process_schema_by_title(connection, process_schema.title)
return process_schema_new
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="An process schema with this information already exists."
)
@api_router.put("/{process_schema_id}", dependencies=[Depends(bearer_schema)], response_model=ProcessSchema)
async def update_process_schema(
process_schema_id: int,
process_schema_update: ProcessSchemaUpdate,
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
process_schema_validation = await get_process_schema_by_id(connection, process_schema_id)
if process_schema_validation is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Process schema not found")
authorize_user = await db_user_role_validation_for_list_events_and_process_schema_by_list_event_id(
connection, current_user, process_schema_validation.creator_id
)
updated_values = process_schema_update.model_dump(by_alias=True, exclude_none=True)
if not updated_values:
return process_schema_validation
await update_process_schema_by_id(connection, updated_values, process_schema_validation)
process_schema = await get_process_schema_by_id(connection, process_schema_id)
return process_schema
@api_router.delete("/{process_schema_id}", dependencies=[Depends(bearer_schema)], response_model=ProcessSchema)
async def delete_process_schema(
process_schema_id: int,
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
process_schema_validation = await get_process_schema_by_id(connection, process_schema_id)
if process_schema_validation is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Process schema not found")
authorize_user = await db_user_role_validation_for_list_events_and_process_schema_by_list_event_id(
connection, current_user, process_schema_validation.creator_id
)
process_schema_update = ProcessSchemaUpdate(status=ProcessStatus.DELETED.value)
updated_values = process_schema_update.model_dump(by_alias=True, exclude_none=True)
if not updated_values:
return process_schema_validation
await update_process_schema_by_id(connection, updated_values, process_schema_validation)
process_schema = await get_process_schema_by_id(connection, process_schema_id)
return process_schema

View File

@@ -1,11 +1,7 @@
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
Body,
Depends, Depends,
Form,
HTTPException, HTTPException,
Request,
Response,
status, status,
) )
@@ -16,7 +12,6 @@ from api.db.connection.session import get_connection_dep
from api.db.logic.account import get_user_by_id, update_user_by_id, get_user_by_login from api.db.logic.account import get_user_by_id, update_user_by_id, get_user_by_login
from api.schemas.base import bearer_schema from api.schemas.base import bearer_schema
from api.services.auth import get_current_user from api.services.auth import get_current_user
from api.services.update_data_validation import update_user_data_changes
from api.schemas.endpoints.account import UserUpdate from api.schemas.endpoints.account import UserUpdate
from api.schemas.account.account import User from api.schemas.account.account import User
@@ -42,7 +37,7 @@ async def get_profile(
@api_router.put("", dependencies=[Depends(bearer_schema)], response_model=User) @api_router.put("", dependencies=[Depends(bearer_schema)], response_model=User)
async def update_profile( async def update_profile(
user_updata: UserUpdate, user_update: UserUpdate,
connection: AsyncConnection = Depends(get_connection_dep), connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
): ):
@@ -50,13 +45,13 @@ async def update_profile(
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
if user_updata.role == None and user_updata.login == None: if user_update.role is None and user_update.login is None:
update_values = update_user_data_changes(user_updata, user) updated_values = user_update.model_dump(by_alias=True, exclude_none=True)
if update_values is None: if updated_values is None:
return user return user
await update_user_by_id(connection, update_values, user) await update_user_by_id(connection, updated_values, user)
user = await get_user_by_id(connection, user.id) user = await get_user_by_id(connection, user.id)

View File

@@ -1,25 +1,34 @@
from typing import Optional, List
from datetime import datetime from datetime import datetime
from typing import List, Optional, Dict
from pydantic import EmailStr, Field, TypeAdapter from pydantic import EmailStr, Field, TypeAdapter
from api.db.tables.account import AccountRole, AccountStatus from api.db.tables.account import AccountRole, AccountStatus
from api.schemas.base import Base from api.schemas.base import Base
class UserUpdate(Base): class UserUpdate(Base):
id: Optional[int] = None
name: Optional[str] = Field(None, max_length=100) name: Optional[str] = Field(None, max_length=100)
login: Optional[str] = Field(None, max_length=100) login: Optional[str] = Field(None, max_length=100)
email: Optional[EmailStr] = None email: Optional[EmailStr] = None
password: Optional[str] = None
bind_tenant_id: Optional[str] = Field(None, max_length=40) bind_tenant_id: Optional[str] = Field(None, max_length=40)
role: Optional[AccountRole] = None role: Optional[AccountRole] = None
meta: Optional[dict] = None meta: Optional[dict] = None
creator_id: Optional[int] = None
created_at: Optional[datetime] = None
status: Optional[AccountStatus] = None 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): class AllUser(Base):
id: int id: int
name: str name: str
@@ -27,6 +36,8 @@ class AllUser(Base):
email: Optional[EmailStr] = None email: Optional[EmailStr] = None
bind_tenant_id: Optional[str] = None bind_tenant_id: Optional[str] = None
role: AccountRole role: AccountRole
meta: Optional[dict] = None
creator_id: Optional[int] = None
created_at: datetime created_at: datetime
status: AccountStatus status: AccountStatus
@@ -35,6 +46,15 @@ class AllUserResponse(Base):
users: List[AllUser] users: List[AllUser]
amount_count: int amount_count: int
amount_pages: int amount_pages: int
current_page: int
limit: int
all_user_adapter = TypeAdapter(List[AllUser]) all_user_adapter = TypeAdapter(List[AllUser])
class UserFilterDTO(Base):
pagination: Dict[str, int]
search: Optional[str] = None
order: Optional[Dict[str, str]] = None
filters: Optional[Dict[str, List[str]]] = None

View File

@@ -1,17 +1,11 @@
import datetime
from typing import Optional from typing import Optional
from pydantic import Field from pydantic import Field
from datetime import datetime
from api.db.tables.account import KeyType, KeyStatus from api.db.tables.account import KeyType, KeyStatus
from api.schemas.base import Base from api.schemas.base import Base
class AccountKeyringUpdate(Base): class AccountKeyringUpdate(Base):
owner_id: Optional[int] = None owner_id: Optional[int] = None
key_type: Optional[KeyType] = None key_type: Optional[KeyType] = None
key_id: Optional[str] = Field(None, max_length=40)
key_value: Optional[str] = Field(None, max_length=255) key_value: Optional[str] = Field(None, max_length=255)
created_at: Optional[datetime] = None
expiry: Optional[datetime] = None
status: Optional[KeyStatus] = None status: Optional[KeyStatus] = None

View File

@@ -8,9 +8,6 @@ class Auth(Base):
password: str password: str
class Refresh(Base): class Tokens(Base):
refresh_token: str
class Access(Base):
access_token: str access_token: str
refresh_token: str | None = None

View File

@@ -0,0 +1,44 @@
from pydantic import Field, TypeAdapter
from typing import Optional, Dict, Any, List
from datetime import datetime
from api.schemas.base import Base
from api.db.tables.events import EventState, EventStatus
class ListEventUpdate(Base):
name: Optional[str] = Field(None, max_length=40)
title: Optional[str] = Field(None, max_length=64)
schema_: Optional[Dict[str, Any]] = Field(None, alias="schema")
state: Optional[EventState] = None
status: Optional[EventStatus] = None
class AllListEvent(Base):
id: int
name: str
title: str
creator_id: int
created_at: datetime
schema_: Dict[str, Any] = Field(default={}, alias="schema")
state: EventState
status: EventStatus
class AllListEventResponse(Base):
list_event: List[AllListEvent]
amount_count: int
amount_pages: int
current_page: int
limit: int
all_list_event_adapter = TypeAdapter(List[AllListEvent])
class ListEventFilterDTO(Base):
pagination: Dict[str, int]
search: Optional[str] = None
order: Optional[Dict[str, str]] = None
filters: Optional[Dict[str, List[str]]] = None

View File

@@ -0,0 +1,44 @@
from pydantic import Field, TypeAdapter
from typing import Optional, Dict, Any, List
from datetime import datetime
from api.schemas.base import Base
from api.db.tables.process import ProcessStatus
class ProcessSchemaUpdate(Base):
title: Optional[str] = Field(None, max_length=100)
description: Optional[str] = None
owner_id: Optional[int] = None
settings: Optional[Dict[str, Any]] = None
status: Optional[ProcessStatus] = None
class AllProcessSchema(Base):
id: int
title: str = Field(..., max_length=100)
description: str
owner_id: int
creator_id: int
created_at: datetime
settings: Dict[str, Any]
status: ProcessStatus
class AllProcessSchemaResponse(Base):
process_schema: List[AllProcessSchema]
amount_count: int
amount_pages: int
current_page: int
limit: int
all_process_schema_adapter = TypeAdapter(List[AllProcessSchema])
class ProcessSchemaFilterDTO(Base):
pagination: Dict[str, int]
search: Optional[str] = None
order: Optional[Dict[str, str]] = None
filters: Optional[Dict[str, List[str]]] = None

View File

@@ -1,20 +1,9 @@
from pydantic import Field from pydantic import Field
from typing import Dict, Any from typing import Dict, Any
from datetime import datetime from datetime import datetime
from enum import Enum
from api.schemas.base import Base from api.schemas.base import Base
from api.db.tables.events import EventState, EventStatus
class State(Enum):
AUTO = "Auto"
DESCRIPTED = "Descripted"
class Status(Enum):
ACTIVE = "Active"
DISABLED = "Disabled"
DELETED = "Deleted"
class ListEvent(Base): class ListEvent(Base):
@@ -23,6 +12,6 @@ class ListEvent(Base):
title: str = Field(..., max_length=64) title: str = Field(..., max_length=64)
creator_id: int creator_id: int
created_at: datetime created_at: datetime
schema: Dict[str, Any] schema_: Dict[str, Any] = Field(..., alias="schema")
state: State state: EventState
status: Status status: EventStatus

View File

@@ -1,16 +1,9 @@
from pydantic import Field from pydantic import Field
from typing import Dict, Any from typing import Dict, Any
from datetime import datetime from datetime import datetime
from enum import Enum
from api.schemas.base import Base from api.schemas.base import Base
from api.db.tables.process import NodeStatus
class Status(Enum):
ACTIVE = "Active"
STOPPING = "Stopping"
STOPPED = "Stopped"
DELETED = "Deleted"
class MyModel(Base): class MyModel(Base):
@@ -21,4 +14,4 @@ class MyModel(Base):
settings: Dict[str, Any] settings: Dict[str, Any]
creator_id: int creator_id: int
created_at: datetime created_at: datetime
status: Status status: NodeStatus

View File

@@ -1,16 +1,9 @@
from pydantic import Field from pydantic import Field
from typing import Dict, Any from typing import Dict, Any
from datetime import datetime from datetime import datetime
from enum import Enum
from api.schemas.base import Base from api.schemas.base import Base
from api.db.tables.process import ProcessStatus
class Status(Enum):
ACTIVE = "Active"
STOPPING = "Stopping"
STOPPED = "Stopped"
DELETED = "Deleted"
class ProcessSchema(Base): class ProcessSchema(Base):
@@ -21,4 +14,4 @@ class ProcessSchema(Base):
creator_id: int creator_id: int
created_at: datetime created_at: datetime
settings: Dict[str, Any] settings: Dict[str, Any]
status: Status status: ProcessStatus

View File

@@ -1,18 +1,8 @@
from datetime import datetime from datetime import datetime
from typing import Dict, Any from typing import Dict, Any
from enum import Enum
from api.schemas.base import Base from api.schemas.base import Base
from api.db.tables.process import NodeType, NodeStatus
class NodeType(Enum):
pass
class Status(Enum):
ACTIVE = "Active"
DISABLED = "Disabled"
DELETED = "Deleted"
class Ps_Node(Base): class Ps_Node(Base):
@@ -22,4 +12,4 @@ class Ps_Node(Base):
settings: dict settings: dict
creator_id: Dict[str, Any] creator_id: Dict[str, Any]
created_at: datetime created_at: datetime
status: Status status: NodeStatus

View File

@@ -1,27 +1,25 @@
from fastapi import Request, HTTPException
from typing import Optional from typing import Optional
from fastapi import HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncConnection from sqlalchemy.ext.asyncio import AsyncConnection
from api.db.logic.auth import get_user 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.db.tables.account import AccountStatus
from api.schemas.endpoints.account import AllUser
from api.utils.hasher import Hasher 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"): if not hasattr(request.state, "current_user"):
return HTTPException(status_code=401, detail="Unauthorized") return HTTPException(status_code=401, detail="Unauthorized")
return request.state.current_user 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) sql_user, sql_password = await get_user(connection, username)
if not sql_user or sql_user.status != AccountStatus.ACTIVE: if not sql_user or sql_user.status != AccountStatus.ACTIVE:
return None return None
hasher = Hasher()
if not hasher.verify_data(password, sql_password.key_value): if not hasher.verify_data(password, sql_password.key_value):
return None return None
return sql_user return sql_user

View File

@@ -1,3 +1,4 @@
from fastapi_jwt_auth import AuthJWT
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import ( from fastapi import (
Request, Request,
@@ -11,9 +12,6 @@ import re
from re import escape from re import escape
from fastapi_jwt_auth import AuthJWT
class MiddlewareAccessTokenValidadtion(BaseHTTPMiddleware): class MiddlewareAccessTokenValidadtion(BaseHTTPMiddleware):
def __init__(self, app): def __init__(self, app):
super().__init__(app) super().__init__(app)
@@ -22,13 +20,18 @@ class MiddlewareAccessTokenValidadtion(BaseHTTPMiddleware):
self.excluded_routes = [ self.excluded_routes = [
re.compile(r"^" + re.escape(self.prefix) + r"/auth/refresh/?$"), re.compile(r"^" + re.escape(self.prefix) + r"/auth/refresh/?$"),
re.compile(r"^" + re.escape(self.prefix) + r"/auth/?$"), re.compile(r"^" + re.escape(self.prefix) + r"/auth/?$"),
re.compile(r"^" + r"/swagger"),
re.compile(r"^" + r"/openapi"),
] ]
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
if request.method in ["GET", "POST", "PUT", "DELETE"]: if request.method not in ["GET", "POST", "PUT", "DELETE"]:
return JSONResponse(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
content={"detail": "Method not allowed"},
)
if any(pattern.match(request.url.path) for pattern in self.excluded_routes): if any(pattern.match(request.url.path) for pattern in self.excluded_routes):
return await call_next(request) return await call_next(request)
else:
auth_header = request.headers.get("Authorization") auth_header = request.headers.get("Authorization")
if not auth_header: if not auth_header:
return JSONResponse( return JSONResponse(
@@ -36,26 +39,15 @@ class MiddlewareAccessTokenValidadtion(BaseHTTPMiddleware):
content={"detail": "Missing authorization header."}, content={"detail": "Missing authorization header."},
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
try:
token = auth_header.split(" ")[1] token = auth_header.split(" ")[1]
Authorize = AuthJWT(request) Authorize = AuthJWT(request)
try:
current_user = Authorize.get_jwt_subject() current_user = Authorize.get_jwt_subject()
request.state.current_user = current_user request.state.current_user = current_user
return await call_next(request)
except Exception: except Exception:
return JSONResponse( return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
content={"detail": "The access token is invalid or expired."}, content={"detail": "The access token is invalid or expired."},
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
return await call_next(request)
# async with get_connection() as connection:
# authorize_user = await get_user_login(connection, current_user)
# print(authorize_user)
# if authorize_user is None :
# return JSONResponse(
# status_code=status.HTTP_404_NOT_FOUND ,
# detail="User not found.")

View File

@@ -1,74 +0,0 @@
from enum import Enum
from typing import Optional
from api.schemas.endpoints.account import UserUpdate
from api.db.tables.account import KeyType, KeyStatus
from api.schemas.endpoints.account_keyring import AccountKeyringUpdate
from api.db.tables.account import AccountRole, AccountStatus
def update_user_data_changes(update_data: UserUpdate, user) -> Optional[dict]:
"""
Сравнивает данные для обновления с текущими значениями пользователя.
Возвращает:
- None, если нет изменений
- Словарь {поле: новое_значение} для измененных полей
"""
update_values = {}
changes = {}
for field, value in update_data.model_dump(exclude_unset=True).items():
if value is None:
continue
if isinstance(value, (AccountRole, AccountStatus)):
update_values[field] = value.value
else:
update_values[field] = value
for field, new_value in update_values.items():
if not hasattr(user, field):
continue
current_value = getattr(user, field)
if isinstance(current_value, Enum):
current_value = current_value.value
if current_value != new_value:
changes[field] = new_value
return changes if changes else None
def update_key_data_changes(update_data: AccountKeyringUpdate, key) -> Optional[dict]:
"""
Сравнивает данные для обновления с текущими значениями пользователя.
Возвращает:
- None, если нет изменений
- Словарь {поле: новое_значение} для измененных полей
"""
update_values = {}
changes = {}
for field, value in update_data.model_dump(exclude_unset=True).items():
if value is None:
continue
if isinstance(value, (KeyType, KeyStatus)):
update_values[field] = value.value
else:
update_values[field] = value
for field, new_value in update_values.items():
if not hasattr(key, field):
continue
current_value = getattr(key, field)
if isinstance(current_value, Enum):
current_value = current_value.value
if current_value != new_value:
changes[field] = new_value
return changes if changes else None

View File

@@ -11,3 +11,21 @@ async def db_user_role_validation(connection, current_user):
if authorize_user.role not in {AccountRole.OWNER, AccountRole.ADMIN}: if authorize_user.role not in {AccountRole.OWNER, AccountRole.ADMIN}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You do not have enough permissions") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You do not have enough permissions")
return authorize_user return authorize_user
async def db_user_role_validation_for_list_events_and_process_schema_by_list_event_id(
connection, current_user, current_listevents_creator_id
):
authorize_user = await get_user_by_login(connection, current_user)
if authorize_user.role not in {AccountRole.OWNER, AccountRole.ADMIN}:
if authorize_user.id != current_listevents_creator_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You do not have enough permissions")
return authorize_user
async def db_user_role_validation_for_list_events_and_process_schema(connection, current_user):
authorize_user = await get_user_by_login(connection, current_user)
if authorize_user.role not in {AccountRole.OWNER, AccountRole.ADMIN}:
return authorize_user, False
else:
return authorize_user, True

View File

@@ -1,4 +1,6 @@
import hashlib import hashlib
import secrets
# Хешер для работы с паролем. # Хешер для работы с паролем.
@@ -14,3 +16,10 @@ class Hasher:
def verify_data(self, password: str, hashed: str) -> bool: def verify_data(self, password: str, hashed: str) -> bool:
# Проверяет пароль путем сравнения его хеша с сохраненным хешем. # Проверяет пароль путем сравнения его хеша с сохраненным хешем.
return self.hash_data(password) == hashed 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 asyncio
import hashlib import os
import secrets
from api.db.connection.session import get_connection 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 from api.utils.key_id_gen import KeyIdGenerator
INIT_LOCK_FILE = "../init.lock" INIT_LOCK_FILE = "../init.lock"
DEFAULT_LOGIN = "vorkout" 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(): async def init():
if os.path.exists(INIT_LOCK_FILE): if os.path.exists(INIT_LOCK_FILE):
print("Sorry, service is already initialized") print("Sorry, service is already initialized")
return return
async with get_connection() as conn: async with get_connection() as conn:
password = generate_password() password = hasher.generate_password()
hashed_password = hash_password(password) hashed_password = hasher.hash_data(password)
create_user_query = account_table.insert().values( create_user_query = account_table.insert().values(
name=DEFAULT_LOGIN, name=DEFAULT_LOGIN,

View File

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

View File

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

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>

17607
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "client", "name": "client",
"version": "0.0.2", "version": "0.0.5",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.6.1", "@ant-design/icons": "^5.6.1",
@@ -9,27 +9,25 @@
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2", "@types/jest": "^27.5.2",
"@types/node": "^16.18.126",
"@types/react": "^19.0.11", "@types/react": "^19.0.11",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@xyflow/react": "^12.8.1",
"antd": "^5.24.7", "antd": "^5.24.7",
"axios": "^1.9.0", "axios": "^1.9.0",
"axios-retry": "^4.5.0",
"i18next": "^25.0.1", "i18next": "^25.0.1",
"i18next-browser-languagedetector": "^8.0.5", "i18next-browser-languagedetector": "^8.0.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^15.5.1", "react-i18next": "^15.5.1",
"react-router-dom": "^7.5.0", "react-router-dom": "^7.5.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4", "web-vitals": "^2.1.4",
"zustand": "^5.0.5" "zustand": "^5.0.5"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "dev": "vite",
"build": "react-scripts build", "build": "vite build",
"test": "react-scripts test", "preview": "vite preview"
"eject": "react-scripts eject"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
@@ -48,5 +46,13 @@
"last 1 firefox version", "last 1 firefox version",
"last 1 safari 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

@@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_381_806" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect width="24" height="24" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_381_806)">
<path d="M12 13.75H17V12.25H12V13.75ZM12 11.25H17V9.75H12V11.25ZM5 21C4.45 21 3.97917 20.8042 3.5875 20.4125C3.19583 20.0208 3 19.55 3 19V5C3 4.45 3.19583 3.97917 3.5875 3.5875C3.97917 3.19583 4.45 3 5 3H19C19.55 3 20.0208 3.19583 20.4125 3.5875C20.8042 3.97917 21 4.45 21 5V19C21 19.55 20.8042 20.0208 20.4125 20.4125C20.0208 20.8042 19.55 21 19 21H5ZM5 19H19V5H5V19Z" fill="#606060"/>
<path d="M9.75 14.0179H7.75V12.5179H9.75V14.0179Z" fill="#606060"/>
<path d="M9.75 11.5179H7.75V10.0179H9.75V11.5179Z" fill="#606060"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 828 B

View File

@@ -0,0 +1,3 @@
<svg width="16" height="18" viewBox="0 0 16 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 16H5V10H11V16H14V7L8 2.5L2 7V16ZM0 18V6L8 0L16 6V18H9V12H7V18H0Z" fill="#606060"/>
</svg>

After

Width:  |  Height:  |  Size: 198 B

View File

@@ -0,0 +1,8 @@
<svg width="28" height="31" viewBox="0 0 28 31" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 28.6L1.4 30L3 28.425L4.575 30L6 28.6L4.4 27L6 25.425L4.575 24L3 25.6L1.4 24L0 25.425L1.575 27L0 28.6Z" fill="#606060"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13 0C14.1 0 15.0419 0.391472 15.8252 1.1748C16.6085 1.95814 17 2.9 17 4C17 5.1 16.6085 6.04186 15.8252 6.8252C15.0419 7.60853 14.1 8 13 8C11.9 8 10.9581 7.60853 10.1748 6.8252C9.39147 6.04186 9 5.1 9 4C9 2.9 9.39147 1.95814 10.1748 1.1748C10.9581 0.391471 11.9 0 13 0ZM13 2C12.45 2 11.9796 2.19622 11.5879 2.58789C11.1962 2.97956 11 3.45 11 4C11 4.53333 11.1962 5.00039 11.5879 5.40039C11.9795 5.80013 12.4502 6 13 6C13.5498 6 14.0205 5.80013 14.4121 5.40039C14.8038 5.00039 15 4.53333 15 4C15 3.45 14.8038 2.97956 14.4121 2.58789C14.0204 2.19622 13.55 2 13 2Z" fill="#606060"/>
<path d="M11.8 9H13.8V14H11.8V9Z" fill="#606060"/>
<path d="M20 27.55L22.825 30.375L27.775 25.425L26.35 24L22.825 27.55L21.4 26.125L20 27.55Z" fill="#606060"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.167 18.417L12.75 24.834L7.25005 19.334H4.00005L4 23H2L2.00005 17.5H7.25005L12.75 12L19.167 18.417ZM8.9229 18.417L12.75 22.2441L16.5772 18.417L12.75 14.5898L8.9229 18.417Z" fill="#606060"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5 19.334L22 19.334V23H24V17.5L17.5 17.5V19.334Z" fill="#606060"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 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,13 +1,25 @@
import React from 'react'; /* eslint-disable react-hooks/exhaustive-deps */
import React, { useEffect } from 'react';
import { Route, Routes } from 'react-router-dom'; import { Route, Routes } from 'react-router-dom';
import MainLayout from './pages/MainLayout'; import { useSetUserSelector } from './store/userStore';
import LoginPage from './pages/LoginPage';
import ProtectedRoute from './pages/ProtectedRoute'; import ProtectedRoute from './pages/ProtectedRoute';
import MainLayout from './pages/MainLayout';
function App() { function App() {
const setUser = useSetUserSelector();
useEffect(() => {
const storedUser = localStorage.getItem('user');
if (storedUser) {
setUser(JSON.parse(storedUser));
}
}, []);
return ( return (
<div className="App"> <div className="App">
<Routes> <Routes>
<Route path="/login" element={<div>login</div>} /> <Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route path="*" element={<MainLayout />}></Route> <Route path="*" element={<MainLayout />}></Route>
</Route> </Route>

View File

@@ -1,7 +1,13 @@
import axios from 'axios'; import axios from 'axios';
import { Access, Auth } from '../types/auth'; import axiosRetry from 'axios-retry';
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({ const base = axios.create({
baseURL, baseURL,
@@ -11,20 +17,114 @@ const base = axios.create({
}, },
}); });
// base.interceptors.request.use((config) => { base.interceptors.request.use((config) => {
// const token = localStorage.getItem('accessToken'); if (config.url === '/auth/refresh') {
// if (token) { return config;
// config.headers.Authorization = `Bearer ${token}`; }
// } const token = useAuthStore.getState().accessToken;
// return config; if (token) {
// }); config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
axiosRetry(base, {
retries: 3,
retryDelay: (retryCount: number) => {
console.log(`retry attempt: ${retryCount}`);
return retryCount * 2000;
},
retryCondition: async (error: any) => {
if (error.code === 'ERR_CANCELED') {
return true;
}
return false;
},
});
base.interceptors.response.use(
(response) => {
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('/');
const url = urlTokens[urlTokens.length - 1];
console.log('url', url);
if (
error.response.status === 401 &&
!(originalRequest?._retry != null) &&
url !== 'login' &&
url !== 'refresh' &&
url !== 'logout'
) {
originalRequest._retry = true;
try {
await AuthService.refresh();
return base(originalRequest);
} catch (error) {
await AuthService.logout();
return new Promise(() => {});
}
}
return await Promise.reject(error);
}
);
const api = { const api = {
async login(auth: Auth): Promise<Access> { // auth
console.log(auth); async login(auth: Auth): Promise<Tokens> {
const response = await base.post<Access>('/auth', auth); const response = await base.post<Tokens>('/auth', auth);
return response.data; return response.data;
}, },
async refreshToken(): Promise<Tokens> {
const token = localStorage.getItem('refreshToken');
const response = await base.post<Tokens>(
'/auth/refresh',
{},
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
return response.data;
},
// user
async getProfile(): Promise<User> {
const response = await base.get<User>('/profile');
return response.data;
},
async getUsers(page: number, limit: number): Promise<any> {
const response = await base.get<User[]>(
`/account?page=${page}&limit=${limit}`
);
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; export default api;

View File

@@ -1,161 +0,0 @@
import { Drawer } from 'antd';
import { useEffect, useState } from 'react';
import { Avatar, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
interface ContentDrawerProps {
open: boolean;
closeDrawer: () => void;
children: React.ReactNode;
type: 'create' | 'edit';
}
export default function ContentDrawer({
open,
closeDrawer,
children,
type,
}: ContentDrawerProps) {
const { t } = useTranslation();
const [width, setWidth] = useState<number | string>('30%');
const calculateWidths = () => {
const windowWidth = window.innerWidth;
const expanded = Math.max(windowWidth * 0.3, 300);
setWidth(expanded);
};
useEffect(() => {
calculateWidths();
window.addEventListener('resize', calculateWidths);
return () => window.removeEventListener('resize', calculateWidths);
}, []);
const editDrawerTitle = (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
}}
>
<div
onClick={closeDrawer}
style={{
display: 'flex',
alignItems: 'center',
height: '24px',
width: '24px',
cursor: 'pointer',
}}
>
<img
src="./icons/drawer/arrow_back.svg"
alt="close_drawer"
style={{ height: '16px', width: '16px' }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1 }}>
<Avatar
src="https://cdn-icons-png.flaticon.com/512/219/219986.png"
size={40}
style={{ flexShrink: 0 }}
/>
<div>
<Typography.Text strong style={{ display: 'block' }}>
Александр Александров
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 14 }}>
alexandralex@vorkout.ru
</Typography.Text>
</div>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
height: '24px',
width: '24px',
}}
>
<img
src="./icons/drawer/delete.svg"
alt="delete"
style={{ height: '18px', width: '16px' }}
/>
</div>
</div>
);
const createDrawerTitle = (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
}}
>
<div
onClick={closeDrawer}
style={{
display: 'flex',
alignItems: 'center',
height: '24px',
width: '24px',
cursor: 'pointer',
}}
>
<img
src="./icons/drawer/arrow_back.svg"
alt="close_drawer"
style={{ height: '16px', width: '16px' }}
/>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
flex: 1,
fontSize: '20px',
}}
>
{t('newAccount')}
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
height: '24px',
width: '24px',
}}
onClick={closeDrawer}
>
<img
src="./icons/drawer/delete.svg"
alt="delete"
style={{ height: '18px', width: '16px' }}
/>
</div>
</div>
);
return (
<Drawer
title={type === 'create' ? createDrawerTitle : editDrawerTitle}
placement="right"
open={open}
width={width}
destroyOnClose={true}
closable={false}
>
{children}
</Drawer>
);
}

View File

@@ -1,5 +1,9 @@
import { useUserSelector } from '@/store/userStore';
import { Avatar } from 'antd'; import { Avatar } from 'antd';
import Title from 'antd/es/typography/Title'; import Title from 'antd/es/typography/Title';
import { useState } from 'react';
import ContentDrawer from './drawers/ContentDrawer';
import UserEdit from './drawers/users/UserEdit';
interface HeaderProps { interface HeaderProps {
title: string; title: string;
@@ -7,6 +11,13 @@ interface HeaderProps {
} }
export default function Header({ title, additionalContent }: 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 ( return (
<div <div
style={{ style={{
@@ -43,13 +54,24 @@ export default function Header({ title, additionalContent }: HeaderProps) {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}} }}
onClick={showEditDrawer}
> >
<Avatar <Avatar
size={25.77} 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>
</div> </div>
<ContentDrawer
login={user?.login}
name={user?.name}
email={user?.email}
open={openEdit}
closeDrawer={closeEditDrawer}
type="userEdit"
>
{user?.id && <UserEdit closeDrawer={closeEditDrawer} userId={user?.id} />}
</ContentDrawer>
</div> </div>
); );
} }

View File

@@ -0,0 +1,281 @@
import {
ReactFlow,
Background,
Controls,
useNodesState,
useEdgesState,
Node,
Edge,
} from "@xyflow/react";
import { useMemo, useState } from "react";
import customEdgeStyle from "./edges/defaultEdgeStyle";
import { Dropdown, DropdownProps } from "antd";
import { useTranslation } from "react-i18next";
import { edgeTitleGenerator } from "@/utils/edge";
import IfElseNode from "./nodes/IfElseNode";
import AppropriationNode from "./nodes/AppropriationNode";
import StartNode from "./nodes/StartNode";
const initialNodes: Node[] = [
{
id: "1",
type: "startNode",
position: { x: 100, y: 0 },
data: {},
},
{
id: "2",
type: "ifElse",
position: { x: 100, y: 200 },
data: { condition: "B=2" },
},
{
id: "3",
type: "appropriation",
position: { x: 100, y: 400 },
data: { value: "Выбрать {{account.email}}" },
},
{
id: "4",
type: "appropriation",
position: { x: 400, y: 400 },
data: { value: "Выбрать {{account.role}}" },
},
];
const initialEdges: Edge[] = [
// {
// id: "e1-3",
// source: "1",
// sourceHandle: "1",
// target: "3",
// targetHandle: null,
// label: "A1",
// ...customEdgeStyle,
// },
// {
// id: "e1-2",
// source: "1",
// sourceHandle: "2",
// target: "2",
// label: "B1",
// ...customEdgeStyle,
// },
];
interface ReactFlowDrawerProps {
showDrawer: () => void;
}
export default function ReactFlowDrawer({ showDrawer }: ReactFlowDrawerProps) {
const { t } = useTranslation();
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const [menuVisible, setMenuVisible] = useState(false);
const [selectedHandleId, setSelectedHandleId] = useState<string | null>(null);
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
const handleOpenChange: DropdownProps["onOpenChange"] = (nextOpen, info) => {
if (info.source === "trigger" || nextOpen) {
setMenuVisible(nextOpen);
}
};
const handleClick = (e: React.MouseEvent, node: Node) => {
e.stopPropagation();
const target = e.target as HTMLElement;
const handleElement = target.closest(".react-flow__handle") as HTMLElement;
if (!handleElement) return;
const handleId = handleElement.getAttribute("data-handleid");
if (!handleId) return;
const handlePos = handleElement.getAttribute("data-handlepos");
if (handlePos === "top") return;
setSelectedHandleId(`${node.id}-${handleId}`);
const flowWrapper = document.querySelector(".react-flow") as HTMLElement;
if (flowWrapper) {
const wrapperRect = flowWrapper.getBoundingClientRect();
const handleRect = handleElement.getBoundingClientRect();
setMenuPosition({
x: handleRect.right - wrapperRect.left,
y: handleRect.top - wrapperRect.top + 20,
});
}
setMenuVisible(true);
};
const handleMenuItemClick = (targetNodeId: string) => {
if (!selectedHandleId) return;
const [sourceNodeId, sourceHandleId] = selectedHandleId.split("-");
const label = edgeTitleGenerator(edges.length + 1);
const newEdge: Edge = {
id: `e${sourceNodeId}-${sourceHandleId}-${targetNodeId}:${label}`,
source: sourceNodeId,
sourceHandle: sourceHandleId,
target: targetNodeId,
label,
...customEdgeStyle,
};
setEdges((eds) => [...eds, newEdge]);
setMenuVisible(false);
setSelectedHandleId(null);
};
const handleCreateNode = (type: string) => {
if (!selectedHandleId) return;
const [sourceNodeId, sourceHandleId] = selectedHandleId.split("-");
const sourceNode = nodes.find((n) => n.id === sourceNodeId);
const newId = (
Math.max(...nodes.map((n) => Number(n.id) || 0)) + 1
).toString();
let newNode;
if (type === "ifElse") {
newNode = {
id: newId,
type: "ifElse",
position: {
x: (sourceNode?.position.x || 0) + 200,
y: (sourceNode?.position.y || 0) + 100,
},
data: { condition: "" },
};
} else if (type === "appropriation") {
newNode = {
id: newId,
type: "appropriation",
position: {
x: (sourceNode?.position.x || 0) + 200,
y: (sourceNode?.position.y || 0) + 100,
},
data: { value: "" },
};
}
if (newNode) {
setNodes((nds) => [...nds, newNode]);
const label = edgeTitleGenerator(edges.length + 1);
const newEdge = {
id: `e${sourceNodeId}-${sourceHandleId}-${newId}:${label}`,
source: sourceNodeId,
sourceHandle: sourceHandleId,
target: newId,
label,
...customEdgeStyle,
};
setEdges((eds) => [...eds, newEdge]);
setMenuVisible(false);
setSelectedHandleId(null);
}
};
const newNodeTypes = [
{ key: "ifElse", label: t("ifElseNode") },
{
key: "appropriation",
label: t("appropriationNode"),
},
];
const existingNodes = nodes
.filter((node) => node.id !== selectedHandleId?.split("-")[0])
.filter((node) => {
if (!selectedHandleId || node.type === "startNode") return false;
const [sourceNodeId, sourceHandleId] = selectedHandleId.split("-");
return !edges.some(
(edge) =>
edge.source === sourceNodeId &&
edge.sourceHandle === sourceHandleId &&
edge.target === node.id
);
});
const menuItems = [
{
key: "connectToExisting",
label: t("connectToExisting"),
children: existingNodes.map((node) => ({
key: node.id,
label: t("connectTo", { nodeId: node.id }),
onClick: () => handleMenuItemClick(node.id),
})),
},
...newNodeTypes.map((nt) => ({
key: `createNew-${nt.key}`,
label: nt.label,
onClick: () => handleCreateNode(nt.key),
})),
];
const nodeTypes = useMemo(
() => ({
startNode: (props: any) => <StartNode {...props} edges={edges} />,
ifElse: (props: any) => <IfElseNode {...props} edges={edges} />,
appropriation: (props: any) => (
<AppropriationNode {...props} edges={edges} />
),
}),
[edges]
);
return (
<ReactFlow
nodes={nodes}
edges={edges}
nodesDraggable={false}
elementsSelectable={false}
nodesConnectable={false}
onNodeClick={(event, node) => {
const target = event.target as HTMLElement;
if (!target.closest(".react-flow__handle")) {
console.log("node clicked");
showDrawer();
} else {
handleClick(event, node);
}
}}
nodeTypes={nodeTypes}
fitView
minZoom={0.5}
maxZoom={1.0}
>
<Background color="#F2F2F2" />
<Controls
position="bottom-center"
orientation="horizontal"
showInteractive={false}
/>
{menuVisible && (
<div
style={{
position: "absolute",
left: `${menuPosition.x}px`,
top: `${menuPosition.y}px`,
zIndex: 9999,
}}
>
<Dropdown
menu={{ items: menuItems }}
open={menuVisible}
onOpenChange={handleOpenChange}
placement="bottom"
>
<div style={{ width: 1, height: 1 }} />
</Dropdown>
</div>
)}
</ReactFlow>
);
}

View File

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

View File

@@ -1,229 +0,0 @@
import {
Button,
Form,
Input,
Select,
Upload,
Image,
UploadFile,
GetProp,
UploadProps,
} from 'antd';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
const { Option } = Select;
type FileType = Parameters<GetProp<UploadProps, 'beforeUpload'>>[0];
const getBase64 = (file: FileType): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
export default function UserCreate() {
const { t } = useTranslation();
const [previewOpen, setPreviewOpen] = useState(false);
const [previewImage, setPreviewImage] = useState('');
const [fileList, setFileList] = useState<UploadFile[]>([]);
const handlePreview = async (file: UploadFile) => {
if (!file.url && !file.preview) {
file.preview = await getBase64(file.originFileObj as FileType);
}
setPreviewImage(file.url || (file.preview as string));
setPreviewOpen(true);
};
const handleChange: UploadProps['onChange'] = ({ fileList: newFileList }) =>
setFileList(newFileList);
const customUploadButton = (
<div>
<div
style={{
height: '102px',
width: '102px',
backgroundColor: '#E2E2E2',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 8,
marginTop: 30,
cursor: 'pointer',
}}
>
<img
src="./icons/drawer/add_photo_alternate.svg"
alt="add_photo_alternate"
style={{ height: '18px', width: '18px' }}
/>
</div>
<span style={{ fontSize: '14px', color: '#8c8c8c' }}>
{t('selectPhoto')}
</span>
</div>
);
const photoToUpload = (
<div style={{ height: '102px' }}>
<Upload
listType="picture-circle"
fileList={fileList}
onPreview={handlePreview}
onChange={handleChange}
beforeUpload={() => false}
>
{fileList.length > 0 ? null : customUploadButton}
</Upload>
{previewImage && (
<Image
wrapperStyle={{ display: 'none' }}
preview={{
visible: previewOpen,
onVisibleChange: (visible) => setPreviewOpen(visible),
afterOpenChange: (visible) => !visible && setPreviewImage(''),
}}
src={previewImage}
/>
)}
</div>
);
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '36px',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
{photoToUpload}
</div>
</div>
<Form
name="user-edit-form"
layout="vertical"
// onFinish={onFinish}
initialValues={{
name: '',
login: '',
password: '',
email: '',
tenant: '',
role: '',
status: '',
}}
style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
>
<Form.Item
label={t('name')}
name="name"
rules={[{ required: true, message: t('nameMessage') }]}
>
<Input />
</Form.Item>
<Form.Item
label={t('login')}
name="login"
rules={[{ required: true, message: t('loginMessage') }]}
>
<Input />
</Form.Item>
<Form.Item
label={t('password')}
name="password"
rules={[{ required: true, message: t('passwordMessage') }]}
>
<Input.Password />
</Form.Item>
<Form.Item
label={t('email')}
name="email"
rules={[
{ required: true, message: t('emailMessage') },
{ type: 'email', message: t('emailErrorMessage') },
]}
>
<Input />
</Form.Item>
<Form.Item
label={t('tenant')}
name="tenant"
rules={[{ required: true, message: t('tenantMessage') }]}
>
<Input />
</Form.Item>
<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>
</Select>
</Form.Item>
<Form.Item
label={t('status')}
name="status"
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>
</Form.Item>
<div style={{ flexGrow: 1 }} />
<Form.Item>
<Button
type="primary"
htmlType="submit"
block
style={{ color: '#000' }}
>
<img
src="/icons/drawer/reg.svg"
alt="save"
style={{ height: '18px', width: '18px' }}
/>{' '}
{t('addAccount')}
</Button>
</Form.Item>
</Form>
</div>
);
}

View File

@@ -1,113 +0,0 @@
import { Button, Form, Input, Select } from 'antd';
import { useTranslation } from 'react-i18next';
const { Option } = Select;
export default function UserEdit() {
const { t } = useTranslation();
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<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: 'Активен',
}}
style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
>
<Form.Item
label={t('name')}
name="name"
rules={[{ required: true, message: t('nameMessage') }]}
>
<Input />
</Form.Item>
<Form.Item
label={t('login')}
name="login"
rules={[{ required: true, message: t('loginMessage') }]}
>
<Input />
</Form.Item>
<Form.Item
label={t('password')}
name="password"
rules={[{ required: true, message: t('passwordMessage') }]}
>
<Input.Password />
</Form.Item>
<Form.Item
label={t('email')}
name="email"
rules={[
{ required: true, message: t('emailMessage') },
{ type: 'email', message: t('emailErrorMessage') },
]}
>
<Input />
</Form.Item>
<Form.Item
label={t('tenant')}
name="tenant"
rules={[{ required: true, message: t('tenantMessage') }]}
>
<Input />
</Form.Item>
<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>
</Select>
</Form.Item>
<Form.Item
label={t('status')}
name="status"
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>
</Form.Item>
<div style={{ flexGrow: 1 }} />
<Form.Item>
<Button
type="primary"
htmlType="submit"
block
style={{ color: '#000' }}
>
<img
src="/icons/drawer/save.svg"
alt="save"
style={{ height: '18px', width: '18px' }}
/>{' '}
{t('save')}
</Button>
</Form.Item>
</Form>
</div>
);
}

View File

@@ -0,0 +1,245 @@
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: "userCreate" | "userEdit" | "nodeEdit";
login?: string;
name?: string;
email?: string | null;
}
export default function ContentDrawer({
open,
closeDrawer,
children,
type,
login,
name,
email,
}: ContentDrawerProps) {
const user = useUserSelector();
const { t } = useTranslation();
const [width, setWidth] = useState<number | string>("30%");
const calculateWidths = () => {
const windowWidth = window.innerWidth;
const expanded = Math.max(windowWidth * 0.3, 300);
setWidth(expanded);
};
useEffect(() => {
calculateWidths();
window.addEventListener("resize", calculateWidths);
return () => window.removeEventListener("resize", calculateWidths);
}, []);
console.log(login, user?.login, login === user?.login);
const userEditDrawerTitle = (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}
>
<div
onClick={closeDrawer}
style={{
display: "flex",
alignItems: "center",
height: "24px",
width: "24px",
cursor: "pointer",
}}
>
<img
src="./icons/drawer/arrow_back.svg"
alt="close_drawer"
style={{ height: "16px", width: "16px" }}
/>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12, flex: 1 }}>
<Avatar
src={
login ? `https://gamma.heado.ru/go/ava?name=${login}` : undefined
}
size={40}
style={{ flexShrink: 0 }}
/>
<div>
<Typography.Text
strong
style={{ display: "block", fontSize: "20px" }}
>
{name} {login === user?.login ? t("you") : ""}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 14 }}>
{email}
</Typography.Text>
</div>
</div>
<div
style={{
display: "flex",
alignItems: "center",
height: "24px",
width: "24px",
}}
>
<img
src="./icons/drawer/delete.svg"
alt="delete"
style={{ height: "18px", width: "16px" }}
/>
</div>
</div>
);
const userCreateDrawerTitle = (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}
>
<div
onClick={closeDrawer}
style={{
display: "flex",
alignItems: "center",
height: "24px",
width: "24px",
cursor: "pointer",
}}
>
<img
src="./icons/drawer/arrow_back.svg"
alt="close_drawer"
style={{ height: "16px", width: "16px" }}
/>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
flex: 1,
fontSize: "20px",
}}
>
{t("newAccount")}
</div>
<div
style={{
display: "flex",
alignItems: "center",
height: "24px",
width: "24px",
}}
onClick={closeDrawer}
>
<img
src="./icons/drawer/delete.svg"
alt="delete"
style={{ height: "18px", width: "16px" }}
/>
</div>
</div>
);
const nodeEditDrawerTitle = (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}
>
<div
onClick={closeDrawer}
style={{
display: "flex",
alignItems: "center",
height: "24px",
width: "24px",
cursor: "pointer",
}}
>
<img
src="./icons/drawer/arrow_back.svg"
alt="close_drawer"
style={{ height: "16px", width: "16px" }}
/>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
flex: 1,
fontSize: "20px",
}}
>
Редактирование блока
</div>
<div
style={{
display: "flex",
alignItems: "center",
height: "24px",
width: "24px",
}}
onClick={closeDrawer}
>
<img
src="./icons/drawer/delete.svg"
alt="delete"
style={{ height: "18px", width: "16px" }}
/>
</div>
</div>
);
return (
<Drawer
// title={
// type === "userCreate" ? userCreateDrawerTitle : userEditDrawerTitle
// }
title={(() => {
switch (type) {
case "userCreate":
return userCreateDrawerTitle;
case "userEdit":
return userEditDrawerTitle;
case "nodeEdit":
return nodeEditDrawerTitle;
default:
return null;
}
})()}
placement="right"
open={open}
width={width}
destroyOnHidden={true}
closable={false}
>
{children}
</Drawer>
);
}

View File

@@ -0,0 +1,213 @@
import { User } from "@/types/user";
import { Avatar, Typography } from "antd";
import { TFunction } from "i18next";
interface DrawerTitleProps {
closeDrawer: () => void;
t: TFunction;
}
interface UserEditDrawerTitleProps extends DrawerTitleProps {
login?: string;
name?: string;
email?: string | null;
user: User | null;
}
interface UserCreateDrawerTitleProps extends DrawerTitleProps {}
interface NodeEditDrawerTitleProps extends DrawerTitleProps {}
const UserEditDrawerTitle = ({
closeDrawer,
login,
name,
email,
user,
t,
}: UserEditDrawerTitleProps) => {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}
>
<div
onClick={closeDrawer}
style={{
display: "flex",
alignItems: "center",
height: "24px",
width: "24px",
cursor: "pointer",
}}
>
<img
src="./icons/drawer/arrow_back.svg"
alt="close_drawer"
style={{ height: "16px", width: "16px" }}
/>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12, flex: 1 }}>
<Avatar
src={
login ? `https://gamma.heado.ru/go/ava?name=${login}` : undefined
}
size={40}
style={{ flexShrink: 0 }}
/>
<div>
<Typography.Text
strong
style={{ display: "block", fontSize: "20px" }}
>
{name} {login === user?.login ? t("you") : ""}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 14 }}>
{email}
</Typography.Text>
</div>
</div>
<div
style={{
display: "flex",
alignItems: "center",
height: "24px",
width: "24px",
}}
>
<img
src="./icons/drawer/delete.svg"
alt="delete"
style={{ height: "18px", width: "16px" }}
/>
</div>
</div>
);
};
const UserCreateDrawerTitle = ({
closeDrawer,
t,
}: UserCreateDrawerTitleProps) => {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}
>
<div
onClick={closeDrawer}
style={{
display: "flex",
alignItems: "center",
height: "24px",
width: "24px",
cursor: "pointer",
}}
>
<img
src="./icons/drawer/arrow_back.svg"
alt="close_drawer"
style={{ height: "16px", width: "16px" }}
/>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: "12px",
flex: 1,
fontSize: "20px",
}}
>
{t("newAccount")}
</div>
<div
style={{
display: "flex",
alignItems: "center",
height: "24px",
width: "24px",
}}
onClick={closeDrawer}
>
<img
src="./icons/drawer/delete.svg"
alt="delete"
style={{ height: "18px", width: "16px" }}
/>
</div>
</div>
);
};
const NodeEditDrawerTitle = ({ closeDrawer, t }: NodeEditDrawerTitleProps) => {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
}}
>
<div
onClick={closeDrawer}
style={{
display: "flex",
alignItems: "center",
height: "24px",
width: "24px",
cursor: "pointer",
}}
>
<img
src="./icons/drawer/arrow_back.svg"
alt="close_drawer"
style={{ height: "16px", width: "16px" }}
/>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: "12px",
flex: 1,
fontSize: "20px",
}}
>
{t("editNode")}
</div>
<div
style={{
display: "flex",
alignItems: "center",
height: "24px",
width: "24px",
}}
onClick={closeDrawer}
>
<img
src="./icons/drawer/delete.svg"
alt="delete"
style={{ height: "18px", width: "16px" }}
/>
</div>
</div>
);
};
export { UserEditDrawerTitle, UserCreateDrawerTitle, NodeEditDrawerTitle };

View File

@@ -0,0 +1,262 @@
import {
Button,
Form,
Input,
Select,
Upload,
Image,
UploadFile,
GetProp,
UploadProps,
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];
const getBase64 = (file: FileType): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
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 [loading, setLoading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const handlePreview = async (file: UploadFile) => {
if (!file.url && !file.preview) {
file.preview = await getBase64(file.originFileObj as FileType);
}
setPreviewImage(file.url || (file.preview as string));
setPreviewOpen(true);
};
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",
marginBottom: 8,
marginTop: 30,
cursor: "pointer",
}}
>
<img
src="./icons/drawer/add_photo_alternate.svg"
alt="add_photo_alternate"
style={{ height: "18px", width: "18px" }}
/>
</div>
<span style={{ fontSize: "14px", color: "#8c8c8c" }}>
{t("selectPhoto")}
</span>
</div>
);
const photoToUpload = (
<div style={{ height: "102px" }}>
<Upload
listType="picture-circle"
fileList={fileList}
onPreview={handlePreview}
onChange={handleChange}
beforeUpload={() => false}
>
{fileList.length > 0 ? null : customUploadButton}
</Upload>
{previewImage && (
<Image
wrapperStyle={{ display: "none" }}
preview={{
visible: previewOpen,
onVisibleChange: (visible) => setPreviewOpen(visible),
afterOpenChange: (visible) => !visible && setPreviewImage(""),
}}
src={previewImage}
/>
)}
</div>
);
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: "36px",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
{photoToUpload}
</div>
</div>
<Form
name="user-edit-form"
layout="vertical"
onFinish={onFinish}
initialValues={{
name: "",
login: "",
password: "",
email: "",
bindTenantId: "",
role: "",
status: "",
}}
style={{ flex: 1, display: "flex", flexDirection: "column" }}
>
<Form.Item
label={t("name")}
name="name"
rules={[{ required: true, message: t("nameMessage") }]}
>
<Input />
</Form.Item>
<Form.Item
label={t("login")}
name="login"
rules={[{ required: true, message: t("loginMessage") }]}
>
<Input />
</Form.Item>
<Form.Item
label={t("password")}
name="password"
rules={[{ message: t("passwordMessage") }]}
>
<Input.Password />
</Form.Item>
<Form.Item
label={t("email")}
name="email"
rules={[
{ message: t("emailMessage") },
{ type: "email", message: t("emailErrorMessage") },
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("tenant")}
name="bindTenantId"
rules={[{ message: t("tenantMessage") }]}
>
<Input />
</Form.Item>
<Form.Item
label={t("role")}
name="role"
rules={[{ required: true, message: t("roleMessage") }]}
>
<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")}
name="status"
rules={[{ required: true, message: t("statusMessage") }]}
>
<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>
<div style={{ flexGrow: 1 }} />
<Form.Item>
<Button
type="primary"
htmlType="submit"
block
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")}
</>
)}
</Button>
</Form.Item>
</Form>
</div>
);
}

View File

@@ -0,0 +1,180 @@
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;
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={{ ...user }}
style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
>
<Form.Item
label={t('name')}
name="name"
rules={[{ message: t('nameMessage') }]}
>
<Input />
</Form.Item>
{user?.id === currentUser?.id ? undefined : (
<Form.Item
label={t('login')}
name="login"
rules={[{ message: t('loginMessage') }]}
>
<Input />
</Form.Item>
)}
<Form.Item
label={t('password')}
name="password"
rules={[{ message: t('passwordMessage') }]}
>
<Input.Password />
</Form.Item>
<Form.Item
label={t('email')}
name="email"
rules={[
{ required: true, message: t('emailMessage') },
{ type: 'email', message: t('emailErrorMessage') },
]}
>
<Input />
</Form.Item>
<Form.Item
label={t('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')}>
{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')}
name="status"
rules={[{ required: true, message: t('statusMessage') }]}
>
<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>
<div style={{ flexGrow: 1 }} />
<Form.Item>
<Button
type="primary"
htmlType="submit"
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>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { MarkerType } from "@xyflow/react";
const customEdgeStyle = {
markerEnd: {
type: MarkerType.Arrow,
width: 20,
height: 20,
color: "#BDBDBD",
},
style: {
strokeWidth: 2,
stroke: "#BDBDBD",
},
type: "step",
};
export default customEdgeStyle;

View File

@@ -0,0 +1,63 @@
import { Handle, Node, NodeProps, Position, Edge } from "@xyflow/react";
import { HANDLE_STYLE_CONNECTED } from "./defaultHandleStyle";
import { useTranslation } from "react-i18next";
type AppropriationNodeData = { value: string; edges?: Edge[] };
export default function AppropriationNode({
data,
id,
edges = [],
}: NodeProps<Node & AppropriationNodeData> & { edges?: Edge[] }) {
const { t } = useTranslation();
return (
<div
style={{
border: "0px solid",
borderRadius: 8,
backgroundColor: "white",
width: "248px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
paddingLeft: "12px",
height: "48px",
fontWeight: "600px",
fontSize: "16px",
gap: "12px",
}}
>
<img
style={{ height: "24px", width: "24px" }}
src="/icons/node/calculate.svg"
alt="appropriation logo"
/>
{t("appropriationNode")}
</div>
<div style={{ height: "1px", backgroundColor: "#E2E2E2" }}></div>
<div
style={{
display: "flex",
alignItems: "center",
padding: "12px",
fontSize: "14px",
minHeight: "48px",
}}
>
{data.value as string}
</div>
<Handle
style={{
...HANDLE_STYLE_CONNECTED,
}}
type="target"
position={Position.Top}
id="input"
/>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import { Handle, NodeProps, Position, Node, Edge } from "@xyflow/react";
import {
HANDLE_STYLE_CONNECTED,
HANDLE_STYLE_CONNECTED_V,
HANDLE_STYLE_DISCONNECTED,
HANDLE_STYLE_DISCONNECTED_V,
} from "./defaultHandleStyle";
import { useTranslation } from "react-i18next";
import { useState } from "react";
interface IfElseNodeProps extends Node {
condition: string;
edges?: Edge[];
}
export default function IfElseNode({
id,
data,
edges = [],
}: NodeProps<IfElseNodeProps> & { edges?: Edge[] }) {
const { t } = useTranslation();
const isHandle1Connected = edges.some(
(e: Edge) => e.source === id && e.sourceHandle === "1"
);
const isHandle2Connected = edges.some(
(e: Edge) => e.source === id && e.sourceHandle === "2"
);
return (
<>
<div
style={{
border: "0px solid",
borderRadius: 8,
backgroundColor: "white",
width: "248px",
minHeight: "144px",
position: "relative",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
paddingLeft: "12px",
height: "48px",
fontWeight: "600px",
fontSize: "16px",
gap: "12px",
}}
>
<img
style={{ height: "24px", width: "24px" }}
src="/icons/node/ifElse.svg"
alt="if else logo"
/>
{t("ifElseNode")}
</div>
<div style={{ height: "1px", backgroundColor: "#E2E2E2" }}></div>
<div
style={{
display: "flex",
alignItems: "center",
padding: "12px",
fontSize: "14px",
minHeight: "48px",
position: "relative",
}}
>
{t("conditionIf", { condition: data.condition })}
<Handle
type="source"
position={Position.Right}
id="1"
style={{
...(isHandle1Connected
? { ...HANDLE_STYLE_CONNECTED_V }
: { ...HANDLE_STYLE_DISCONNECTED_V }),
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
}}
/>
</div>
<div style={{ height: "1px", backgroundColor: "#E2E2E2" }}></div>
<div
style={{
display: "flex",
alignItems: "center",
paddingLeft: "12px",
fontSize: "14px",
height: "48px",
}}
>
{t("conditionElse")}
</div>
<Handle
type="target"
position={Position.Top}
id="input"
style={{ ...HANDLE_STYLE_CONNECTED }}
/>
<Handle
type="source"
position={Position.Bottom}
id="2"
style={
isHandle2Connected
? { ...HANDLE_STYLE_CONNECTED }
: { ...HANDLE_STYLE_DISCONNECTED }
}
/>
</div>
</>
);
}

View File

@@ -0,0 +1,83 @@
import { Edge, Handle, Node, NodeProps, Position } from "@xyflow/react";
import { useTranslation } from "react-i18next";
import {
HANDLE_STYLE_CONNECTED,
HANDLE_STYLE_DISCONNECTED,
} from "./defaultHandleStyle";
import { useState } from "react";
interface StartNodeProps extends Node {
edges?: Edge[];
}
export default function StartNode({
id,
edges = [],
}: NodeProps<StartNodeProps> & { edges?: Edge[] }) {
const { t } = useTranslation();
const isHandleConnected = edges.some(
(e: Edge) => e.source === id && e.sourceHandle === "1"
);
return (
<div
style={{
border: "0px solid",
borderRadius: 8,
backgroundColor: "white",
width: "248px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
paddingLeft: "12px",
height: "40px",
fontWeight: "600px",
fontSize: "16px",
gap: "12px",
backgroundColor: "#D4E0BD",
borderRadius: "8px 8px 0 0",
}}
>
<div
style={{
height: "24px",
width: "24px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<img
style={{ height: "16px", width: "18px" }}
src="/icons/node/home.svg"
alt="start logo"
/>
</div>
{t("startNode")}
</div>
<div style={{ height: "1px", backgroundColor: "#E2E2E2" }}></div>
<div
style={{
display: "flex",
alignItems: "center",
padding: "12px",
fontSize: "14px",
}}
>
{t("startNodeDescription")}
</div>
<Handle
type="source"
position={Position.Bottom}
id="1"
style={
isHandleConnected
? { ...HANDLE_STYLE_CONNECTED }
: { ...HANDLE_STYLE_DISCONNECTED }
}
/>
</div>
);
}

View File

@@ -0,0 +1,34 @@
// horizontal
const HANDLE_STYLE_CONNECTED = {
width: 8,
height: 8,
backgroundColor: "#BDBDBD",
};
// horizontal
const HANDLE_STYLE_DISCONNECTED = {
width: 20,
height: 20,
backgroundColor: "#C7D95A",
borderColor: "#606060",
borderWidth: 2,
};
// vertical
const HANDLE_STYLE_CONNECTED_V = {
...HANDLE_STYLE_CONNECTED,
right: "-4px",
};
// vertical
const HANDLE_STYLE_DISCONNECTED_V = {
...HANDLE_STYLE_DISCONNECTED,
right: "-10px",
};
export {
HANDLE_STYLE_CONNECTED,
HANDLE_STYLE_DISCONNECTED,
HANDLE_STYLE_CONNECTED_V,
HANDLE_STYLE_DISCONNECTED_V,
};

View File

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

View File

@@ -1,71 +1,119 @@
import i18n from 'i18next'; import i18n from "i18next";
import { initReactI18next } from 'react-i18next'; import { initReactI18next } from "react-i18next";
import LanguageDetector from 'i18next-browser-languagedetector'; import LanguageDetector from "i18next-browser-languagedetector";
i18n i18n
.use(LanguageDetector) .use(LanguageDetector)
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
fallbackLng: 'en', fallbackLng: "en",
supportedLngs: ['en', 'ru'], supportedLngs: ["en", "ru"],
interpolation: { escapeValue: false }, interpolation: { escapeValue: false },
resources: { resources: {
en: { en: {
translation: { translation: {
accounts: 'Accounts', accounts: "Accounts",
processDiagrams: 'Process diagrams', processDiagrams: "Process diagrams",
runningProcesses: 'Running processes', runningProcesses: "Running processes",
settings: 'Settings', settings: "Settings",
eventsList: 'Events list', eventsList: "Events list",
configuration: 'Configuration', configuration: "Configuration",
selectPhoto: 'Select photo', selectPhoto: "Select photo",
name: 'Name', name: "Name",
login: 'Login', login: "Login",
password: 'Password', password: "Password",
email: 'Email', email: "Email",
tenant: 'Tenant', tenant: "Tenant",
role: 'Role', role: "Role",
status: 'Status', status: "Status",
nameMessage: 'Enter name', nameMessage: "Enter name",
loginMessage: 'Enter login', loginMessage: "Enter login",
passwordMessage: 'Enter password', passwordMessage: "Enter password",
emailMessage: 'Enter email', emailMessage: "Enter email",
emailErrorMessage: 'Incorrect email', emailErrorMessage: "Incorrect email",
tenantMessage: 'Enter tenant', tenantMessage: "Enter tenant",
roleMessage: 'Choose role', roleMessage: "Choose role",
statusMessage: 'Choose status', statusMessage: "Choose status",
addAccount: 'Add account', addAccount: "Add account",
save: 'Save changes', save: "Save changes",
newAccount: 'New account', 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)",
// nodes
startNode: "Start",
startNodeDescription: "Start",
connectToExisting: "Connect to existing",
editNode: "Editing a block",
connectTo: "Connect to {{nodeId}}",
ifElseNode: "IF - ELSE",
conditionIf: "If {{condition}}, then",
conditionElse: "Else",
appropriationNode: "Appropriation",
}, },
}, },
ru: { ru: {
translation: { translation: {
accounts: 'Учетные записи', accounts: "Учетные записи",
processDiagrams: 'Схемы процессов', processDiagrams: "Схемы процессов",
runningProcesses: 'Запущенные процессы', runningProcesses: "Запущенные процессы",
settings: 'Настройки', settings: "Настройки",
eventsList: 'Справочкин событий', eventsList: "Справочкин событий",
configuration: 'Конфигурация', configuration: "Конфигурация",
selectPhoto: 'Выбрать фото', selectPhoto: "Выбрать фото",
name: 'Имя', name: "Имя",
login: 'Логин', login: "Логин",
password: 'Пароль', password: "Пароль",
email: 'Имейл', email: "Имейл",
tenant: 'Привязка', tenant: "Привязка",
role: 'Роль', role: "Роль",
status: 'Статус', status: "Статус",
nameMessage: 'Введите имя', nameMessage: "Введите имя",
loginMessage: 'Введите логин', loginMessage: "Введите логин",
passwordMessage: 'Введите пароль', passwordMessage: "Введите пароль",
emailMessage: 'Введите имейл', emailMessage: "Введите имейл",
emailErrorMessage: 'Некорректный имейл', emailErrorMessage: "Некорректный имейл",
tenantMessage: 'Введите привязку', tenantMessage: "Введите привязку",
roleMessage: 'Выберите роль', roleMessage: "Выберите роль",
statusMessage: 'Выберите статус', statusMessage: "Выберите статус",
addAccount: 'Добавить аккаунт', addAccount: "Добавить аккаунт",
save: 'Сохранить изменения', save: "Сохранить изменения",
newAccount: 'Новая учетная запись', newAccount: "Новая учетная запись",
ACTIVE: "Активен",
DISABLED: "Выключен",
BLOCKED: "Заблокирован",
DELETED: "Удален",
OWNER: "Владелец",
ADMIN: "Админ",
EDITOR: "Редактор",
VIEWER: "Наблюдатель",
nameLogin: "Имя, Логин",
createdAt: "Создано",
saving: "Сохранение...",
createdAccountMessage: "Пользователь успешно создан!",
editAccountMessage: "Пользователь успешно обновлен!",
you: "(Вы)",
// nodes
startNode: "Запуск",
startNodeDescription: "Включение",
connectToExisting: "Подключить к существующей",
editNode: "Редактирование блока",
connectTo: `Подключить к {{nodeId}}`,
ifElseNode: "ЕСЛИ - ТО",
conditionIf: "Если {{condition}}, то",
conditionElse: "Иначе",
appropriationNode: "Присвоение",
}, },
}, },
}, },

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,18 +0,0 @@
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';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<AppWrapper>
<BrowserRouter>
<App />
</BrowserRouter>
</AppWrapper>
);

16
client/src/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "@/index.css";
import App from "@/App";
import AppWrapper from "@/config/AppWrapper";
import "@xyflow/react/dist/style.css";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<AppWrapper>
<App />
</AppWrapper>
);

View File

@@ -1,36 +1,219 @@
import Header from '../components/Header'; import { useEffect, useState } from "react";
import { useState } from 'react'; import { useTranslation } from "react-i18next";
import ContentDrawer from '../components/ContentDrawer'; import { AccountStatus, AllUser, AllUserResponse } from "@/types/user";
import UserCreate from '../components/UserCreate'; import Header from "@/components/Header";
import { useTranslation } from 'react-i18next'; import ContentDrawer from "@/components/drawers/ContentDrawer";
import UserCreate from "@/components/drawers/users/UserCreate";
import { Avatar, Table } from "antd";
import { TableProps } from "antd/lib";
import { UserService } from "@/services/userService";
import UserEdit from "@/components/drawers/users/UserEdit";
import { useSearchParams } from "react-router-dom";
export default function AccountsPage() { export default function AccountsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [openCreate, setOpenCreate] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const showDrawer = () => setOpen(true); const [activeAccount, setActiveAccount] = useState<
const closeDrawer = () => setOpen(false); { login: string; id: number; name: string; email: string } | undefined
>(undefined);
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 ( return (
<> <>
<Header <Header
title={t('accounts')} title={t("accounts")}
additionalContent={ additionalContent={
<img <img
src="./icons/header/add_2.svg" src="./icons/header/add_2.svg"
alt="add" alt="add"
style={{ style={{
height: '18px', height: "18px",
width: '18px', width: "18px",
cursor: 'pointer', 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"> <ContentDrawer
<UserCreate /> open={openCreate}
closeDrawer={closeCreateDrawer}
type="userCreate"
>
<UserCreate getUsers={getUsers} closeDrawer={closeCreateDrawer} />
</ContentDrawer>
<ContentDrawer
login={activeAccount?.login}
name={activeAccount?.name}
email={activeAccount?.email}
open={openEdit}
closeDrawer={closeEditDrawer}
type="userEdit"
>
<UserEdit userId={activeAccount?.id} closeDrawer={closeEditDrawer} />
</ContentDrawer> </ContentDrawer>
</> </>
); );

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,22 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from "react";
import { Layout } from 'antd'; import { Layout } from "antd";
import Sider from 'antd/es/layout/Sider'; import Sider from "antd/es/layout/Sider";
import SiderMenu from '../components/SiderMenu'; import { Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import SiderMenu from "@/components/SiderMenu";
import ProcessDiagramPage from './ProcessDiagramPage'; import ProcessDiagramPage from "./ProcessDiagramPage";
import RunningProcessesPage from './RunningProcessesPage'; import AccountsPage from "./AccountsPage";
import AccountsPage from './AccountsPage'; import ConfigurationPage from "./ConfigurationPage";
import EventsListPage from './EventsListPage'; import EventsListPage from "./EventsListPage";
import ConfigurationPage from './ConfigurationPage'; import RunningProcessesPage from "./RunningProcessesPage";
export default function MainLayout() { export default function MainLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [selectedKey, setSelectedKey] = useState('1'); const [selectedKey, setSelectedKey] = useState("1");
const [width, setWidth] = useState<number | string>('15%'); const [width, setWidth] = useState<number | string>("15%");
const [collapsedWidth, setCollapsedWidth] = useState(50); const [collapsedWidth, setCollapsedWidth] = useState(50);
const calculateWidths = () => { const calculateWidths = () => {
@@ -29,24 +29,24 @@ export default function MainLayout() {
useEffect(() => { useEffect(() => {
calculateWidths(); calculateWidths();
window.addEventListener('resize', calculateWidths); window.addEventListener("resize", calculateWidths);
return () => window.removeEventListener('resize', calculateWidths); return () => window.removeEventListener("resize", calculateWidths);
}, []); }, []);
useEffect(() => { useEffect(() => {
if (location.pathname === '/') { if (location.pathname === "/") {
navigate('/process-diagram'); navigate("/process-diagram");
} }
setSelectedKey(location.pathname); setSelectedKey(location.pathname);
}, [location.pathname]); }, [location.pathname]);
function hangleMenuClick(e: any) { function hangleMenuClick(e: any) {
const key = e.key; const key = e.key;
if (key === 'toggle') { if (key === "toggle") {
setCollapsed(!collapsed); setCollapsed(!collapsed);
return; return;
} }
if (key === 'divider') { if (key === "divider") {
return; return;
} }
@@ -55,7 +55,7 @@ export default function MainLayout() {
} }
return ( return (
<Layout style={{ minHeight: '100vh' }}> <Layout style={{ minHeight: "100vh" }}>
<Sider <Sider
className="sider" className="sider"
collapsible collapsible

View File

@@ -1,11 +1,33 @@
import { useTranslation } from 'react-i18next'; import Header from "@/components/Header";
import Header from '../components/Header'; import { useTranslation } from "react-i18next";
import ReactFlowDrawer from "@/components/ReactFlowDrawer";
import { useState } from "react";
import ContentDrawer from "@/components/drawers/ContentDrawer";
export default function ProcessDiagramPage() { export default function ProcessDiagramPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const showDrawer = () => setIsDrawerOpen(true);
const closeDrawer = () => {
setIsDrawerOpen(false);
};
return ( return (
<> <>
<Header title={t('processDiagrams')} /> <Header title={t("processDiagrams")} />
<div style={{ width: "100%", height: "100%" }}>
<ReactFlowDrawer showDrawer={showDrawer} />
</div>
<ContentDrawer
open={isDrawerOpen}
closeDrawer={closeDrawer}
children={undefined}
type={"nodeEdit"}
></ContentDrawer>
</> </>
); );
} }

View File

@@ -1,8 +1,18 @@
// ProtectedRoute.js /* eslint-disable react-hooks/exhaustive-deps */
import { Outlet } from 'react-router-dom'; import { Outlet, useNavigate } from 'react-router-dom';
import React from 'react'; import React, { useEffect } from 'react';
import { useUserSelector } from '@/store/userStore';
const ProtectedRoute = (): React.JSX.Element => { const ProtectedRoute = (): React.JSX.Element => {
const navigate = useNavigate();
const user = useUserSelector();
useEffect(() => {
if (!user?.id) {
navigate('/login');
}
}, [user]);
return <Outlet />; return <Outlet />;
}; };
export default ProtectedRoute; export default ProtectedRoute;

View File

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

View File

@@ -0,0 +1,30 @@
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) {
const token = await api.login(auth);
useAuthStore.getState().setAccessToken(token.accessToken);
localStorage.setItem('refreshToken', token.refreshToken as string);
await UserService.getProfile().then((user) => {
useUserStore.getState().setUser(user);
});
}
static async logout() {
console.log('logout');
useUserStore.getState().setUser(null);
useAuthStore.getState().setAccessToken(null);
localStorage.removeItem('userInfo');
localStorage.removeItem('refreshToken');
}
static async refresh() {
console.log('refresh');
const token = await api.refreshToken();
useAuthStore.getState().setAccessToken(token.accessToken);
}
}

View File

@@ -0,0 +1,38 @@
import api from '@/api/api';
import { AllUserResponse, User, UserCreate, UserUpdate } from '@/types/user';
export class UserService {
static async getProfile(): Promise<User> {
console.log('getProfile');
const user = api.getProfile();
return user;
}
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

@@ -0,0 +1,18 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
type AuthState = {
accessToken: string | null;
setAccessToken: (token: string | null) => void;
};
export const useAuthStore = create<AuthState>()(
devtools((set) => ({
accessToken: null,
setAccessToken: (token) => set({ accessToken: token }),
}))
);
export const useAuthSelector = () => {
return useAuthStore((state) => state.accessToken);
};

View File

@@ -0,0 +1,25 @@
import { Edge, Node } from "@xyflow/react";
import { create } from "zustand";
import { devtools } from "zustand/middleware";
type NodeStoreState = {
node: Node;
edge: Edge;
loading: boolean;
};
type NodeStoreActions = {
addNode: (node: Node) => void;
addEdge: (edge: Edge) => void;
removeNode: (nodeId: string) => void;
removeEdge: (edgeId: string) => void;
};
type NodeStore = NodeStoreState & NodeStoreActions;
export const useNodeStore = create<NodeStore>()(
devtools((set) => ({
nodes: [],
edges: [],
}))
);

View File

@@ -1,16 +1,16 @@
import { User } from '@/types/user';
import { create } from 'zustand'; import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware'; import { devtools, persist } from 'zustand/middleware';
import { User } from '../types/user';
const userInfo = localStorage.getItem('userInfo'); const userInfo = localStorage.getItem('userInfo');
type UserStoreState = { type UserStoreState = {
user: User; user: User | null;
loading: boolean; loading: boolean;
}; };
type UserStoreActions = { type UserStoreActions = {
setUser: (user: User) => void; setUser: (user: User | null) => void;
}; };
type UserStore = UserStoreState & UserStoreActions; type UserStore = UserStoreState & UserStoreActions;
@@ -21,7 +21,7 @@ export const useUserStore = create<UserStore>()(
(set, get) => ({ (set, get) => ({
user: userInfo != null ? JSON.parse(userInfo) : ({} as User), user: userInfo != null ? JSON.parse(userInfo) : ({} as User),
loading: false, loading: false,
setUser: (user: User) => set({ user }), setUser: (user: User | null) => set({ user }),
}), }),
{ name: 'userInfo' } { name: 'userInfo' }
) )

View File

@@ -1,4 +1,4 @@
import { components } from './openapi-types'; import { components } from './openapi-types';
export type Auth = components['schemas']['Auth']; export type Auth = components['schemas']['Auth'];
export type Access = components['schemas']['Access']; export type Tokens = components['schemas']['Tokens'];

View File

@@ -120,11 +120,6 @@ export interface paths {
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { schemas: {
/** Access */
Access: {
/** Accesstoken */
accessToken: string;
};
/** AccountKeyring */ /** AccountKeyring */
AccountKeyring: { AccountKeyring: {
/** Ownerid */ /** Ownerid */
@@ -196,6 +191,10 @@ export interface components {
amountCount: number; amountCount: number;
/** Amountpages */ /** Amountpages */
amountPages: number; amountPages: number;
/** Currentpage */
currentPage: number;
/** Limit */
limit: number;
}; };
/** Auth */ /** Auth */
Auth: { Auth: {
@@ -219,6 +218,13 @@ export interface components {
* @enum {string} * @enum {string}
*/ */
KeyType: "PASSWORD" | "ACCESS_TOKEN" | "REFRESH_TOKEN" | "API_KEY"; KeyType: "PASSWORD" | "ACCESS_TOKEN" | "REFRESH_TOKEN" | "API_KEY";
/** Tokens */
Tokens: {
/** Accesstoken */
accessToken: string;
/** Refreshtoken */
refreshToken?: string | null;
};
/** User */ /** User */
User: { User: {
/** Id */ /** Id */
@@ -245,6 +251,25 @@ export interface components {
createdAt: string; createdAt: string;
status: components["schemas"]["AccountStatus"]; 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 */
UserUpdate: { UserUpdate: {
/** Id */ /** Id */
@@ -255,6 +280,8 @@ export interface components {
login?: string | null; login?: string | null;
/** Email */ /** Email */
email?: string | null; email?: string | null;
/** Password */
password?: string | null;
/** Bindtenantid */ /** Bindtenantid */
bindTenantId?: string | null; bindTenantId?: string | null;
role?: components["schemas"]["AccountRole"] | null; role?: components["schemas"]["AccountRole"] | null;
@@ -305,7 +332,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Access"]; "application/json": components["schemas"]["Tokens"];
}; };
}; };
/** @description Validation Error */ /** @description Validation Error */
@@ -334,7 +361,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Access"]; "application/json": components["schemas"]["Tokens"];
}; };
}; };
}; };
@@ -433,7 +460,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["UserUpdate"]; "application/json": components["schemas"]["UserCreate"];
}; };
}; };
responses: { responses: {
@@ -443,7 +470,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["User"]; "application/json": components["schemas"]["AllUser"];
}; };
}; };
/** @description Validation Error */ /** @description Validation Error */
@@ -474,7 +501,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["User"]; "application/json": components["schemas"]["UserUpdate"];
}; };
}; };
/** @description Validation Error */ /** @description Validation Error */
@@ -509,7 +536,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["User"]; "application/json": components["schemas"]["UserUpdate"];
}; };
}; };
/** @description Validation Error */ /** @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'];

8
client/src/utils/edge.ts Normal file
View File

@@ -0,0 +1,8 @@
function edgeTitleGenerator(counter: number) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const num = Math.ceil(counter / chars.length);
const letterIndex = (counter - 1) % chars.length;
return `${chars[letterIndex]}${num}`;
}
export { edgeTitleGenerator };

View File

@@ -1,11 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es5",
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
@@ -18,9 +14,11 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx" "jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}, },
"include": [ "include": ["src"]
"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] [program:client]
environment= environment=
REACT_APP_WEBSOCKET_PROTOCOL=ws, VITE_APP_WEBSOCKET_PROTOCOL=ws,
REACT_APP_HTTP_PROTOCOL=http, VITE_APP_HTTP_PROTOCOL=http,
REACT_APP_API_URL=localhost:8000, VITE_APP_API_URL=localhost:8000,
REACT_APP_URL=localhost:3000 VITE_APP_URL=localhost:3000
command=bash -c 'cd client; npm run build; serve -s build' command=bash -c 'cd client; npm run build; npm run preview'
numprocs=1 numprocs=1
process_name=node-%(process_num)d process_name=node-%(process_num)d
stdout_logfile=client.out.log stdout_logfile=client.out.log