13 Commits

37 changed files with 2406 additions and 15679 deletions

View File

@@ -17,7 +17,7 @@ start-api:
start-client: start-client:
cd client && \ cd client && \
npm start npm run dev
migrate: migrate:
cd api && \ cd api && \
@@ -42,6 +42,10 @@ venv-api:
poetry env activate \ poetry env activate \
poetry install poetry install
venv-client:
cd client && \
npm install
install: install:
make migrate head && \ make migrate head && \
cd api && \ cd api && \

View File

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

View File

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

View File

@@ -50,13 +50,12 @@ async def get_user(connection: AsyncConnection, login: str) -> Optional[User]:
return user, password return user, password
async def upgrade_old_refresh_token(connection: AsyncConnection, user, refresh_token) -> Optional[User]: async def upgrade_old_refresh_token(connection: AsyncConnection, refresh_token) -> Optional[User]:
new_status = KeyStatus.EXPIRED new_status = KeyStatus.EXPIRED
update_query = ( update_query = (
update(account_keyring_table) update(account_keyring_table)
.where( .where(
account_table.c.id == user.id,
account_keyring_table.c.status == KeyStatus.ACTIVE, account_keyring_table.c.status == KeyStatus.ACTIVE,
account_keyring_table.c.key_type == KeyType.REFRESH_TOKEN, account_keyring_table.c.key_type == KeyType.REFRESH_TOKEN,
account_keyring_table.c.key_value == refresh_token, account_keyring_table.c.key_value == refresh_token,

View File

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

View File

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

View File

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

View File

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

View File

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

17
client/index.html Normal file
View File

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

17451
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>VORKOUT</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -1,8 +1,9 @@
import Header from '../components/Header';
import { useState } from 'react'; import { useState } from 'react';
import ContentDrawer from '../components/ContentDrawer';
import UserCreate from '../components/UserCreate';
import { useTranslation } from 'react-i18next'; 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';
export default function AccountsPage() { export default function AccountsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -11,6 +12,8 @@ export default function AccountsPage() {
const showDrawer = () => setOpen(true); const showDrawer = () => setOpen(true);
const closeDrawer = () => setOpen(false); const closeDrawer = () => setOpen(false);
const [accounts, setAccounts] = useState<User[]>([]);
return ( return (
<> <>
<Header <Header

View File

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

View File

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

View File

@@ -5,9 +5,9 @@ import {
EyeTwoTone, EyeTwoTone,
UserOutlined, UserOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { AuthService } from '../services/auth';
import { Auth } from '../types/auth';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { AuthService } from '@/services/authService';
import { Auth } from '@/types/auth';
const { Text, Link } = Typography; const { Text, Link } = Typography;

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
import api from "@/api/api";
import { useAuthStore } from "@/store/authStore";
import { Auth } from "@/types/auth";
import { UserService } from "./userService";
import { useUserStore } from "@/store/userStore";
export class AuthService {
static async login(auth: Auth) {
const token = await api.login(auth);
useAuthStore.getState().setAccessToken(token.accessToken);
localStorage.setItem('refreshToken', token.refreshToken as string);
await UserService.getProfile().then((user) => {
useUserStore.getState().setUser(user);
});
}
static async logout() {
console.log('logout');
useUserStore.getState().setUser(null);
useAuthStore.getState().setAccessToken(null);
localStorage.removeItem('userInfo');
localStorage.removeItem('refreshToken');
}
static async refresh() {
console.log('refresh');
const token = await api.refreshToken();
useAuthStore.getState().setAccessToken(token.accessToken);
}
}

View File

@@ -0,0 +1,16 @@
import api from '@/api/api';
import { User } from '@/types/user';
export class UserService {
static async getProfile(): Promise<User> {
console.log('getProfile');
const user = api.getProfile();
return user;
}
static async getUsers(page: number = 1, limit: number = 10): Promise<any> {
const users = api.getUsers(page, limit);
return users;
}
}

View File

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

View File

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

View File

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

View File

@@ -120,11 +120,6 @@ export interface paths {
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { schemas: {
/** Access */
Access: {
/** Accesstoken */
accessToken: string;
};
/** AccountKeyring */ /** AccountKeyring */
AccountKeyring: { AccountKeyring: {
/** Ownerid */ /** Ownerid */
@@ -219,6 +214,13 @@ export interface components {
* @enum {string} * @enum {string}
*/ */
KeyType: "PASSWORD" | "ACCESS_TOKEN" | "REFRESH_TOKEN" | "API_KEY"; KeyType: "PASSWORD" | "ACCESS_TOKEN" | "REFRESH_TOKEN" | "API_KEY";
/** Tokens */
Tokens: {
/** Accesstoken */
accessToken: string;
/** Refreshtoken */
refreshToken?: string | null;
};
/** User */ /** User */
User: { User: {
/** Id */ /** Id */
@@ -305,7 +307,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Access"]; "application/json": components["schemas"]["Tokens"];
}; };
}; };
/** @description Validation Error */ /** @description Validation Error */
@@ -334,7 +336,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Access"]; "application/json": components["schemas"]["Tokens"];
}; };
}; };
}; };

View File

@@ -1,3 +1,3 @@
import { components } from "./openapi-types" import { components } from './openapi-types';
export type User = components["schemas"]["User"]; export type User = components['schemas']['User'];

View File

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

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

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

View File

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