diff --git a/Makefile b/Makefile index c0e1a7f..1a7ff7a 100644 --- a/Makefile +++ b/Makefile @@ -12,8 +12,7 @@ services: start-api: cd api && \ - source .venv/bin/activate && \ - python -m ${API_APPLICATION_NAME} + poetry run python -m ${API_APPLICATION_NAME} start-client: cd client && \ @@ -21,25 +20,21 @@ start-client: migrate: cd api && \ - source .venv/bin/activate && \ cd $(API_APPLICATION_NAME)/db && \ - PYTHONPATH='../..' ALEMBIC_MIGRATIONS=True alembic upgrade $(args) + PYTHONPATH='../..' ALEMBIC_MIGRATIONS=True poetry run alembic upgrade $(args) downgrade: cd api && \ - source .venv/bin/activate && \ cd $(API_APPLICATION_NAME)/db && \ - PYTHONPATH='../..' alembic downgrade -1 + PYTHONPATH='../..' poetry run alembic downgrade -1 revision: cd api && \ - source .venv/bin/activate && \ cd $(API_APPLICATION_NAME)/db && \ - PYTHONPATH='../..' ALEMBIC_MIGRATIONS=True alembic revision --autogenerate + PYTHONPATH='../..' ALEMBIC_MIGRATIONS=True poetry run alembic revision --autogenerate venv-api: cd api && \ - poetry env activate \ poetry install venv-client: @@ -62,3 +57,8 @@ format-api: check-api: cd api && \ poetry run ruff format . --check + +regenerate-openapi-local: + cd client \ + rm src/types/openapi-types.ts \ + npx openapi-typescript http://localhost:8000/openapi -o src/types/openapi-types.ts diff --git a/api/api/db/alembic/versions/93106fbe7d83_.py b/api/api/db/alembic/versions/93106fbe7d83_.py new file mode 100644 index 0000000..53d9604 --- /dev/null +++ b/api/api/db/alembic/versions/93106fbe7d83_.py @@ -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 ### diff --git a/api/api/db/logic/account.py b/api/api/db/logic/account.py index 5f16ac4..2efb0af 100644 --- a/api/api/db/logic/account.py +++ b/api/api/db/logic/account.py @@ -1,19 +1,17 @@ -from typing import Optional import math - from datetime import datetime, timezone - -from sqlalchemy import insert, select, func -from sqlalchemy.ext.asyncio import AsyncConnection from enum import Enum +from typing import Optional + +from sqlalchemy import func, insert, select +from sqlalchemy.ext.asyncio import AsyncConnection from api.db.tables.account import account_table - from api.schemas.account.account import User -from api.schemas.endpoints.account import AllUserResponse, all_user_adapter +from api.schemas.endpoints.account import all_user_adapter, AllUser, AllUserResponse, UserCreate -async def get_user_accaunt_page(connection: AsyncConnection, page, limit) -> Optional[User]: +async def get_user_accaunt_page(connection: AsyncConnection, page, limit) -> Optional[AllUserResponse]: """ Получает список ползовелей заданных значениями page, limit. """ @@ -47,31 +45,28 @@ async def get_user_accaunt_page(connection: AsyncConnection, page, limit) -> Opt validated_users = all_user_adapter.validate_python(users_data) - return AllUserResponse(users=validated_users, amount_count=total_count, amount_pages=total_pages) + return AllUserResponse( + users=validated_users, + amount_count=total_count, + amount_pages=total_pages, + current_page=page, + limit=limit, + ) -async def get_user_by_id(connection: AsyncConnection, id: int) -> Optional[User]: +async def get_user_by_id(connection: AsyncConnection, user_id: int) -> Optional[AllUser]: """ Получает юзера по id. """ - query = select(account_table).where(account_table.c.id == id) + query = select(account_table).where(account_table.c.id == user_id) user_db_cursor = await connection.execute(query) - user_db = user_db_cursor.one_or_none() + user = user_db_cursor.mappings().one_or_none() - if not user_db: + if not user: return None - user_data = { - column.name: ( - getattr(user_db, column.name).name - if isinstance(getattr(user_db, column.name), Enum) - else getattr(user_db, column.name) - ) - for column in account_table.columns - } - - return User.model_validate(user_data) + return AllUser.model_validate(user) async def get_user_by_login(connection: AsyncConnection, login: str) -> Optional[User]: @@ -107,7 +102,7 @@ async def update_user_by_id(connection: AsyncConnection, update_values, user) -> await connection.commit() -async def create_user(connection: AsyncConnection, user: User, creator_id: int) -> Optional[User]: +async def create_user(connection: AsyncConnection, user: UserCreate, creator_id: int) -> Optional[AllUser]: """ Создает нове поле в таблице account_table. """ @@ -117,14 +112,15 @@ async def create_user(connection: AsyncConnection, user: User, creator_id: int) email=user.email, bind_tenant_id=user.bind_tenant_id, role=user.role.value, - meta=user.meta, + meta=user.meta or {}, creator_id=creator_id, created_at=datetime.now(timezone.utc), status=user.status.value, ) - await connection.execute(query) + res = await connection.execute(query) await connection.commit() + new_user = await get_user_by_id(connection, res.lastrowid) - return user + return new_user diff --git a/api/api/db/logic/auth.py b/api/api/db/logic/auth.py index 20e9573..bb02986 100644 --- a/api/api/db/logic/auth.py +++ b/api/api/db/logic/auth.py @@ -8,13 +8,14 @@ from api.db.tables.account import account_table, account_keyring_table, KeyType, from api.schemas.account.account import User from api.schemas.account.account_keyring import AccountKeyring +from api.schemas.endpoints.account import AllUser from api.utils.key_id_gen import KeyIdGenerator from datetime import datetime, timezone -async def get_user(connection: AsyncConnection, login: str) -> Optional[User]: +async def get_user(connection: AsyncConnection, login: str) -> tuple[Optional[AllUser], Optional[AccountKeyring]]: query = ( select(account_table, account_keyring_table) .join(account_keyring_table, account_table.c.id == account_keyring_table.c.owner_id) @@ -45,7 +46,7 @@ async def get_user(connection: AsyncConnection, login: str) -> Optional[User]: for column in account_keyring_table.columns } - user = User.model_validate(user_data) + user = AllUser.model_validate(user_data) password = AccountKeyring.model_validate(password_data) return user, password diff --git a/api/api/db/logic/keyring.py b/api/api/db/logic/keyring.py index 91469a6..ad3de1b 100644 --- a/api/api/db/logic/keyring.py +++ b/api/api/db/logic/keyring.py @@ -1,13 +1,14 @@ -from typing import Optional -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from enum import Enum +from typing import Optional -from sqlalchemy import insert, select +from sqlalchemy import insert, select, update +from sqlalchemy.dialects.mysql import insert as mysql_insert from sqlalchemy.ext.asyncio import AsyncConnection -from api.db.tables.account import account_keyring_table - +from api.db.tables.account import account_keyring_table, KeyStatus, KeyType from api.schemas.account.account_keyring import AccountKeyring +from api.utils.hasher import hasher async def get_key_by_id(connection: AsyncConnection, key_id: str) -> Optional[AccountKeyring]: @@ -67,3 +68,37 @@ async def create_key(connection: AsyncConnection, key: AccountKeyring, key_id: i await connection.commit() return key + + +async def create_password_key(connection: AsyncConnection, password: str | None, owner_id: int): + if password is None: + password = hasher.generate_password() + hashed_password = hasher.hash_data(password) + stmt = mysql_insert(account_keyring_table).values( + owner_id=owner_id, + key_type=KeyType.PASSWORD.value, + key_id="PASSWORD", + key_value=hashed_password, + created_at=datetime.now(timezone.utc), + expiry=datetime.now(timezone.utc) + timedelta(days=365), + status=KeyStatus.ACTIVE, + ) + stmt.on_duplicate_key_update(key_value=hashed_password) + await connection.execute(stmt) + await connection.commit() + + +async def update_password_key(connection: AsyncConnection, owner_id: int, password: str): + stmt = select(account_keyring_table).where(account_keyring_table.c.owner_id == owner_id) + result = await connection.execute(stmt) + keyring = result.one_or_none() + if not keyring: + await create_password_key(connection, password, owner_id) + else: + stmt = ( + update(account_keyring_table) + .values(key_value=hasher.hash_data(password), expiry=datetime.now(timezone.utc) + timedelta(days=365)) + .where(account_keyring_table.c.owner_id == owner_id) + ) + await connection.execute(stmt) + await connection.commit() diff --git a/api/api/db/tables/account.py b/api/api/db/tables/account.py index 84c689e..5325ddc 100644 --- a/api/api/db/tables/account.py +++ b/api/api/db/tables/account.py @@ -60,7 +60,7 @@ account_keyring_table = Table( Column("owner_id", UnsignedInt, ForeignKey("account.id"), primary_key=True, nullable=False), Column("key_type", SQLAEnum(KeyType), primary_key=True, nullable=False), Column("key_id", String(40), primary_key=True, default=None), - Column("key_value", String(255), nullable=False), + Column("key_value", String(512), nullable=False), Column("created_at", DateTime(timezone=True), server_default=func.now()), Column("expiry", DateTime(timezone=True), nullable=True), Column("status", SQLAEnum(KeyStatus), nullable=False), diff --git a/api/api/endpoints/account.py b/api/api/endpoints/account.py index 99ecf83..5ce0da4 100644 --- a/api/api/endpoints/account.py +++ b/api/api/endpoints/account.py @@ -4,29 +4,24 @@ from fastapi import ( HTTPException, status, ) - - from sqlalchemy.ext.asyncio import AsyncConnection from api.db.connection.session import get_connection_dep - from api.db.logic.account import ( - get_user_by_id, - update_user_by_id, create_user, - get_user_by_login, get_user_accaunt_page, + get_user_by_id, + get_user_by_login, + update_user_by_id, ) - -from api.schemas.account.account import User +from api.db.logic.keyring import create_password_key, update_password_key from api.db.tables.account import AccountStatus +from api.schemas.account.account import User from api.schemas.base import bearer_schema -from api.schemas.endpoints.account import UserUpdate, AllUserResponse +from api.schemas.endpoints.account import AllUser, AllUserResponse, UserCreate, UserUpdate from api.services.auth import get_current_user - -from api.services.user_role_validation import db_user_role_validation from api.services.update_data_validation import update_user_data_changes - +from api.services.user_role_validation import db_user_role_validation api_router = APIRouter( prefix="/account", @@ -51,9 +46,11 @@ async def get_all_account( return user_list -@api_router.get("/{user_id}", dependencies=[Depends(bearer_schema)], response_model=User) +@api_router.get("/{user_id}", dependencies=[Depends(bearer_schema)], response_model=UserUpdate) async def get_account( - user_id: int, connection: AsyncConnection = Depends(get_connection_dep), current_user=Depends(get_current_user) + user_id: int, + connection: AsyncConnection = Depends(get_connection_dep), + current_user=Depends(get_current_user), ): authorize_user = await db_user_role_validation(connection, current_user) @@ -65,26 +62,27 @@ async def get_account( return user -@api_router.post("", dependencies=[Depends(bearer_schema)], response_model=User) +@api_router.post("", dependencies=[Depends(bearer_schema)], response_model=AllUser) async def create_account( - user: UserUpdate, connection: AsyncConnection = Depends(get_connection_dep), current_user=Depends(get_current_user) + user: UserCreate, + connection: AsyncConnection = Depends(get_connection_dep), + current_user=Depends(get_current_user), ): authorize_user = await db_user_role_validation(connection, current_user) user_validation = await get_user_by_login(connection, user.login) if user_validation is None: - await create_user(connection, user, authorize_user.id) - user_new = await get_user_by_login(connection, user.login) - return user_new - + new_user = await create_user(connection, user, authorize_user.id) + await create_password_key(connection, user.password, new_user.id) + return new_user else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="An account with this information already exists." ) -@api_router.put("/{user_id}", dependencies=[Depends(bearer_schema)], response_model=User) +@api_router.put("/{user_id}", dependencies=[Depends(bearer_schema)], response_model=UserUpdate) async def update_account( user_id: int, user_update: UserUpdate, @@ -97,12 +95,15 @@ async def update_account( if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") + if user_update.password is not None: + await update_password_key(connection, user.id, user_update.password) + update_values = update_user_data_changes(user_update, user) if update_values is None: return user - user_update_data = User.model_validate({**user.model_dump(), **update_values}) + user_update_data = UserUpdate.model_validate({**user.model_dump(), **update_values}) await update_user_by_id(connection, update_values, user) @@ -113,7 +114,9 @@ async def update_account( @api_router.delete("/{user_id}", dependencies=[Depends(bearer_schema)], response_model=User) async def delete_account( - user_id: int, connection: AsyncConnection = Depends(get_connection_dep), current_user=Depends(get_current_user) + user_id: int, + connection: AsyncConnection = Depends(get_connection_dep), + current_user=Depends(get_current_user), ): authorize_user = await db_user_role_validation(connection, current_user) diff --git a/api/api/schemas/endpoints/account.py b/api/api/schemas/endpoints/account.py index 37ceeea..60a29ff 100644 --- a/api/api/schemas/endpoints/account.py +++ b/api/api/schemas/endpoints/account.py @@ -1,9 +1,9 @@ -from typing import Optional, List from datetime import datetime +from typing import List, Optional + from pydantic import EmailStr, Field, TypeAdapter from api.db.tables.account import AccountRole, AccountStatus - from api.schemas.base import Base @@ -12,6 +12,7 @@ class UserUpdate(Base): name: Optional[str] = Field(None, max_length=100) login: Optional[str] = Field(None, max_length=100) email: Optional[EmailStr] = None + password: Optional[str] = None bind_tenant_id: Optional[str] = Field(None, max_length=40) role: Optional[AccountRole] = None meta: Optional[dict] = None @@ -20,6 +21,17 @@ class UserUpdate(Base): status: Optional[AccountStatus] = None +class UserCreate(Base): + name: str = Field(max_length=100) + login: str = Field(max_length=100) + email: Optional[EmailStr] = None + password: Optional[str] = None + bind_tenant_id: Optional[str] = Field(None, max_length=40) + role: AccountRole + meta: Optional[dict] = None + status: AccountStatus + + class AllUser(Base): id: int name: str @@ -35,6 +47,8 @@ class AllUserResponse(Base): users: List[AllUser] amount_count: int amount_pages: int + current_page: int + limit: int all_user_adapter = TypeAdapter(List[AllUser]) diff --git a/api/api/services/auth.py b/api/api/services/auth.py index 761949a..d4b0161 100644 --- a/api/api/services/auth.py +++ b/api/api/services/auth.py @@ -1,27 +1,25 @@ -from fastapi import Request, HTTPException from typing import Optional + +from fastapi import HTTPException, Request from sqlalchemy.ext.asyncio import AsyncConnection + from api.db.logic.auth import get_user - -# # from backend.schemas.users.token import TokenData -from api.schemas.account.account import User from api.db.tables.account import AccountStatus - -from api.utils.hasher import Hasher +from api.schemas.endpoints.account import AllUser +from api.utils.hasher import hasher -async def get_current_user(request: Request) -> Optional[User]: +async def get_current_user(request: Request) -> str | HTTPException: if not hasattr(request.state, "current_user"): return HTTPException(status_code=401, detail="Unauthorized") return request.state.current_user -async def authenticate_user(connection: AsyncConnection, username: str, password: str) -> Optional[User]: +async def authenticate_user(connection: AsyncConnection, username: str, password: str) -> Optional[AllUser]: sql_user, sql_password = await get_user(connection, username) if not sql_user or sql_user.status != AccountStatus.ACTIVE: return None - hasher = Hasher() if not hasher.verify_data(password, sql_password.key_value): return None return sql_user diff --git a/api/api/utils/hasher.py b/api/api/utils/hasher.py index 61d4c0e..3224245 100644 --- a/api/api/utils/hasher.py +++ b/api/api/utils/hasher.py @@ -1,4 +1,6 @@ import hashlib +import secrets + # Хешер для работы с паролем. @@ -14,3 +16,10 @@ class Hasher: def verify_data(self, password: str, hashed: str) -> bool: # Проверяет пароль путем сравнения его хеша с сохраненным хешем. return self.hash_data(password) == hashed + + @staticmethod + def generate_password() -> str: + return secrets.token_urlsafe(20) + + +hasher = Hasher() diff --git a/api/api/utils/init.py b/api/api/utils/init.py index 06161a9..bc93be1 100644 --- a/api/api/utils/init.py +++ b/api/api/utils/init.py @@ -1,32 +1,23 @@ -import os import asyncio -import hashlib -import secrets +import os from api.db.connection.session import get_connection -from api.db.tables.account import account_table, account_keyring_table, AccountRole, KeyType, KeyStatus +from api.db.tables.account import account_keyring_table, account_table, AccountRole, KeyStatus, KeyType +from api.utils.hasher import hasher from api.utils.key_id_gen import KeyIdGenerator INIT_LOCK_FILE = "../init.lock" DEFAULT_LOGIN = "vorkout" -def hash_password(password: str) -> str: - return hashlib.sha256(password.encode()).hexdigest() - - -def generate_password() -> str: - return secrets.token_urlsafe(20) - - async def init(): if os.path.exists(INIT_LOCK_FILE): print("Sorry, service is already initialized") return async with get_connection() as conn: - password = generate_password() - hashed_password = hash_password(password) + password = hasher.generate_password() + hashed_password = hasher.hash_data(password) create_user_query = account_table.insert().values( name=DEFAULT_LOGIN, diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 76641c0..714b26f 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -1,10 +1,9 @@ import axios from 'axios'; -// import { Auth, Tokens } 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 } from '@/types/user'; +import { User, UserCreate, UserUpdate } from '@/types/user'; const baseURL = `${import.meta.env.VITE_APP_HTTP_PROTOCOL}://${ import.meta.env.VITE_APP_API_URL @@ -109,6 +108,23 @@ const api = { ); return response.data; }, + + async getUserById(userId: number): Promise { + const response = await base.get(`/account/${userId}`); + return response.data; + }, + + async createUser(user: UserCreate): Promise { + const response = await base.post('/account', user); + return response.data; + }, + + async updateUser(userId: number, user: UserUpdate): Promise { + const response = await base.put(`/account/${userId}`, user); + return response.data; + }, + + // keyrings }; export default api; diff --git a/client/src/components/ContentDrawer.tsx b/client/src/components/ContentDrawer.tsx index f02b6f4..4cf0627 100644 --- a/client/src/components/ContentDrawer.tsx +++ b/client/src/components/ContentDrawer.tsx @@ -2,12 +2,16 @@ import { Drawer } from 'antd'; import { useEffect, useState } from 'react'; import { Avatar, Typography } from 'antd'; import { useTranslation } from 'react-i18next'; +import { useUserSelector } from '@/store/userStore'; interface ContentDrawerProps { open: boolean; closeDrawer: () => void; children: React.ReactNode; type: 'create' | 'edit'; + login?: string; + name?: string; + email?: string | null; } export default function ContentDrawer({ @@ -15,7 +19,11 @@ export default function ContentDrawer({ closeDrawer, children, type, + login, + name, + email, }: ContentDrawerProps) { + const user = useUserSelector(); const { t } = useTranslation(); const [width, setWidth] = useState('30%'); @@ -30,6 +38,7 @@ export default function ContentDrawer({ window.addEventListener('resize', calculateWidths); return () => window.removeEventListener('resize', calculateWidths); }, []); + console.log(login, user?.login, login === user?.login); const editDrawerTitle = (
- - Александр Александров + + {name} {login === user?.login ? t('you') : ''} - alexandralex@vorkout.ru + {email}
@@ -152,7 +166,7 @@ export default function ContentDrawer({ placement="right" open={open} width={width} - destroyOnClose={true} + destroyOnHidden={true} closable={false} > {children} diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx index 2ff9076..3a65726 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -1,5 +1,9 @@ +import { useUserSelector } from '@/store/userStore'; import { Avatar } from 'antd'; import Title from 'antd/es/typography/Title'; +import { useState } from 'react'; +import ContentDrawer from './ContentDrawer'; +import UserEdit from './UserEdit'; interface HeaderProps { title: string; @@ -7,6 +11,13 @@ interface HeaderProps { } export default function Header({ title, additionalContent }: HeaderProps) { + const [openEdit, setOpenEdit] = useState(false); + + const showEditDrawer = () => setOpenEdit(true); + const closeEditDrawer = () => { + setOpenEdit(false); + }; + const user = useUserSelector(); return (
+ + {user?.id && } + ); } diff --git a/client/src/components/SiderMenu.tsx b/client/src/components/SiderMenu.tsx index 8ce7ab4..af8ca38 100644 --- a/client/src/components/SiderMenu.tsx +++ b/client/src/components/SiderMenu.tsx @@ -1,3 +1,4 @@ +import { useUserSelector } from '@/store/userStore'; import { Divider, Menu, Tooltip } from 'antd'; import React from 'react'; import { useTranslation } from 'react-i18next'; @@ -13,6 +14,7 @@ export default function SiderMenu({ selectedKey, hangleMenuClick, }: SiderMenuProps) { + const user = useUserSelector(); const { t } = useTranslation(); const collapseStyle = collapsed ? { fontSize: '12px' } @@ -74,15 +76,17 @@ export default function SiderMenu({ label: t('settings'), className: 'no-expand-icon', children: [ - { - key: '/accounts', - label: !collapsed ? ( - {t('accounts')} - ) : ( - t('accounts') - ), - style: collapseStyle, - }, + user && (user.role === 'OWNER' || user.role === 'ADMIN') + ? { + key: '/accounts', + label: !collapsed ? ( + {t('accounts')} + ) : ( + t('accounts') + ), + style: collapseStyle, + } + : undefined, { key: '/events-list', label: !collapsed ? ( diff --git a/client/src/components/UserCreate.tsx b/client/src/components/UserCreate.tsx index edf09ab..81393d3 100644 --- a/client/src/components/UserCreate.tsx +++ b/client/src/components/UserCreate.tsx @@ -8,13 +8,19 @@ import { UploadFile, GetProp, UploadProps, -} from 'antd'; -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; + message, + Spin, +} from "antd"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useUserSelector } from "@/store/userStore"; +import { UserCreate as NewUserCreate } from "@/types/user"; +import { UserService } from "@/services/userService"; +import { LoadingOutlined } from "@ant-design/icons"; const { Option } = Select; -type FileType = Parameters>[0]; +type FileType = Parameters>[0]; const getBase64 = (file: FileType): Promise => new Promise((resolve, reject) => { @@ -24,10 +30,17 @@ const getBase64 = (file: FileType): Promise => reader.onerror = (error) => reject(error); }); -export default function UserCreate() { +interface UserCreateProps { + closeDrawer: () => void; + getUsers: () => Promise; +} + +export default function UserCreate({ closeDrawer, getUsers }: UserCreateProps) { + const user = useUserSelector(); const { t } = useTranslation(); const [previewOpen, setPreviewOpen] = useState(false); - const [previewImage, setPreviewImage] = useState(''); + const [previewImage, setPreviewImage] = useState(""); + const [loading, setLoading] = useState(false); const [fileList, setFileList] = useState([]); @@ -40,40 +53,49 @@ export default function UserCreate() { setPreviewOpen(true); }; - const handleChange: UploadProps['onChange'] = ({ fileList: newFileList }) => + const handleChange: UploadProps["onChange"] = ({ fileList: newFileList }) => setFileList(newFileList); + const onFinish = async (values: NewUserCreate) => { + setLoading(true); + await UserService.createUser(values); + await getUsers(); + closeDrawer(); + setLoading(false); + message.info(t("createdAccountMessage"), 4); + }; + const customUploadButton = (
add_photo_alternate
- - {t('selectPhoto')} + + {t("selectPhoto")}
); const photoToUpload = ( -
+
{previewImage && ( setPreviewOpen(visible), - afterOpenChange: (visible) => !visible && setPreviewImage(''), + afterOpenChange: (visible) => !visible && setPreviewImage(""), }} src={previewImage} /> @@ -100,24 +122,24 @@ export default function UserCreate() { return (
{photoToUpload} @@ -126,83 +148,85 @@ export default function UserCreate() {
- + {user && user.role === "OWNER" ? ( + + ) : undefined} + + - + + + + @@ -213,14 +237,23 @@ export default function UserCreate() { type="primary" htmlType="submit" block - style={{ color: '#000' }} + style={{ color: "#000" }} > - save{' '} - {t('addAccount')} + {loading ? ( + <> + } size="small">{" "} + {t("saving")} + + ) : ( + <> + save{" "} + {t("addAccount")} + + )}
diff --git a/client/src/components/UserEdit.tsx b/client/src/components/UserEdit.tsx index bd5e8f0..70740db 100644 --- a/client/src/components/UserEdit.tsx +++ b/client/src/components/UserEdit.tsx @@ -1,47 +1,101 @@ -import { Button, Form, Input, Select } from 'antd'; +import { UserService } from '@/services/userService'; +import { useUserSelector } from '@/store/userStore'; +import { UserUpdate } from '@/types/user'; +import { LoadingOutlined } from '@ant-design/icons'; +import { Button, Form, Input, message, Select, Spin } from 'antd'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; const { Option } = Select; -export default function UserEdit() { +interface UserEditProps { + userId?: number; + closeDrawer: () => void; +} + +export default function UserEdit({ userId, closeDrawer }: UserEditProps) { + const currentUser = useUserSelector(); + const [form] = Form.useForm(); const { t } = useTranslation(); + const [user, setUser] = useState({ + 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 = {}; + + (Object.keys(values) as Array).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 (
- - - + {user?.id === currentUser?.id ? undefined : ( + + + + )} @@ -59,23 +113,27 @@ export default function UserEdit() { - - - + {user?.id === currentUser?.id ? undefined : ( + + + + )} @@ -99,12 +157,21 @@ export default function UserEdit() { block style={{ color: '#000' }} > - save{' '} - {t('save')} + {loading ? ( + <> + } size="small">{' '} + {t('saving')} + + ) : ( + <> + save{' '} + {t('save')} + + )}
diff --git a/client/src/config/i18n.ts b/client/src/config/i18n.ts index 09bd8b8..c3ea6eb 100644 --- a/client/src/config/i18n.ts +++ b/client/src/config/i18n.ts @@ -37,6 +37,20 @@ i18n addAccount: 'Add account', save: 'Save changes', newAccount: 'New account', + ACTIVE: 'Active', + DISABLED: 'Disabled', + BLOCKED: 'Blocked', + DELETED: 'Deleted', + OWNER: 'Owner', + ADMIN: 'Admin', + EDITOR: 'Editor', + VIEWER: 'Viewer', + nameLogin: 'Name, login', + createdAt: 'Created', + saving: 'Saving...', + createdAccountMessage: 'User successfully created!', + editAccountMessage: 'User successfully updated!', + you: '(You)', }, }, ru: { @@ -66,6 +80,20 @@ i18n addAccount: 'Добавить аккаунт', save: 'Сохранить изменения', newAccount: 'Новая учетная запись', + ACTIVE: 'Активен', + DISABLED: 'Выключен', + BLOCKED: 'Заблокирован', + DELETED: 'Удален', + OWNER: 'Владелец', + ADMIN: 'Админ', + EDITOR: 'Редактор', + VIEWER: 'Наблюдатель', + nameLogin: 'Имя, Логин', + createdAt: 'Создано', + saving: 'Сохранение...', + createdAccountMessage: 'Пользователь успешно создан!', + editAccountMessage: 'Пользователь успешно обновлен!', + you: '(Вы)', }, }, }, diff --git a/client/src/pages/AccountsPage.tsx b/client/src/pages/AccountsPage.tsx index 02d85fd..19e53a0 100644 --- a/client/src/pages/AccountsPage.tsx +++ b/client/src/pages/AccountsPage.tsx @@ -1,39 +1,219 @@ -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { User } from '@/types/user'; -import Header from '@/components/Header'; -import ContentDrawer from '@/components/ContentDrawer'; -import UserCreate from '@/components/UserCreate'; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AccountStatus, AllUser, AllUserResponse } from "@/types/user"; +import Header from "@/components/Header"; +import ContentDrawer from "@/components/ContentDrawer"; +import UserCreate from "@/components/UserCreate"; +import { Avatar, Table } from "antd"; +import { TableProps } from "antd/lib"; +import { UserService } from "@/services/userService"; +import UserEdit from "@/components/UserEdit"; +import { useSearchParams } from "react-router-dom"; export default function AccountsPage() { const { t } = useTranslation(); - const [open, setOpen] = useState(false); + const [openCreate, setOpenCreate] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); - const showDrawer = () => setOpen(true); - const closeDrawer = () => setOpen(false); + const [activeAccount, setActiveAccount] = useState< + { login: string; id: number; name: string; email: string } | undefined + >(undefined); - const [accounts, setAccounts] = useState([]); + 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({ + 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["columns"] = [ + { + title: "#", + dataIndex: "id", + key: "id", + }, + { + title: t("nameLogin"), + dataIndex: "nameLogin", + key: "nameLogin", + render: (text, record) => ( +
{ + setActiveAccount({ + login: record.login, + id: record.id, + name: record.name, + email: record.email || "", + }); + showEditDrawer(); + }} + style={{ + display: "flex", + alignItems: "center", + gap: "16px", + cursor: "pointer", + }} + > +
+ +
+
+
{record.name}
+
{record.login}
+
+
+ ), + }, + { + title: "E-mail", + dataIndex: "email", + key: "email", + }, + { + title: t("tenant"), + dataIndex: "bindTenantId", + key: "tenant", + }, + { + title: t("role"), + dataIndex: "role", + key: "role", + render: (text) =>
{t(text)}
, + }, + { + title: t("createdAt"), + dataIndex: "createdAt", + key: "createdAt", + render: (text) => ( +
+ {new Date(text).toLocaleString("ru", { + year: "2-digit", + month: "2-digit", + day: "2-digit", + })} +
+ ), + }, + { + title: t("status"), + dataIndex: "status", + key: "status", + render: (text) => ( +
+ {t(text)} +
+ ), + }, + ]; + + const onTableChange: TableProps["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 ( <>
} /> + - - + + + + + ); diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index b6b65e9..c2c74c7 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Form, Input, Button, Typography } from 'antd'; +import { Form, Input, Button, Typography, message } from 'antd'; import { EyeInvisibleOutlined, EyeTwoTone, @@ -45,7 +45,11 @@ export default function LoginPage() { /> -
+ { @@ -9,8 +9,30 @@ export class UserService { return user; } - static async getUsers(page: number = 1, limit: number = 10): Promise { - const users = api.getUsers(page, limit); - return users; + static async getUsers( + page: number = 1, + limit: number = 10 + ): Promise { + console.log('getUsers'); + const allUsers = api.getUsers(page, limit); + return allUsers; + } + + static async getUserById(userId: number): Promise { + console.log('getUserById'); + const user = api.getUserById(userId); + return user; + } + + static async createUser(user: UserCreate): Promise { + console.log('createUser'); + const createdUser = api.createUser(user); + return createdUser; + } + + static async updateUser(userId: number, user: UserUpdate): Promise { + console.log('updateUser'); + const updatedUser = api.updateUser(userId, user); + return updatedUser; } } diff --git a/client/src/types/openapi-types.ts b/client/src/types/openapi-types.ts index b23e43f..c9eebe6 100644 --- a/client/src/types/openapi-types.ts +++ b/client/src/types/openapi-types.ts @@ -191,6 +191,10 @@ export interface components { amountCount: number; /** Amountpages */ amountPages: number; + /** Currentpage */ + currentPage: number; + /** Limit */ + limit: number; }; /** Auth */ Auth: { @@ -247,6 +251,25 @@ export interface components { createdAt: string; status: components["schemas"]["AccountStatus"]; }; + /** UserCreate */ + UserCreate: { + /** Name */ + name?: string | null; + /** Login */ + login?: string | null; + /** Email */ + email?: string | null; + /** Password */ + password?: string | null; + /** Bindtenantid */ + bindTenantId?: string | null; + role?: components["schemas"]["AccountRole"] | null; + /** Meta */ + meta?: { + [key: string]: unknown; + } | null; + status?: components["schemas"]["AccountStatus"] | null; + }; /** UserUpdate */ UserUpdate: { /** Id */ @@ -257,6 +280,8 @@ export interface components { login?: string | null; /** Email */ email?: string | null; + /** Password */ + password?: string | null; /** Bindtenantid */ bindTenantId?: string | null; role?: components["schemas"]["AccountRole"] | null; @@ -435,7 +460,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["UserUpdate"]; + "application/json": components["schemas"]["UserCreate"]; }; }; responses: { @@ -445,7 +470,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["User"]; + "application/json": components["schemas"]["AllUser"]; }; }; /** @description Validation Error */ @@ -476,7 +501,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["User"]; + "application/json": components["schemas"]["UserUpdate"]; }; }; /** @description Validation Error */ @@ -511,7 +536,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["User"]; + "application/json": components["schemas"]["UserUpdate"]; }; }; /** @description Validation Error */ diff --git a/client/src/types/user.ts b/client/src/types/user.ts index 10b18d4..6a3f506 100644 --- a/client/src/types/user.ts +++ b/client/src/types/user.ts @@ -1,3 +1,9 @@ import { components } from './openapi-types'; 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'];