add frontend and change password

This commit is contained in:
2026-01-09 14:24:21 +03:00
parent 8e0131451d
commit 7a906fa824
44 changed files with 6020 additions and 49 deletions
Regular → Executable
+3 -1
View File
@@ -1 +1,3 @@
__pycache__
__pycache__
.env
test.py
Generated Executable
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
Generated Executable
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/backend" isTestSource="false" />
</content>
<orderEntry type="jdk" jdkName="Python 3.13 (aetherbackend-6Zf3gKAD-py3.13)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
Generated Executable
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.13" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13 (aetherbackend-6Zf3gKAD-py3.13)" project-jdk-type="Python SDK" />
</project>
Generated Executable
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Aether.iml" filepath="$PROJECT_DIR$/.idea/Aether.iml" />
</modules>
</component>
</project>
Generated Executable
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
Regular → Executable
View File
+25 -5
View File
@@ -5,13 +5,13 @@ import uuid
from fastapi import APIRouter, status, Response, Depends, Request, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from app.users.schemas import UserCreate, User
from app.users.schemas import UserCreate, User, ChangePassword
from app.auth.schemas import Token
from app.users.service import UserService
from app.auth.service import AuthService
from app.users.models import UserModel
from app.core.exceptions import InvalidCredentialsException
from app.auth.dependencies import get_current_user
from app.auth.dependencies import get_current_user, get_current_verified_user
from app.core.config import settings
router = APIRouter(prefix="/auth", tags=["Auth"])
@@ -22,12 +22,12 @@ log = logging.getLogger(__name__)
async def register(user: UserCreate) -> User:
return await UserService.register_new_user(user)
@router.get("/verify/{token}")
@router.post("/email/verify/{token}")
async def verify_email(token: uuid.UUID) -> Dict:
await UserService.verify_email(token)
return {"status": True, "message": "User successfully verified email"}
@router.post("/send/verify-email")
@router.post("/email/resend-verification")
async def resend_verify_email(user: UserModel = Depends(get_current_user)) -> Dict:
if user.is_verified:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Email already verified")
@@ -86,4 +86,24 @@ async def logout(request: Request, response: Response, user: UserModel = Depends
@router.post("/abort")
async def abort_all_sessions(user: UserModel = Depends(get_current_user)) -> Dict:
await AuthService.abort_all_sessions(user.id)
return {"status": True, "message": "All sessions was aborted"}
return {"status": True, "message": "All sessions was aborted"}
@router.post("/password/change")
async def change_password(
passwords: ChangePassword,
user: UserModel = Depends(get_current_verified_user)
) -> Dict:
await UserService.change_password(user, passwords)
return {"status": True, "message": "Successfully change password"}
@router.post("/password/reset")
async def send_reset_password_email(
username: str
) -> Dict:
await UserService.send_reset_password_email(username)
return {"status": True, "message": "Successfully send email reset password"}
@router.post("/password/reset/{token}")
async def reset_password(token: uuid.UUID, new_password: str) -> Dict:
await UserService.reset_password(token, new_password)
return {"status": True, "message": "Successfully reset password"}
+2
View File
@@ -14,3 +14,5 @@ class TokenExpiredException(HTTPException):
class InvalidCredentialsException(HTTPException):
def __init__(self):
super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
UserNotFoundException = HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
+28 -7
View File
@@ -1,6 +1,8 @@
import time
from contextlib import asynccontextmanager
import uvicorn
import logging
import uuid
from fastapi import FastAPI, APIRouter, Request, Response
from fastapi.middleware.cors import CORSMiddleware
@@ -46,19 +48,38 @@ app.add_middleware(
@app.middleware("http")
async def log_requests(request: Request, call_next):
response: Response = await call_next(request)
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
start_time = time.perf_counter()
log.info(
"method=%s path=%s status=%s",
request.method,
request.url.path,
response.status_code,
"Started method=%s path=%s",
request.method, request.url.path,
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status": response.status_code
"type": "start"
}
)
return response
try:
response: Response = await call_next(request)
process_time = time.perf_counter() - start_time
log.info(
"Finished method=%s path=%s status=%s duration=%.3fs",
request.method, request.url.path, response.status_code, process_time,
extra={
"request_id": request_id,
"status": response.status_code,
"duration": process_time,
"type": "end"
}
)
return response
except Exception as e:
log.error("Request failed id=%s error=%s", request_id, str(e))
raise
@app.get("/health")
+25
View File
@@ -26,6 +26,31 @@ class EmailService:
company_name=settings.COMPANY_NAME
)
EmailClient.send_email(to=email, subject=subject, html=html, body=body)
log.info("Verify email sent to %s", email, extra={"email": email})
except Exception as e:
log.error("Failed to send email to %s", email, extra={"email": email})
raise e
@classmethod
def send_reset_password_email(cls, email: str, username: str, url: str):
log.debug("Sending email to %s", email, extra={"email": email})
try:
subject = "Подтверждение эл. почты"
html = EmailClient.render(
template_path="reset_password.html",
username=username,
url=url,
company_name=settings.COMPANY_NAME
)
body = EmailClient.render(
template_path="confirm_email.txt",
username=username,
url=url,
company_name=settings.COMPANY_NAME
)
EmailClient.send_email(to=email, subject=subject, html=html, body=body)
log.info("Verify email sent to %s", email, extra={"email": email})
except Exception as e:
+22 -15
View File
@@ -1,10 +1,28 @@
import logging
import uuid
from typing import Optional
from app.core.redis import get_redis
log = logging.getLogger(__name__)
class TokenStorage:
PREFIX: Optional[str] = None
@classmethod
async def save_token(cls, token: uuid.UUID, user_id: int, ttl: int):
redis_client = await get_redis()
await redis_client.setex(f"{cls.PREFIX}:{token}", ttl, user_id)
log.info("Save new %s token from redis", cls.PREFIX, extra={"user_id": user_id, "token": token})
@classmethod
async def getdel_token(cls, token: uuid.UUID) -> int:
redis_client = await get_redis()
log.debug("User_id fetched from %s token", cls.PREFIX, extra={"token": token})
return await redis_client.getdel(f"{cls.PREFIX}:{token}")
class RefreshTokenStorage:
PREFIX: str = "refresh"
@@ -53,20 +71,9 @@ class RefreshTokenStorage:
class EmailTokenStorage:
PREFIX: str = "email"
@classmethod
async def save_token(cls, token: uuid.UUID, user_id: int, ttl: int):
redis_client = await get_redis()
await redis_client.setex(f"{cls.PREFIX}:{token}", ttl, user_id)
log.info("Save new refresh token from redis", extra={"user_id": user_id, "token": token})
class EmailTokenStorage(TokenStorage):
PREFIX = "email"
@classmethod
async def getdel_token(cls, token: uuid.UUID) -> int:
redis_client = await get_redis()
log.debug("User_id fetched from email token", extra={"token": token})
return await redis_client.getdel(f"{cls.PREFIX}:{token}")
class ChangePasswordTokenStorage(TokenStorage):
PREFIX = "changepassword"
+7 -1
View File
@@ -10,4 +10,10 @@ class EmailTasks:
@staticmethod
@celery_app.task
def send_verify_email_task(email: str, username: str, url: str):
EmailService.send_verify_email(email, username, url)
EmailService.send_verify_email(email, username, url)
@staticmethod
@celery_app.task
def send_reset_password_email_task(email: str, username: str, url: str):
EmailService.send_reset_password_email(email, username, url)
+3
View File
@@ -56,3 +56,6 @@ class UserUpdateDB(UserBase):
is_verified: Optional[bool] = None
is_superuser: Optional[bool] = None
class ChangePassword(BaseModel):
old_password: str
new_password: str
+78 -18
View File
@@ -6,14 +6,13 @@ from typing import List
from fastapi import HTTPException, status
from sqlalchemy import or_
from app.utils.hash_password import hash_password
from app.services.redis_service import EmailTokenStorage
from app.core.redis import get_redis
from app.core.exceptions import InvalidTokenException, TokenExpiredException
from app.utils.hash_password import hash_password, verify_password
from app.services.redis_service import EmailTokenStorage, ChangePasswordTokenStorage
from app.core.exceptions import InvalidTokenException, TokenExpiredException, UserNotFoundException
from app.users.models import UserModel
from app.users.dao import UserDAO
from app.core.database import async_session_maker
from app.users.schemas import UserCreate, UserCreateDB, User, UserUpdate, UserUpdateDB
from app.users.schemas import UserCreate, UserCreateDB, User, UserUpdate, UserUpdateDB, ChangePassword
from app.tasks.email_tasks import EmailTasks
from app.core.config import settings
@@ -27,7 +26,7 @@ class UserService:
user_exist = await UserDAO.find_one_or_none(session, id=user_id)
if user_exist is None:
log.warning("User not found", extra={"user_id": user_id})
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
raise UserNotFoundException
log.debug("User fetched", extra={"user_id": user_id})
return user_exist
@@ -64,10 +63,8 @@ class UserService:
@classmethod
async def send_verify_email(cls, user: UserModel):
redis_client = await get_redis()
token = cls._create_email_verification_token()
url = f"{settings.URL}/api/v1/auth/verify/{token}"
token = cls._create_uuid_token()
url = f"{settings.URL}/verify-email/{token}"
email_token_expires = timedelta(minutes=settings.EMAIL_TOKEN_EXPIRE_MINUTES)
await EmailTokenStorage.save_token(
@@ -79,7 +76,7 @@ class UserService:
@classmethod
def _create_email_verification_token(cls) -> uuid.UUID:
def _create_uuid_token(cls) -> uuid.UUID:
return uuid.uuid4()
@@ -99,11 +96,11 @@ class UserService:
await UserDAO.update(
session,
UserModel.id==int(user_id),
UserModel.id==user_exist.id,
obj_in={"is_verified": True}
)
await session.commit()
log.info("Email verified", extra={"email": user_exist.email, "user_id": user_exist.id})
@classmethod
@@ -113,20 +110,20 @@ class UserService:
if users is None:
log.warning("Users not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Users not found")
raise UserNotFoundException
log.debug("Users fetched", extra={"count": len(users), "offset": offset, "limit": limit})
return users
@classmethod
async def update_user(cls, user_id: int, update_user: UserUpdate):
async def update_user(cls, user_id: int, update_user: UserUpdate) -> User:
async with async_session_maker() as session:
user_exist = await UserDAO.find_one_or_none(session, id=user_id)
if user_exist is None:
log.warning("User not found", extra={"user_id": user_id})
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
raise UserNotFoundException
if user_exist.username != update_user.username:
username_exist = await UserDAO.find_one_or_none(session, username=update_user.username)
@@ -153,7 +150,7 @@ class UserService:
if user_exist is None:
log.warning("User not found", extra={"user_id": user_id})
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
raise UserNotFoundException
await UserDAO.update(
session,
@@ -161,4 +158,67 @@ class UserService:
obj_in={"is_active": False}
)
await session.commit()
log.info("User is inactive", extra={"user_id": user_id})
log.info("User is inactive", extra={"user_id": user_id})
@classmethod
async def change_password(cls, user: UserModel, change_password: ChangePassword):
async with async_session_maker() as session:
if not verify_password(change_password.old_password, user.hashed_password):
log.warning("Invalid current password", extra={"user_id": user.id})
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Invalid current password")
await UserDAO.update(
session,
UserModel.id==user.id,
obj_in={"hashed_password": hash_password(change_password.new_password)}
)
await session.commit()
log.info("Successfully changed password", extra={"user_id": user.id})
@classmethod
async def send_reset_password_email(cls, username: str):
async with async_session_maker() as session:
user = await UserDAO.find_one_or_none(session, username=username)
if user is None:
raise UserNotFoundException
token = cls._create_uuid_token()
url = f"{settings.URL}/reset-password/{token}"
token_expires = timedelta(minutes=settings.EMAIL_TOKEN_EXPIRE_MINUTES)
await ChangePasswordTokenStorage.save_token(
token,
user.id,
int(token_expires.total_seconds())
)
EmailTasks.send_reset_password_email_task.delay(
email=user.email,
username=user.username,
url=url
)
@classmethod
async def reset_password(cls, token: uuid.UUID, new_password: str):
async with async_session_maker() as session:
user_id = await ChangePasswordTokenStorage.getdel_token(token)
if user_id is None:
raise TokenExpiredException
user_exist = await UserDAO.find_one_or_none(session, id=int(user_id))
if user_exist is None:
raise InvalidTokenException
await UserDAO.update(
session,
UserModel.id==user_exist.id,
obj_in={"hashed_password": hash_password(new_password)}
)
await session.commit()
log.info("Successfully reset password", extra={"user_id": user_id})
+2 -2
View File
@@ -3,8 +3,8 @@ from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password):
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password, hashed_password):
def verify_password(plain_password: str, hashed_password: str):
return pwd_context.verify(plain_password, hashed_password)
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Aether — Messenger</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;1,400&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4540
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.2",
"framer-motion": "^12.25.0",
"lucide-react": "^0.562.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.12.0",
"zustand": "^5.0.9"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+42
View File
@@ -0,0 +1,42 @@
#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;
}
+64
View File
@@ -0,0 +1,64 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useEffect } from 'react';
import { useAuthStore } from './store/authStore';
import { authService } from './services/authService';
import AuthPage from './pages/AuthPage';
import VerifyEmailPage from './pages/VerifyEmailPage';
import ResetPasswordPage from './pages/ResetPasswordPage';
import ChatPage from './pages/ChatPage';
function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isLoading = useAuthStore((state) => state.isLoading);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: '#F5F5F1' }}>
<div className="w-16 h-16 border-4 border-gray-200 rounded-full animate-spin" style={{ borderTopColor: '#6B705C' }}></div>
</div>
);
}
return isAuthenticated ? <>{children}</> : <Navigate to="/auth" />;
}
function App() {
const setUser = useAuthStore((state) => state.setUser);
const setLoading = useAuthStore((state) => state.setLoading);
useEffect(() => {
const checkAuth = async () => {
try {
const user = await authService.getCurrentUser();
setUser(user);
} catch (error) {
setUser(null);
} finally {
setLoading(false);
}
};
checkAuth();
}, []); // Пустой массив зависимостей - выполнится только один раз
return (
<BrowserRouter>
<Routes>
<Route path="/auth" element={<AuthPage />} />
<Route path="/verify-email/:token" element={<VerifyEmailPage />} />
<Route path="/reset-password/:token" element={<ResetPasswordPage />} />
<Route
path="/chat"
element={
<PrivateRoute>
<ChatPage />
</PrivateRoute>
}
/>
<Route path="/" element={<Navigate to="/chat" />} />
</Routes>
</BrowserRouter>
);
}
export default App;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+104
View File
@@ -0,0 +1,104 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Eye, EyeOff } from 'lucide-react';
import { motion } from 'framer-motion';
import { authService } from '../../services/authService';
import { useAuthStore } from '../../store/authStore';
export default function LoginForm() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const navigate = useNavigate();
const setUser = useAuthStore((state) => state.setUser);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const data = await authService.login({ username, password });
const user = await authService.getCurrentUser();
setUser(user);
navigate('/chat');
} catch (err: any) {
setError(err.response?.data?.detail || 'Ошибка входа');
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="username" className="block font-lora italic text-[15px] text-text-muted mb-2">
Почта или никнейм
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="example@mail.com"
autoFocus
className="w-full px-0 py-3 bg-transparent border-0 border-b-2 border-gray-200 font-inter text-text-main placeholder:text-text-muted/50 focus:outline-none focus:border-accent-olive transition-all duration-300"
required
/>
</div>
<div>
<div className="flex justify-between items-center mb-2">
<label htmlFor="password" className="block font-lora italic text-[15px] text-text-muted">
Пароль
</label>
<a href="#" className="font-inter text-sm hover:underline transition" style={{ color: '#6B705C' }}>
Забыли пароль?
</a>
</div>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Ваш пароль"
className="w-full px-0 py-3 pr-10 bg-transparent border-0 border-b-2 border-gray-200 font-inter text-text-main placeholder:text-text-muted/50 focus:outline-none focus:border-accent-olive transition-all duration-300"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-0 top-1/2 -translate-y-1/2 transition"
style={{ color: '#8B8B8B' }}
>
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-3 bg-error-soft/10 border-b-2 border-error-soft text-error-soft text-sm font-inter"
>
{error}
</motion.div>
)}
<motion.button
type="submit"
disabled={isLoading}
whileTap={{ scale: 0.95 }}
style={{ backgroundColor: '#6B705C', color: 'white' }}
className="w-full mt-8 py-[18px] px-10 rounded-full font-inter font-semibold uppercase tracking-wider hover:shadow-lg transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Вход...' : 'Войти'}
</motion.button>
</form>
);
}
@@ -0,0 +1,181 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Eye, EyeOff } from 'lucide-react';
import { motion } from 'framer-motion';
import { authService } from '../../services/authService';
export default function RegisterForm() {
const [email, setEmail] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [agreedToTerms, setAgreedToTerms] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess('');
if (password !== confirmPassword) {
setError('Пароли не совпадают');
return;
}
if (!agreedToTerms) {
setError('Необходимо принять правила');
return;
}
setIsLoading(true);
try {
await authService.register({ email, username, password });
setSuccess('Регистрация успешна! Проверьте почту для подтверждения.');
setTimeout(() => navigate('/auth'), 2000);
} catch (err: any) {
setError(err.response?.data?.detail || 'Ошибка регистрации');
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="email" className="block font-lora italic text-[15px] text-text-muted mb-2">
Электронная почта
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="example@mail.com"
autoFocus
className="w-full px-0 py-3 bg-transparent border-0 border-b-2 border-gray-200 font-inter text-text-main placeholder:text-text-muted/50 focus:outline-none focus:border-accent-terracotta transition-all duration-300"
required
/>
</div>
<div>
<label htmlFor="username" className="block font-lora italic text-[15px] text-text-muted mb-2">
Никнейм
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Ваш никнейм"
className="w-full px-0 py-3 bg-transparent border-0 border-b-2 border-gray-200 font-inter text-text-main placeholder:text-text-muted/50 focus:outline-none focus:border-accent-terracotta transition-all duration-300"
required
/>
</div>
<div>
<label htmlFor="password" className="block font-lora italic text-[15px] text-text-muted mb-2">
Пароль
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Минимум 8 символов"
className="w-full px-0 py-3 pr-10 bg-transparent border-0 border-b-2 border-gray-200 font-inter text-text-main placeholder:text-text-muted/50 focus:outline-none focus:border-accent-terracotta transition-all duration-300"
required
minLength={8}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-0 top-1/2 -translate-y-1/2 transition"
style={{ color: '#8B8B8B' }}
>
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
<div>
<label htmlFor="confirmPassword" className="block font-lora italic text-[15px] text-text-muted mb-2">
Повторите пароль
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Повторите пароль"
className="w-full px-0 py-3 bg-transparent border-0 border-b-2 border-gray-200 font-inter text-text-main placeholder:text-text-muted/50 focus:outline-none focus:border-accent-terracotta transition-all duration-300"
required
/>
</div>
<div className="flex items-center gap-3 pt-3">
<button
type="button"
onClick={() => setAgreedToTerms(!agreedToTerms)}
style={{
backgroundColor: agreedToTerms ? '#D27D56' : 'transparent',
borderColor: agreedToTerms ? '#D27D56' : '#8B8B8B'
}}
className="w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all"
>
{agreedToTerms && (
<motion.svg
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="w-3 h-3 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</motion.svg>
)}
</button>
<label className="font-inter text-sm cursor-pointer" style={{ color: '#8B8B8B' }} onClick={() => setAgreedToTerms(!agreedToTerms)}>
Я согласен с <a href="#" style={{ color: '#6B705C' }} className="hover:underline">правилами</a>
</label>
</div>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-3 bg-error-soft/10 border-b-2 border-error-soft text-error-soft text-sm font-inter"
>
{error}
</motion.div>
)}
{success && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-3 bg-accent-terracotta/10 border-b-2 border-accent-terracotta text-accent-terracotta text-sm font-inter"
>
{success}
</motion.div>
)}
<motion.button
type="submit"
disabled={isLoading}
whileTap={{ scale: 0.95 }}
style={{ backgroundColor: '#D27D56', color: 'white' }}
className="w-full mt-8 py-[18px] px-10 rounded-full font-inter font-semibold uppercase tracking-wider hover:shadow-lg transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Регистрация...' : 'Зарегистрироваться'}
</motion.button>
</form>
);
}
+14
View File
@@ -0,0 +1,14 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
min-height: 100vh;
font-family: 'Inter', sans-serif;
line-height: 1.5;
font-weight: 400;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2C2C2C;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+91
View File
@@ -0,0 +1,91 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import LoginForm from '../components/auth/LoginForm';
import RegisterForm from '../components/auth/RegisterForm';
export default function AuthPage() {
const [isLogin, setIsLogin] = useState(true);
return (
<div className="min-h-screen flex items-center justify-center p-4 relative" style={{ backgroundColor: '#F5F5F1' }}>
{/* Subtle texture background */}
<div className="absolute inset-0 opacity-30 pointer-events-none"
style={{
backgroundImage: 'radial-gradient(circle at center, rgba(0,0,0,0.03) 1%, transparent 1%)',
backgroundSize: '20px 20px'
}}>
</div>
<motion.div
className="w-full max-w-[480px] relative z-10"
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.3 }}
>
<div className="bg-card-white rounded-[32px] shadow-soft px-10 py-12">
{/* Logo */}
<div className="text-center mb-8">
<div className="auth-logo w-[100px] h-[100px] mx-auto mb-8 rounded-full bg-gradient-to-br from-accent-terracotta to-accent-olive flex items-center justify-center text-white text-4xl font-lora shadow-logo border-[3px] border-[#EBEBE6]">
A
</div>
<div className="font-lora text-accent-olive text-lg tracking-[2px] mb-6">
AETHER
</div>
<AnimatePresence mode="wait">
<motion.h1
key={isLogin ? 'login' : 'register'}
className="font-lora font-semibold text-[28px] text-text-main mb-2"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
>
{isLogin ? 'Добро пожаловать!' : 'Присоединяйтесь'}
</motion.h1>
</AnimatePresence>
</div>
{/* Forms with animation */}
<AnimatePresence mode="wait">
<motion.div
key={isLogin ? 'login' : 'register'}
initial={{ opacity: 0, x: isLogin ? -20 : 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isLogin ? 20 : -20 }}
transition={{ duration: 0.3 }}
>
{isLogin ? <LoginForm /> : <RegisterForm />}
</motion.div>
</AnimatePresence>
{/* Switch */}
<div className="mt-6 text-center text-sm font-inter" style={{ color: '#8B8B8B' }}>
{isLogin ? (
<>
Ещё нет аккаунта?{' '}
<button
onClick={() => setIsLogin(false)}
className="font-medium hover:underline transition"
style={{ color: '#6B705C' }}
>
Зарегистрироваться
</button>
</>
) : (
<>
Уже есть аккаунт?{' '}
<button
onClick={() => setIsLogin(true)}
className="font-medium hover:underline transition"
style={{ color: '#6B705C' }}
>
Войти
</button>
</>
)}
</div>
</div>
</motion.div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { useAuthStore } from '../store/authStore';
export default function ChatPage() {
const user = useAuthStore((state) => state.user);
const logout = useAuthStore((state) => state.logout);
const handleLogout = async () => {
// TODO: Call logout API
logout();
window.location.href = '/auth';
};
return (
<div className="min-h-screen bg-gray-100">
<div className="h-screen flex">
{/* Sidebar */}
<div className="w-80 bg-card-white border-r border-gray-200">
<div className="p-4 border-b border-gray-200">
<h1 className="text-2xl font-lora font-semibold text-accent-olive">Aether</h1>
</div>
<div className="p-4">
<p className="text-sm text-text-muted font-inter">Привет, {user?.username}!</p>
<button
onClick={handleLogout}
className="mt-2 text-sm text-error-soft hover:text-red-700 font-inter"
>
Выйти
</button>
</div>
<div className="flex-1 overflow-y-auto">
<div className="p-4 text-center text-text-muted font-inter">
Чаты скоро появятся...
</div>
</div>
</div>
{/* Main Chat Area */}
<div className="flex-1 flex flex-col">
<div className="flex-1 flex items-center justify-center text-text-muted font-inter">
Выберите чат или начните новый
</div>
</div>
</div>
</div>
);
}
+198
View File
@@ -0,0 +1,198 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Eye, EyeOff } from 'lucide-react';
import { motion } from 'framer-motion';
import { authService } from '../services/authService';
export default function ResetPasswordPage() {
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!token) {
setError('Токен сброса пароля не найден');
}
}, [token]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (newPassword !== confirmPassword) {
setError('Пароли не совпадают');
return;
}
if (newPassword.length < 8) {
setError('Пароль должен быть минимум 8 символов');
return;
}
if (!token) {
setError('Токен не найден');
return;
}
setIsLoading(true);
try {
await authService.resetPassword(token, newPassword);
setSuccess(true);
setTimeout(() => navigate('/auth'), 3000);
} catch (err: any) {
setError(err.response?.data?.detail || 'Не удалось сбросить пароль');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center p-4 relative" style={{ backgroundColor: '#F5F5F1' }}>
{/* Subtle texture background */}
<div className="absolute inset-0 opacity-30 pointer-events-none"
style={{
backgroundImage: 'radial-gradient(circle at center, rgba(0,0,0,0.03) 1%, transparent 1%)',
backgroundSize: '20px 20px'
}}>
</div>
<motion.div
className="w-full max-w-md relative z-10"
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
>
<div className="bg-card-white rounded-[32px] shadow-soft p-8">
<div className="text-center mb-8">
<div className="w-20 h-20 mx-auto mb-4 rounded-full bg-gradient-to-br from-accent-terracotta to-accent-olive flex items-center justify-center text-white text-3xl font-lora shadow-logo border-[3px] border-[#EBEBE6]">
A
</div>
<div className="font-lora text-lg tracking-[2px] mb-6" style={{ color: '#6B705C' }}>
AETHER
</div>
<h2 className="text-xl font-lora font-semibold" style={{ color: '#2C2C2C' }}>
Сброс пароля
</h2>
</div>
{!success ? (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="newPassword" className="block font-lora italic text-[15px] mb-2" style={{ color: '#8B8B8B' }}>
Новый пароль
</label>
<div className="relative">
<input
id="newPassword"
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Минимум 8 символов"
autoFocus
className="w-full px-0 py-3 pr-10 bg-transparent border-0 border-b-2 border-gray-200 font-inter placeholder:text-text-muted/50 focus:outline-none transition-all duration-300"
style={{
color: '#2C2C2C',
borderBottomColor: '#E5E5E5'
}}
onFocus={(e) => e.target.style.borderBottomColor = '#6B705C'}
onBlur={(e) => e.target.style.borderBottomColor = '#E5E5E5'}
required
minLength={8}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-0 top-1/2 -translate-y-1/2 transition"
style={{ color: '#8B8B8B' }}
>
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
<div>
<label htmlFor="confirmPassword" className="block font-lora italic text-[15px] mb-2" style={{ color: '#8B8B8B' }}>
Повторите пароль
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Повторите новый пароль"
className="w-full px-0 py-3 bg-transparent border-0 border-b-2 border-gray-200 font-inter placeholder:text-text-muted/50 focus:outline-none transition-all duration-300"
style={{
color: '#2C2C2C',
borderBottomColor: '#E5E5E5'
}}
onFocus={(e) => e.target.style.borderBottomColor = '#6B705C'}
onBlur={(e) => e.target.style.borderBottomColor = '#E5E5E5'}
required
/>
</div>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-3 border-b-2 text-sm font-inter"
style={{
backgroundColor: 'rgba(199, 154, 139, 0.1)',
borderBottomColor: '#C79A8B',
color: '#C79A8B'
}}
>
{error}
</motion.div>
)}
<motion.button
type="submit"
disabled={isLoading}
whileTap={{ scale: 0.95 }}
style={{ backgroundColor: '#6B705C', color: 'white' }}
className="w-full mt-8 py-[18px] px-10 rounded-full font-inter font-semibold uppercase tracking-wider hover:shadow-lg transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Сброс...' : 'Сбросить пароль'}
</motion.button>
</form>
) : (
<motion.div
className="text-center space-y-4"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
>
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-lora font-semibold" style={{ color: '#2C2C2C' }}>
Пароль успешно изменен!
</h2>
<p className="font-inter" style={{ color: '#8B8B8B' }}>
Перенаправление на страницу входа...
</p>
</motion.div>
)}
<div className="mt-6 text-center">
<button
onClick={() => navigate('/auth')}
className="text-sm font-inter font-medium hover:underline transition"
style={{ color: '#6B705C' }}
>
Вернуться ко входу
</button>
</div>
</div>
</motion.div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { authService } from '../services/authService';
export default function VerifyEmailPage() {
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
const verify = async () => {
if (!token) {
setErrorMessage('Токен верификации не найден');
setStatus('error');
return;
}
try {
await authService.verifyEmail(token);
setStatus('success');
setTimeout(() => navigate('/auth'), 3000);
} catch (error: any) {
setErrorMessage(
error.response?.data?.detail || 'Не удалось подтвердить почту'
);
setStatus('error');
}
};
verify();
}, [token, navigate]);
return (
<div className="min-h-screen bg-bg-sand flex items-center justify-center p-4">
<motion.div
className="w-full max-w-md"
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
>
<div className="bg-card-white rounded-[32px] shadow-soft p-8 text-center">
<div className="mb-6">
<h1 className="text-2xl font-lora font-semibold text-accent-olive mb-2">Aether</h1>
</div>
{status === 'loading' && (
<div className="space-y-4">
<div className="w-16 h-16 border-4 border-gray-200 border-t-accent-olive rounded-full animate-spin mx-auto"></div>
<h2 className="text-xl font-lora font-semibold text-text-main">Верификация почты...</h2>
<p className="text-text-muted font-inter">Пожалуйста, подождите</p>
</div>
)}
{status === 'success' && (
<motion.div
className="space-y-4"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
>
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-lora font-semibold text-text-main">Почта подтверждена!</h2>
<p className="text-text-muted font-inter">
Ваша почта успешно подтверждена. Перенаправление на страницу входа...
</p>
</motion.div>
)}
{status === 'error' && (
<motion.div
className="space-y-4"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
>
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto">
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 className="text-xl font-lora font-semibold text-text-main">Ошибка верификации</h2>
<p className="text-text-muted font-inter">{errorMessage}</p>
<motion.button
onClick={() => navigate('/auth')}
whileTap={{ scale: 0.95 }}
className="mt-4 px-6 py-3 bg-accent-olive text-white rounded-full font-inter font-semibold hover:shadow-lg transition"
>
Вернуться к регистрации
</motion.button>
</motion.div>
)}
</div>
</motion.div>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import axios from 'axios';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const apiClient = axios.create({
baseURL: API_BASE_URL,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
});
// Interceptor для обработки ошибок
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Redirect to login if unauthorized, but not if already on public pages
const publicPaths = ['/auth', '/verify-email', '/reset-password'];
const currentPath = window.location.pathname;
const isPublicPage = publicPaths.some(path => currentPath.startsWith(path));
if (!isPublicPage) {
window.location.href = '/auth';
}
}
return Promise.reject(error);
}
);
export default apiClient;
+54
View File
@@ -0,0 +1,54 @@
import apiClient from './api';
export interface LoginData {
username: string;
password: string;
}
export interface RegisterData {
email: string;
username: string;
password: string;
}
export const authService = {
login: async (data: LoginData) => {
const formData = new URLSearchParams();
formData.append('username', data.username);
formData.append('password', data.password);
const response = await apiClient.post('/auth/login', formData, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
return response.data;
},
register: async (data: RegisterData) => {
const response = await apiClient.post('/auth/register', data);
return response.data;
},
logout: async () => {
const response = await apiClient.post('/auth/logout');
return response.data;
},
verifyEmail: async (token: string) => {
const response = await apiClient.post(`/auth/email/verify/${token}`);
return response.data;
},
resetPassword: async (token: string, newPassword: string) => {
const response = await apiClient.post(`/auth/password/reset/${token}`, null, {
params: { new_password: newPassword }
});
return response.data;
},
getCurrentUser: async () => {
const response = await apiClient.get('/users/me');
return response.data;
},
};
+25
View File
@@ -0,0 +1,25 @@
import { create } from 'zustand';
interface User {
id: string;
email: string;
username: string;
}
interface AuthStore {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
setUser: (user: User | null) => void;
setLoading: (loading: boolean) => void;
logout: () => void;
}
export const useAuthStore = create<AuthStore>((set) => ({
user: null,
isAuthenticated: false,
isLoading: true,
setUser: (user) => set({ user, isAuthenticated: !!user, isLoading: false }),
setLoading: (loading) => set({ isLoading: loading }),
logout: () => set({ user: null, isAuthenticated: false }),
}));
+30
View File
@@ -0,0 +1,30 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
'bg-sand': '#F5F5F1',
'card-white': '#FFFFFF',
'accent-terracotta': '#D27D56',
'accent-olive': '#6B705C',
'text-main': '#2C2C2C',
'text-muted': '#8B8B8B',
'input-bg': '#F9F9F7',
'error-soft': '#C79A8B',
},
fontFamily: {
'lora': ['Lora', 'serif'],
'inter': ['Inter', 'sans-serif'],
},
boxShadow: {
'soft': '0 15px 45px rgba(107, 112, 92, 0.1), 0 0 0 1px rgba(107, 112, 92, 0.05)',
'logo': '0 4px 12px rgba(107, 112, 92, 0.15)',
},
},
},
plugins: [],
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})