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 logo - - - React logo - -
-

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 ? ( + + ) : ( + + )} + +
{new Date().getFullYear()}
+
+ + ) + +}; + +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( - - - , -)