diff --git a/api/app/controllers/auth_router.py b/api/app/controllers/auth_router.py
index af29289..0da98df 100644
--- a/api/app/controllers/auth_router.py
+++ b/api/app/controllers/auth_router.py
@@ -15,7 +15,6 @@ auth_router = APIRouter()
description='Logs in the user and outputs the `access_token` ',
)
async def auth_user(
- response: Response,
user_data: LoginRequest,
db: AsyncSession = Depends(get_db)
):
diff --git a/api/app/controllers/users_router.py b/api/app/controllers/users_router.py
new file mode 100644
index 0000000..f6d2048
--- /dev/null
+++ b/api/app/controllers/users_router.py
@@ -0,0 +1,26 @@
+from typing import List, Optional
+
+from fastapi import APIRouter, Depends, Response
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database.session import get_db
+from app.domain.entities.users import UserRead
+from app.domain.models import User
+from app.infrastructure.dependencies import require_auth_user
+from app.infrastructure.users_service import UsersService
+
+users_router = APIRouter()
+
+
+@users_router.get(
+ '/me/',
+ response_model=Optional[UserRead],
+ summary='Returns current authenticated user data',
+ description='Returns current authenticated user data',
+)
+async def get_authenticated_user_data(
+ db: AsyncSession = Depends(get_db),
+ user: User = Depends(require_auth_user)
+):
+ users_service = UsersService(db)
+ return await users_service.get_by_id(user.id)
diff --git a/api/app/domain/entities/roles.py b/api/app/domain/entities/roles.py
new file mode 100644
index 0000000..a18fa7e
--- /dev/null
+++ b/api/app/domain/entities/roles.py
@@ -0,0 +1,9 @@
+from pydantic import BaseModel
+
+
+class RoleRead(BaseModel):
+ id: int
+ title: str
+
+ class Config:
+ from_attributes = True
diff --git a/api/app/domain/entities/statuses.py b/api/app/domain/entities/statuses.py
new file mode 100644
index 0000000..c269c23
--- /dev/null
+++ b/api/app/domain/entities/statuses.py
@@ -0,0 +1,9 @@
+from pydantic import BaseModel
+
+
+class StatusRead(BaseModel):
+ id: int
+ title: str
+
+ class Config:
+ from_attributes = True
diff --git a/api/app/domain/entities/users.py b/api/app/domain/entities/users.py
index 9a2db5f..4603040 100644
--- a/api/app/domain/entities/users.py
+++ b/api/app/domain/entities/users.py
@@ -3,6 +3,9 @@ from typing import Optional
from pydantic import BaseModel, EmailStr, Field
+from app.domain.entities.roles import RoleRead
+from app.domain.entities.statuses import StatusRead
+
class UserRegister(BaseModel):
first_name: str = Field(max_length=250)
@@ -30,6 +33,8 @@ class UserRead(BaseModel):
status_id: int
role_id: int
+ role: RoleRead
+ status: StatusRead
+
class Config:
- orm_mode = True
from_attributes = True
diff --git a/api/app/infrastructure/dependencies.py b/api/app/infrastructure/dependencies.py
index 8901f90..2dca81d 100644
--- a/api/app/infrastructure/dependencies.py
+++ b/api/app/infrastructure/dependencies.py
@@ -16,7 +16,7 @@ security = HTTPBearer()
async def require_auth_user(
credentials: HTTPAuthorizationCredentials = Security(security),
db: AsyncSession = Depends(get_db)
-):
+) -> User:
auth_data = get_auth_data()
try:
@@ -34,7 +34,7 @@ async def require_auth_user(
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Ошибка авторизации')
- if user.status != UserStatuses.ACTIVE:
+ if user.status.title != UserStatuses.ACTIVE:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Ошибка авторизации')
return user
diff --git a/api/app/infrastructure/users_service.py b/api/app/infrastructure/users_service.py
index 903895b..d476ba5 100644
--- a/api/app/infrastructure/users_service.py
+++ b/api/app/infrastructure/users_service.py
@@ -1,17 +1,20 @@
-import re
-from typing import Optional
-
from fastapi import HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
-from app.application.roles_repository import RolesRepository
-from app.application.statuses_repository import StatusesRepository
from app.application.users_repository import UsersRepository
-from app.domain.entities.users import UserRegister, UserRead
-from app.domain.models.users import User
-from app.settings import Settings
+from app.domain.entities.users import UserRead
class UsersService:
def __init__(self, db: AsyncSession):
- pass
+ self.users_repository = UsersRepository(db)
+
+ async def get_by_id(self, user_id: int) -> UserRead:
+ user = await self.users_repository.get_by_id(user_id)
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail='Пользователь не найден',
+ )
+
+ return UserRead.model_validate(user)
diff --git a/api/app/main.py b/api/app/main.py
index b7d548d..c817535 100644
--- a/api/app/main.py
+++ b/api/app/main.py
@@ -3,6 +3,7 @@ from starlette.middleware.cors import CORSMiddleware
from app.controllers.auth_router import auth_router
from app.controllers.register_router import register_router
+from app.controllers.users_router import users_router
from app.settings import Settings
@@ -20,6 +21,7 @@ def start_app():
api_app.include_router(auth_router, prefix=f'{settings.prefix}/auth', tags=['auth'])
api_app.include_router(register_router, prefix=f'{settings.prefix}/register', tags=['register'])
+ api_app.include_router(users_router, prefix=f'{settings.prefix}/users', tags=['users'])
return api_app
diff --git a/api/app/settings.py b/api/app/settings.py
index 2372062..513ff67 100644
--- a/api/app/settings.py
+++ b/api/app/settings.py
@@ -14,7 +14,7 @@ class Settings(BaseSettings):
db_schema: str = Field(alias='DB_SCHEMA')
secret_key: str = Field(alias='SECRET_KEY')
- algorithm: str = Field(alias='ALGORITHM', default='sha256')
+ algorithm: str = Field(alias='ALGORITHM', default='HS256')
default_role_name: str = Field(alias='DEFAULT_ROLE_NAME', default='student')
root_role_name: str = Field(alias='ROOT_ROLE_NAME', default='root')
diff --git a/web/index.html b/web/index.html
index 99b88e1..9c049fa 100644
--- a/web/index.html
+++ b/web/index.html
@@ -2,12 +2,12 @@
-
+
web
-
+
diff --git a/web/package-lock.json b/web/package-lock.json
index 07ba994..6d5f6a3 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -8,9 +8,14 @@
"name": "web",
"version": "0.0.0",
"dependencies": {
+ "@ant-design/icons": "^6.1.0",
+ "@reduxjs/toolkit": "^2.11.0",
"antd": "^6.0.0",
+ "dayjs": "^1.11.19",
"react": "^19.2.0",
- "react-dom": "^19.2.0"
+ "react-dom": "^19.2.0",
+ "react-redux": "^9.2.0",
+ "react-router-dom": "^7.9.6"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@@ -1801,6 +1806,32 @@
"react-dom": ">=18.0.0"
}
},
+ "node_modules/@reduxjs/toolkit": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.0.tgz",
+ "integrity": "sha512-hBjYg0aaRL1O2Z0IqWhnTLytnjDIxekmRxm1snsHjHaKVmIF1HiImWqsq+PuEbn6zdMlkIj9WofK1vR8jjx+Xw==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@standard-schema/utils": "^0.3.0",
+ "immer": "^11.0.0",
+ "redux": "^5.0.1",
+ "redux-thunk": "^3.1.0",
+ "reselect": "^5.1.0"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-redux": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.47",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
@@ -2116,6 +2147,18 @@
"win32"
]
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
+ "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2179,7 +2222,7 @@
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -2195,6 +2238,12 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
"node_modules/@vitejs/plugin-react": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz",
@@ -2507,6 +2556,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2993,6 +3055,16 @@
"node": ">= 4"
}
},
+ "node_modules/immer": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.0.0.tgz",
+ "integrity": "sha512-XtRG4SINt4dpqlnJvs70O2j6hH7H0X8fUzFsjMn1rwnETaxwp83HLNimXBjZ78MrKl3/d3/pkzDH0o0Lkxm37Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -3486,6 +3558,29 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
+ "node_modules/react-redux": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
+ "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.2.25 || ^19",
+ "react": "^18.0 || ^19",
+ "redux": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "redux": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -3496,6 +3591,65 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-router": {
+ "version": "7.9.6",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz",
+ "integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.9.6",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz",
+ "integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.9.6"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/redux": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
+ "license": "MIT"
+ },
+ "node_modules/redux-thunk": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "redux": "^5.0.0"
+ }
+ },
+ "node_modules/reselect": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
+ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
+ "license": "MIT"
+ },
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@@ -3579,6 +3733,12 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3730,6 +3890,15 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/vite": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz",
diff --git a/web/package.json b/web/package.json
index 9ef0da6..abadb48 100644
--- a/web/package.json
+++ b/web/package.json
@@ -10,9 +10,14 @@
"preview": "vite preview"
},
"dependencies": {
+ "@ant-design/icons": "^6.1.0",
+ "@reduxjs/toolkit": "^2.11.0",
"antd": "^6.0.0",
+ "dayjs": "^1.11.19",
"react": "^19.2.0",
- "react-dom": "^19.2.0"
+ "react-dom": "^19.2.0",
+ "react-redux": "^9.2.0",
+ "react-router-dom": "^7.9.6"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
diff --git a/web/public/logo.png b/web/public/logo.png
new file mode 100644
index 0000000..da1cb6b
Binary files /dev/null and b/web/public/logo.png differ
diff --git a/web/public/rounded_logo.png b/web/public/rounded_logo.png
new file mode 100644
index 0000000..4c5cd10
Binary files /dev/null and b/web/public/rounded_logo.png differ
diff --git a/web/public/vite.svg b/web/public/vite.svg
deleted file mode 100644
index e7b8dfb..0000000
--- a/web/public/vite.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/web/src/Api/authApi.js b/web/src/Api/authApi.js
new file mode 100644
index 0000000..28aeccb
--- /dev/null
+++ b/web/src/Api/authApi.js
@@ -0,0 +1,19 @@
+import {createApi} from "@reduxjs/toolkit/query/react";
+import {baseQueryWithAuth} from "./baseQuery.js";
+
+
+export const authApi = createApi({
+ reducerPath: "authApi",
+ baseQuery: baseQueryWithAuth,
+ endpoints: (builder) => ({
+ login: builder.mutation({
+ query: (credentials) => ({
+ url: "/auth/login/",
+ method: "POST",
+ body: credentials,
+ }),
+ }),
+ }),
+});
+
+export const {useLoginMutation} = authApi;
\ No newline at end of file
diff --git a/web/src/Api/baseQuery.js b/web/src/Api/baseQuery.js
new file mode 100644
index 0000000..4fdff3c
--- /dev/null
+++ b/web/src/Api/baseQuery.js
@@ -0,0 +1,33 @@
+import {fetchBaseQuery} from '@reduxjs/toolkit/query/react';
+import {logout} from '../Redux/Slices/authSlice.js';
+import CONFIG from "../Core/сonfig.js";
+
+export const baseQuery = fetchBaseQuery({
+ baseUrl: CONFIG.BASE_URL,
+ prepareHeaders: (headers, {getState, endpoint}) => {
+ const token = localStorage.getItem('access_token');
+ if (token) {
+ headers.set('Authorization', `Bearer ${token}`);
+ }
+ if (endpoint === 'uploadAppointmentFile' || endpoint === 'uploadBackup') {
+ const mutation = getState()?.api?.mutations?.[Object.keys(getState()?.api?.mutations || {})[0]];
+ if (mutation?.body instanceof FormData) {
+ headers.delete('Content-Type');
+ }
+ } else {
+ headers.set('Content-Type', 'application/json');
+ }
+
+ return headers;
+ },
+});
+
+export const baseQueryWithAuth = async (args, api, extraOptions) => {
+ const result = await baseQuery(args, api, extraOptions);
+ if (result.error && [401, 403].includes(result.error.status)) {
+ localStorage.removeItem('access_token');
+ api.dispatch(logout());
+ window.location.href = '/login';
+ }
+ return result;
+};
\ No newline at end of file
diff --git a/web/src/Api/usersApi.js b/web/src/Api/usersApi.js
new file mode 100644
index 0000000..3a28a59
--- /dev/null
+++ b/web/src/Api/usersApi.js
@@ -0,0 +1,16 @@
+import {baseQueryWithAuth} from "./baseQuery.js";
+import {createApi} from "@reduxjs/toolkit/query/react";
+
+
+export const usersApi = createApi({
+ reducerPath: "usersApi",
+ baseQuery: baseQueryWithAuth,
+ tagTypes: ["user"],
+ endpoints: (builder) => ({
+ getAuthenticatedUserData: builder.query({
+ query: () => "/users/me/",
+ }),
+ }),
+});
+
+export const {useGetAuthenticatedUserDataQuery} = usersApi;
\ No newline at end of file
diff --git a/web/src/App.css b/web/src/App.css
deleted file mode 100644
index b9d355d..0000000
--- a/web/src/App.css
+++ /dev/null
@@ -1,42 +0,0 @@
-#root {
- max-width: 1280px;
- margin: 0 auto;
- padding: 2rem;
- text-align: center;
-}
-
-.logo {
- height: 6em;
- padding: 1.5em;
- will-change: filter;
- transition: filter 300ms;
-}
-.logo:hover {
- filter: drop-shadow(0 0 2em #646cffaa);
-}
-.logo.react:hover {
- filter: drop-shadow(0 0 2em #61dafbaa);
-}
-
-@keyframes logo-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
-
-@media (prefers-reduced-motion: no-preference) {
- a:nth-of-type(2) .logo {
- animation: logo-spin infinite 20s linear;
- }
-}
-
-.card {
- padding: 2em;
-}
-
-.read-the-docs {
- color: #888;
-}
diff --git a/web/src/App.jsx b/web/src/App.jsx
deleted file mode 100644
index f67355a..0000000
--- a/web/src/App.jsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { useState } from 'react'
-import reactLogo from './assets/react.svg'
-import viteLogo from '/vite.svg'
-import './App.css'
-
-function App() {
- const [count, setCount] = useState(0)
-
- return (
- <>
-
- Vite + React
-
-
-
- Edit src/App.jsx and save to test HMR
-
-
-
- Click on the Vite and React logos to learn more
-
- >
- )
-}
-
-export default App
diff --git a/web/src/App/AdminRoute.jsx b/web/src/App/AdminRoute.jsx
new file mode 100644
index 0000000..063670f
--- /dev/null
+++ b/web/src/App/AdminRoute.jsx
@@ -0,0 +1,34 @@
+import {Navigate, Outlet} from "react-router-dom";
+import {useGetAuthenticatedUserDataQuery} from "../Api/usersApi.js";
+import LoadingIndicator from "../Components/Widgets/LoadingIndicator/LoadingIndicator.jsx";
+import {Result} from "antd";
+
+const AdminRoute = () => {
+ const {
+ data: user,
+ isLoading: isUserLoading,
+ isError: isUserError,
+ } = useGetAuthenticatedUserDataQuery(undefined, {
+ pollingInterval: 20000,
+ });
+
+ if (isUserLoading) {
+ return ;
+ }
+
+ if (isUserError) {
+ return ;
+ }
+
+ if (!user) {
+ return ;
+ }
+
+ if (!user.role || user.role.title !== "Администратор") {
+ return ;
+ }
+
+ return ;
+};
+
+export default AdminRoute;
\ No newline at end of file
diff --git a/web/src/App/App.jsx b/web/src/App/App.jsx
new file mode 100644
index 0000000..fb2ef53
--- /dev/null
+++ b/web/src/App/App.jsx
@@ -0,0 +1,41 @@
+import {useEffect} from 'react'
+import '../Styles/App.css'
+import dayjs from "dayjs";
+import {useDispatch, useSelector} from "react-redux";
+import LoadingIndicator from "../Components/Widgets/LoadingIndicator/LoadingIndicator.jsx";
+import {checkAuth} from "../Redux/Slices/authSlice.js";
+import {ConfigProvider} from "antd";
+import locale from 'antd/locale/ru_RU';
+import ErrorBoundary from "./ErrorBoundary.jsx";
+import AppRouter from "./AppRouter.jsx";
+import {BrowserRouter as Router} from "react-router-dom";
+
+dayjs.locale('ru');
+
+function App() {
+ const dispatch = useDispatch();
+ const {isLoading} = useSelector((state) => state.auth);
+
+
+ useEffect(() => {
+ dispatch(checkAuth());
+ }, [dispatch]);
+
+ if (isLoading) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+export default App
diff --git a/web/src/App/AppRouter.jsx b/web/src/App/AppRouter.jsx
new file mode 100644
index 0000000..bf73a5a
--- /dev/null
+++ b/web/src/App/AppRouter.jsx
@@ -0,0 +1,29 @@
+import {Routes, Route, Navigate} from "react-router-dom";
+import PrivateRoute from "./PrivateRoute.jsx";
+import AdminRoute from "./AdminRoute.jsx";
+import LoginPage from "../Components/Pages/LoginPage/LoginPage.jsx";
+import CoursesPage from "../Components/Pages/Courses/CoursesPage.jsx";
+import MainLayout from "../Components/Layouts/MainLayout.jsx";
+
+
+const AppRouter = () => (
+
+ }/>
+
+ }>
+ }>
+ }/>
+
+
+
+ }>
+ {/*}>*/}
+ {/* } />*/}
+ {/**/}
+
+
+ }/>
+
+);
+
+export default AppRouter;
\ No newline at end of file
diff --git a/web/src/App/ErrorBoundary.jsx b/web/src/App/ErrorBoundary.jsx
new file mode 100644
index 0000000..37342ab
--- /dev/null
+++ b/web/src/App/ErrorBoundary.jsx
@@ -0,0 +1,28 @@
+import { Component } from "react";
+import {Alert, Result} from "antd";
+
+class ErrorBoundary extends Component {
+ state = { hasError: false };
+
+ static getDerivedStateFromError() {
+ return { hasError: true };
+ }
+
+ componentDidCatch(error, info) {
+ console.error("ErrorBoundary caught:", error, info);
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+ <>
+
+ >
+
+ );
+ }
+ return this.props.children;
+ }
+}
+
+export default ErrorBoundary;
\ No newline at end of file
diff --git a/web/src/App/PrivateRoute.jsx b/web/src/App/PrivateRoute.jsx
new file mode 100644
index 0000000..39c51bd
--- /dev/null
+++ b/web/src/App/PrivateRoute.jsx
@@ -0,0 +1,19 @@
+import {Navigate, Outlet} from "react-router-dom";
+import {useSelector} from "react-redux";
+import LoadingIndicator from "../Components/Widgets/LoadingIndicator/LoadingIndicator.jsx";
+
+const PrivateRoute = () => {
+ const {user, userData, isLoading} = useSelector((state) => state.auth);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!user || !userData || userData.status.title !== "active") {
+ return ;
+ }
+
+ return ;
+};
+
+export default PrivateRoute;
\ No newline at end of file
diff --git a/web/src/App/main.jsx b/web/src/App/main.jsx
new file mode 100644
index 0000000..b5f6469
--- /dev/null
+++ b/web/src/App/main.jsx
@@ -0,0 +1,13 @@
+import {StrictMode} from 'react'
+import {createRoot} from 'react-dom/client'
+import App from './App.jsx'
+import {Provider} from "react-redux";
+import store from "../Redux/store.js";
+
+createRoot(document.getElementById('root')).render(
+
+
+
+
+ ,
+)
diff --git a/web/src/Components/Layouts/MainLayout.jsx b/web/src/Components/Layouts/MainLayout.jsx
new file mode 100644
index 0000000..2c43cc1
--- /dev/null
+++ b/web/src/Components/Layouts/MainLayout.jsx
@@ -0,0 +1,80 @@
+import useMainLayout from "./useMainLayout.js";
+import {Layout, Menu} from "antd";
+import CoursesPage from "../Pages/Courses/CoursesPage.jsx";
+import LoadingIndicator from "../Widgets/LoadingIndicator/LoadingIndicator.jsx";
+import {Outlet} from "react-router-dom";
+import {BookOutlined, LogoutOutlined, UserOutlined} from "@ant-design/icons";
+
+const {Content, Footer, Sider} = Layout;
+
+const MainLayout = () => {
+ const {
+ screens,
+ collapsed,
+ setCollapsed,
+ location,
+ user,
+ isUserLoading,
+ isUserError,
+ handleMenuClick,
+ getItem
+ } = useMainLayout();
+
+ const menuItems = [
+ getItem("Мои курсы", "/", ),
+ getItem("Профиль", "/profile", ),
+ getItem("Выйти", "logout", ),
+ {type: "divider"}
+ ];
+
+
+ return (
+
+
+
+

+
+
+
+
+
+
+ {isUserLoading ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ )
+
+};
+
+export default MainLayout;
\ No newline at end of file
diff --git a/web/src/Components/Layouts/useMainLayout.js b/web/src/Components/Layouts/useMainLayout.js
new file mode 100644
index 0000000..132921e
--- /dev/null
+++ b/web/src/Components/Layouts/useMainLayout.js
@@ -0,0 +1,41 @@
+import {useState} from "react";
+import {useLocation, useNavigate} from "react-router-dom";
+import {useSelector} from "react-redux";
+import useAuthUtils from "../../Hooks/useAuthUtils.js";
+import {Grid} from "antd";
+
+const {useBreakpoint} = Grid;
+
+
+const useMainLayout = () => {
+ const [collapsed, setCollapsed] = useState(true);
+ const navigate = useNavigate();
+ const location = useLocation();
+ const {logoutAndRedirect} = useAuthUtils();
+ const screens = useBreakpoint();
+
+ const {userData: user, isLoading: isUserLoading, error: isUserError} = useSelector((state) => state.auth);
+
+ const handleMenuClick = ({key}) => {
+ if (key === "logout") {
+ logoutAndRedirect();
+ return;
+ }
+ navigate(key);
+ };
+ const getItem = (label, key, icon, children) => ({key, icon, children, label});
+
+ return {
+ screens,
+ collapsed,
+ setCollapsed,
+ location,
+ user,
+ isUserLoading,
+ isUserError,
+ handleMenuClick,
+ getItem
+ };
+};
+
+export default useMainLayout;
\ No newline at end of file
diff --git a/web/src/Components/Pages/Courses/CoursesPage.jsx b/web/src/Components/Pages/Courses/CoursesPage.jsx
new file mode 100644
index 0000000..04d97da
--- /dev/null
+++ b/web/src/Components/Pages/Courses/CoursesPage.jsx
@@ -0,0 +1,12 @@
+
+
+
+const CoursesPage = () => {
+ return (
+
+ CoursesPage
+
+ );
+};
+
+export default CoursesPage;
\ No newline at end of file
diff --git a/web/src/Components/Pages/Courses/useCoursesPage.js b/web/src/Components/Pages/Courses/useCoursesPage.js
new file mode 100644
index 0000000..e69de29
diff --git a/web/src/Components/Pages/LoginPage/LoginPage.jsx b/web/src/Components/Pages/LoginPage/LoginPage.jsx
new file mode 100644
index 0000000..73fe19e
--- /dev/null
+++ b/web/src/Components/Pages/LoginPage/LoginPage.jsx
@@ -0,0 +1,42 @@
+import {Button, Col, Flex, Form, Input, Typography} from "antd";
+import useLoginPage from "./useLoginPage.js";
+
+const {Title} = Typography;
+
+const LoginPage = () => {
+ const {
+ pageContainerStyle,
+ onFinish
+ } = useLoginPage();
+
+ return (
+
+
+ Аутентификация
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+};
+
+export default LoginPage;
\ No newline at end of file
diff --git a/web/src/Components/Pages/LoginPage/useLoginPage.js b/web/src/Components/Pages/LoginPage/useLoginPage.js
new file mode 100644
index 0000000..9259a2c
--- /dev/null
+++ b/web/src/Components/Pages/LoginPage/useLoginPage.js
@@ -0,0 +1,57 @@
+import {useNavigate} from "react-router-dom";
+import {useDispatch, useSelector} from "react-redux";
+import {useLoginMutation} from "../../../Api/authApi.js";
+import {useEffect, useRef} from "react";
+import {notification} from "antd";
+import {checkAuth, setError, setUser} from "../../../Redux/Slices/authSlice.js";
+
+
+const useLoginPage = () => {
+ const navigate = useNavigate();
+ const dispatch = useDispatch();
+ const [loginUser, { isLoading }] = useLoginMutation();
+ const { user, userData } = useSelector((state) => state.auth);
+ const hasRedirected = useRef(false);
+
+ const pageContainerStyle = {
+ paddingTop: screen.xs ? "100px" : "200px"
+ };
+
+ useEffect(() => {
+ if (user && userData && !isLoading && !hasRedirected.current) {
+ hasRedirected.current = true;
+ navigate("/");
+ }
+ document.title = "Аутентификация";
+ }, [user, userData, isLoading, navigate]);
+
+ const onFinish = async (loginData) => {
+ try {
+ const response = await loginUser(loginData).unwrap();
+ const token = response.access_token || response.token;
+ if (!token) {
+ throw new Error("Сервер не вернул токен авторизации");
+ }
+ localStorage.setItem("access_token", token);
+ dispatch(setUser({ token }));
+
+ await dispatch(checkAuth()).unwrap();
+ } catch (error) {
+ const errorMessage = error?.data?.detail || "Не удалось войти. Проверьте логин и пароль.";
+ console.error(error);
+ dispatch(setError(errorMessage));
+ notification.error({
+ title: "Ошибка при входе",
+ description: errorMessage,
+ placement: "topRight",
+ });
+ }
+ };
+
+ return {
+ pageContainerStyle,
+ onFinish
+ };
+};
+
+export default useLoginPage;
\ No newline at end of file
diff --git a/web/src/Components/Widgets/LoadingIndicator/LoadingIndicator.jsx b/web/src/Components/Widgets/LoadingIndicator/LoadingIndicator.jsx
new file mode 100644
index 0000000..0e850a3
--- /dev/null
+++ b/web/src/Components/Widgets/LoadingIndicator/LoadingIndicator.jsx
@@ -0,0 +1,15 @@
+import {Spin} from "antd";
+import {LoadingOutlined} from "@ant-design/icons";
+import useLoadingIndicatorUI from "./useLoadingIndicator.js";
+
+const LoadingIndicator = () => {
+ const {containerStyle, iconStyle} = useLoadingIndicatorUI();
+
+ return (
+
+ }/>
+
+ );
+};
+
+export default LoadingIndicator;
\ No newline at end of file
diff --git a/web/src/Components/Widgets/LoadingIndicator/useLoadingIndicator.js b/web/src/Components/Widgets/LoadingIndicator/useLoadingIndicator.js
new file mode 100644
index 0000000..8ff8713
--- /dev/null
+++ b/web/src/Components/Widgets/LoadingIndicator/useLoadingIndicator.js
@@ -0,0 +1,20 @@
+const useLoadingIndicatorUI = () => {
+ const containerStyle = {
+ display: "flex",
+ justifyContent: "center",
+ alignItems: "center",
+ height: "100vh",
+ };
+
+ const iconStyle = {
+ fontSize: 64,
+ color: "#1890ff",
+ };
+
+ return {
+ containerStyle,
+ iconStyle,
+ };
+};
+
+export default useLoadingIndicatorUI;
\ No newline at end of file
diff --git a/web/src/Core/сonfig.js b/web/src/Core/сonfig.js
new file mode 100644
index 0000000..9196e01
--- /dev/null
+++ b/web/src/Core/сonfig.js
@@ -0,0 +1,5 @@
+const CONFIG = {
+ BASE_URL: import.meta.env.VITE_BASE_URL,
+};
+
+export default CONFIG;
\ No newline at end of file
diff --git a/web/src/Hooks/useAuthUtils.js b/web/src/Hooks/useAuthUtils.js
new file mode 100644
index 0000000..20c9d85
--- /dev/null
+++ b/web/src/Hooks/useAuthUtils.js
@@ -0,0 +1,18 @@
+import { useDispatch } from "react-redux";
+import { useNavigate } from "react-router-dom";
+import { logout } from "../Redux/Slices/authSlice.js";
+
+const useAuthUtils = () => {
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+
+ const logoutAndRedirect = () => {
+ localStorage.removeItem("access_token");
+ dispatch(logout());
+ navigate("/login");
+ };
+
+ return { logoutAndRedirect };
+};
+
+export default useAuthUtils;
\ No newline at end of file
diff --git a/web/src/Redux/Slices/authSlice.js b/web/src/Redux/Slices/authSlice.js
new file mode 100644
index 0000000..02ef512
--- /dev/null
+++ b/web/src/Redux/Slices/authSlice.js
@@ -0,0 +1,74 @@
+import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
+import {usersApi} from "../../Api/usersApi.js";
+
+export const checkAuth = createAsyncThunk("auth/checkAuth", async (_, { dispatch, rejectWithValue }) => {
+ try {
+ const token = localStorage.getItem("access_token");
+ if (!token) {
+ return rejectWithValue("No token found");
+ }
+
+ const userData = await dispatch(
+ usersApi.endpoints.getAuthenticatedUserData.initiate(undefined, { forceRefetch: true })
+ ).unwrap();
+
+ return { token, userData };
+ } catch (error) {
+ localStorage.removeItem("access_token");
+ return rejectWithValue(error?.data?.detail || "Failed to authenticate");
+ }
+});
+
+const initialState = {
+ user: null,
+ userData: null,
+ isLoading: true,
+ error: null,
+};
+
+const authSlice = createSlice({
+ name: "auth",
+ initialState,
+ reducers: {
+ setUser(state, action) {
+ state.user = action.payload;
+ state.isLoading = false;
+ state.error = null;
+ },
+ setError(state, action) {
+ state.error = action.payload;
+ state.isLoading = false;
+ },
+ logout(state) {
+ state.user = null;
+ state.userData = null;
+ state.error = null;
+ state.isLoading = false;
+ localStorage.removeItem("access_token");
+ },
+ setUserData(state, action) {
+ state.userData = action.payload;
+ },
+ },
+ extraReducers: (builder) => {
+ builder
+ .addCase(checkAuth.pending, (state) => {
+ state.isLoading = true;
+ state.error = null;
+ })
+ .addCase(checkAuth.fulfilled, (state, action) => {
+ state.user = { token: action.payload.token };
+ state.userData = action.payload.userData;
+ state.isLoading = false;
+ })
+ .addCase(checkAuth.rejected, (state, action) => {
+ state.user = null;
+ state.userData = null;
+ state.isLoading = false;
+ state.error = action.payload;
+ });
+ },
+});
+
+export const { setUser, setError, logout, setUserData } = authSlice.actions;
+export default authSlice.reducer;
\ No newline at end of file
diff --git a/web/src/Redux/store.js b/web/src/Redux/store.js
new file mode 100644
index 0000000..2b1845c
--- /dev/null
+++ b/web/src/Redux/store.js
@@ -0,0 +1,21 @@
+import {configureStore} from "@reduxjs/toolkit";
+import authReducer from "./Slices/authSlice.js";
+import {authApi} from "../Api/authApi.js";
+import {usersApi} from "../Api/usersApi.js";
+
+export const store = configureStore({
+ reducer: {
+ auth: authReducer,
+ [authApi.reducerPath]: authApi.reducer,
+
+ [usersApi.reducerPath]: usersApi.reducer
+ },
+ middleware: (getDefaultMiddleware) => (
+ getDefaultMiddleware().concat(
+ authApi.middleware,
+ usersApi.middleware,
+ )
+ ),
+});
+
+export default store;
\ No newline at end of file
diff --git a/web/src/Styles/App.css b/web/src/Styles/App.css
new file mode 100644
index 0000000..e69de29
diff --git a/web/src/assets/react.svg b/web/src/assets/react.svg
deleted file mode 100644
index 6c87de9..0000000
--- a/web/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/web/src/index.css b/web/src/index.css
deleted file mode 100644
index 08a3ac9..0000000
--- a/web/src/index.css
+++ /dev/null
@@ -1,68 +0,0 @@
-:root {
- font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
- line-height: 1.5;
- font-weight: 400;
-
- color-scheme: light dark;
- color: rgba(255, 255, 255, 0.87);
- background-color: #242424;
-
- font-synthesis: none;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-}
-
-a {
- font-weight: 500;
- color: #646cff;
- text-decoration: inherit;
-}
-a:hover {
- color: #535bf2;
-}
-
-body {
- margin: 0;
- display: flex;
- place-items: center;
- min-width: 320px;
- min-height: 100vh;
-}
-
-h1 {
- font-size: 3.2em;
- line-height: 1.1;
-}
-
-button {
- border-radius: 8px;
- border: 1px solid transparent;
- padding: 0.6em 1.2em;
- font-size: 1em;
- font-weight: 500;
- font-family: inherit;
- background-color: #1a1a1a;
- cursor: pointer;
- transition: border-color 0.25s;
-}
-button:hover {
- border-color: #646cff;
-}
-button:focus,
-button:focus-visible {
- outline: 4px auto -webkit-focus-ring-color;
-}
-
-@media (prefers-color-scheme: light) {
- :root {
- color: #213547;
- background-color: #ffffff;
- }
- a:hover {
- color: #747bff;
- }
- button {
- background-color: #f9f9f9;
- }
-}
diff --git a/web/src/main.jsx b/web/src/main.jsx
deleted file mode 100644
index b9a1a6d..0000000
--- a/web/src/main.jsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import './index.css'
-import App from './App.jsx'
-
-createRoot(document.getElementById('root')).render(
-
-
- ,
-)