Merge pull request #3 from lorsanstand/dev

Dev
This commit is contained in:
Станислав
2026-01-09 20:34:49 +03:00
committed by GitHub
61 changed files with 6400 additions and 236 deletions
Regular → Executable
+2
View File
@@ -1 +1,3 @@
__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
@@ -1,15 +1,14 @@
import logging
from typing import Optional
import uuid
from fastapi import Depends, HTTPException, status
from jose import jwt, JWTError
from app.utils.OAuth2WithCookie import OAuth2PasswordBearerWithCookie
from app.config import settings
from app.core.config import settings
from app.users.models import UserModel
from app.users.service import UserService
from app.exceptions import InvalidTokenException
from app.core.exceptions import InvalidTokenException
log = logging.getLogger(__name__)
oauth2_scheme = OAuth2PasswordBearerWithCookie(tokenUrl="/api/v1/auth/login")
@@ -52,5 +51,7 @@ async def get_current_superuser(current_user: UserModel = Depends(get_current_us
async def get_current_verified_user(current_user: UserModel = Depends(get_current_user)):
if not current_user.is_verified:
log.debug("User has not confirmed the email.", extra={"user_id": current_user.id})
log.debug("User has not confirmed the email.", extra={"user_id": str(current_user.id)})
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="verify email")
return current_user
+109
View File
@@ -0,0 +1,109 @@
from typing import Dict
import logging
import uuid
from fastapi import APIRouter, status, Response, Depends, Request, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
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, get_current_verified_user
from app.core.config import settings
router = APIRouter(prefix="/auth", tags=["Auth"])
log = logging.getLogger(__name__)
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(user: UserCreate) -> User:
return await UserService.register_new_user(user)
@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("/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")
await UserService.send_verify_email(user)
return {"status": True, "message": "Successfully send email letter"}
@router.post("/login")
async def login(response: Response, credentials: OAuth2PasswordRequestForm = Depends()) -> Token:
user = await AuthService.authenticate_user(credentials.username, credentials.password)
if not user:
log.warning("Failed login attempt", extra={"email or username": credentials.username})
raise InvalidCredentialsException
token = await AuthService.create_token(user.id)
response.set_cookie(
'access_token',
token.access_token,
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
httponly=True
)
response.set_cookie(
'refresh_token',
str(token.refresh_token),
max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 30 * 24 * 60,
httponly=True
)
return token
@router.post("/refresh")
async def refresh_token(request: Request, response: Response) -> Token:
new_token = await AuthService.refresh_token(uuid.UUID(request.cookies.get("refresh_token")))
response.set_cookie(
'access_token',
new_token.access_token,
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
httponly=True
)
response.set_cookie(
'refresh_token',
str(new_token.refresh_token),
max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 30 * 24 * 60,
httponly=True
)
log.debug("Token refreshed via endpoint")
return new_token
@router.post("/logout")
async def logout(request: Request, response: Response, user: UserModel = Depends(get_current_user)) -> Dict:
response.delete_cookie("access_token")
response.delete_cookie("refresh_token")
await AuthService.logout(uuid.UUID(request.cookies.get("refresh_token")))
return {"status": True, "message": "Logged out successfully"}
@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"}
@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"}
+9
View File
@@ -0,0 +1,9 @@
import uuid
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
refresh_token: uuid.UUID
token_type: str
+102
View File
@@ -0,0 +1,102 @@
import logging
import uuid
from datetime import datetime, timedelta
from typing import Optional
from jose import jwt
from sqlalchemy import or_
from app.utils.hash_password import verify_password
from app.services.redis_service import RefreshTokenStorage
from app.core.exceptions import InvalidTokenException
from app.users.models import UserModel
from app.users.dao import UserDAO
from app.core.database import async_session_maker
from app.auth.schemas import Token
from app.core.config import settings
log = logging.getLogger(__name__)
class AuthService:
@classmethod
async def create_token(cls, user_id: int) -> Token:
access_token = cls._create_access_token(user_id)
refresh_token_expires = timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
refresh_token = cls._create_refresh_token()
await RefreshTokenStorage.save_token(refresh_token, user_id, int(refresh_token_expires.total_seconds()))
log.info("Token created has user", extra={"user_id": user_id})
return Token(access_token=access_token, refresh_token=refresh_token, token_type="bearer")
@classmethod
async def logout(cls, token: uuid.UUID) -> None:
user_id = await RefreshTokenStorage.getdel_token(token)
log.info("User logged out", extra={"user_id": user_id})
@classmethod
async def refresh_token(cls, token: uuid.UUID) -> Token:
async with async_session_maker() as session:
refresh_session = await RefreshTokenStorage.getdel_token(token)
if refresh_session is None:
log.warning("Refresh token not found")
raise InvalidTokenException
user = await UserDAO.find_one_or_none(session, id=int(refresh_session))
if user is None:
log.error("User not found during token refresh", extra={"user_id": str(refresh_session.user_id)})
raise InvalidTokenException
access_token = cls._create_access_token(user.id)
refresh_token_expires = timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
refresh_token = cls._create_refresh_token()
await RefreshTokenStorage.save_token(
refresh_token,
user.id,
int(refresh_token_expires.total_seconds())
)
await session.commit()
log.info("Token refreshed for user", extra={"user_id": str(user.id)})
return Token(access_token=access_token, refresh_token=refresh_token, token_type="bearer")
@classmethod
async def authenticate_user(cls, email_or_username: str, password: str) -> Optional[UserModel]:
async with async_session_maker() as session:
db_user = await UserDAO.find_one_or_none(
session,
or_(
UserModel.email==email_or_username,
UserModel.username==email_or_username
)
)
if db_user and verify_password(password, db_user.hashed_password):
log.info("User authenticated successfully", extra={"username": db_user.username})
return db_user
log.warning("Authentication failed", extra={"email": email_or_username})
return None
@classmethod
async def abort_all_sessions(cls, user_id: int):
await RefreshTokenStorage.abort_all_tokens(user_id)
@classmethod
def _create_access_token(cls, user_id: int) -> str:
to_encode = {
"sub": str(user_id),
"exp": datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
}
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return f'Bearer {encoded_jwt}'
@classmethod
def _create_refresh_token(cls) -> uuid.UUID:
return uuid.uuid4()
View File
@@ -1,9 +1,9 @@
from celery import Celery
from app.config import settings
from app.core.config import settings
celery_app = Celery(
"app.utils.celery_app",
"app.core.celery_app",
broker=settings.RABBITMQ_URL,
backend="rpc://"
)
@@ -14,13 +14,14 @@ class Settings(BaseSettings):
WORKERS: int
URL: str
CORS_ORIGINS: List[str] = ["*"]
CORS_ORIGINS: List[str] = ["http://localhost:5500", "http://127.0.0.1:5500", "http://localhost:8080", "http://127.0.0.1:8080", "null"]
CORS_HEADERS: List[str] = ["*"]
CORS_METHODS: List[str] = ["*"]
SECRET_KEY: str
ALGORITHM: str
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
EMAIL_TOKEN_EXPIRE_MINUTES: int = 60
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
SMTP_SERVER: str
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError
from pydantic import BaseModel
from app.database import Base
from app.core.database import Base
log = logging.getLogger(__name__)
@@ -1,12 +1,11 @@
from datetime import datetime
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
from sqlalchemy.engine import create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import MetaData, NullPool, func
from app.config import settings
from app.constants import DB_NAMING_CONVENTION
from app.core.config import settings
from app.core.constants import DB_NAMING_CONVENTION
class Base(DeclarativeBase):
@@ -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")
@@ -1,6 +1,6 @@
from logging.config import dictConfig
from app.config import settings
from app.core.config import settings
LOGGING_CONFIG = {
"version": 1,
@@ -1,6 +1,6 @@
from redis.asyncio import Redis, from_url
from app.config import settings
from app.core.config import settings
redis_client: Redis = None
+32 -10
View File
@@ -1,14 +1,17 @@
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
from app.utils.redis import close_redis, init_redis
from app.users.router import user_router, auth_router
from app.log_config import set_logging
from app.config import settings
from app.core.redis import close_redis, init_redis
from app.users.router import router as user_router
from app.auth.router import router as auth_router
from app.core.log_config import set_logging
from app.core.config import settings
set_logging()
log = logging.getLogger(__name__)
@@ -45,20 +48,39 @@ 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"
}
)
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")
async def test_health():
+2 -3
View File
@@ -7,9 +7,8 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.users.models import UserModel
from app.database import Base
from app.config import settings
from app.core.database import Base
from app.core.config import settings
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
+27 -2
View File
@@ -1,7 +1,7 @@
import logging
from app.utils.email_client import EmailClient
from app.config import settings
from app.core.config import settings
log = logging.getLogger(__name__)
@@ -16,7 +16,32 @@ class EmailService:
template_path="confirm_email.html",
username=username,
url=url,
expire_minutes=60,
expire_minutes=settings.EMAIL_TOKEN_EXPIRE_MINUTES,
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:
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(
+79
View File
@@ -0,0 +1,79 @@
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"
@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)
await redis_client.sadd(f"user:{user_id}:{cls.PREFIX}", str(token))
await redis_client.expire(f"user:{user_id}:{cls.PREFIX}", ttl+3600)
log.info("Save new refresh token from redis", extra={"user_id": user_id, "token": token})
@classmethod
async def getdel_token(cls, token: uuid.UUID) -> int:
redis_client = await get_redis()
user_id = await redis_client.getdel(f"{cls.PREFIX}:{token}")
await redis_client.srem(f"user:{user_id}:{cls.PREFIX}", str(token))
log.info("Remove token", extra={"user_id": user_id, "token": token})
return user_id
@classmethod
async def get_token(cls, token: uuid.UUID) -> int:
redis_client = await get_redis()
user_id = await redis_client.get(f"{cls.PREFIX}:{token}")
log.debug("User_id fetched from refresh token", extra={"user_id": user_id, "token": token})
return user_id
@classmethod
async def abort_all_tokens(cls, user_id: int):
redis_client = await get_redis()
log.debug("Start abort all tokens", extra={"user_id": user_id})
tokens = await redis_client.smembers(f"user:{user_id}:{cls.PREFIX}")
for token in tokens:
await redis_client.delete(f"{cls.PREFIX}:{token}")
await redis_client.delete(f"user:{user_id}:{cls.PREFIX}")
log.info("Successfully abort all tokens", extra={"user_id": user_id})
class EmailTokenStorage(TokenStorage):
PREFIX = "email"
class ChangePasswordTokenStorage(TokenStorage):
PREFIX = "changepassword"
+7 -1
View File
@@ -1,6 +1,6 @@
import logging
from app.utils.celery_app import celery_app
from app.core.celery_app import celery_app
from app.services.email_service import EmailService
log = logging.getLogger(__name__)
@@ -11,3 +11,9 @@ class EmailTasks:
@celery_app.task
def send_verify_email_task(email: str, username: str, url: str):
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)
+1 -1
View File
@@ -1,4 +1,4 @@
from app.dao import BaseDAO
from app.core.dao import BaseDAO
from app.users.models import UserModel
from app.users.schemas import UserCreateDB, UserUpdateDB
+2 -11
View File
@@ -1,10 +1,9 @@
from datetime import date
import uuid
from sqlalchemy.orm import mapped_column, Mapped
from sqlalchemy import DATE, UUID, ForeignKey
from sqlalchemy import DATE
from app.database import Base
from app.core.database import Base
class UserModel(Base):
__tablename__ = "user"
@@ -20,11 +19,3 @@ class UserModel(Base):
is_verified: Mapped[bool] = mapped_column(default=False)
is_superuser: Mapped[bool] = mapped_column(default=False)
hashed_password: Mapped[str] = mapped_column()
# class RefreshSessionModel(Base):
# __tablename__ = "refresh_session"
#
# id: Mapped[int] = mapped_column(primary_key=True, index=True)
# refresh_token: Mapped[uuid.UUID] = mapped_column(UUID, index=True)
# expires_in: Mapped[int] = mapped_column()
# user_ud: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"))
+26 -67
View File
@@ -1,80 +1,39 @@
from typing import Dict
import logging
import uuid
from fastapi import APIRouter, status, Response, Depends, Request
from fastapi.security import OAuth2PasswordRequestForm
from fastapi import APIRouter, Response, Depends
from app.users.schemas import UserCreate, User, Token
from app.users.service import AuthService, UserService
from app.users.schemas import User, UserUpdate
from app.users.service import UserService
from app.auth.service import AuthService
from app.users.models import UserModel
from app.exceptions import InvalidCredentialsException
from app.users.dependencies import get_current_user, get_current_verified_user
from app.config import settings
from app.auth.dependencies import get_current_verified_user, get_current_superuser
user_router = APIRouter(prefix="/users", tags=["User"])
auth_router = APIRouter(prefix="/auth", tags=["Auth"])
router = APIRouter(prefix="/users", tags=["User"])
log = logging.getLogger(__name__)
@auth_router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(user: UserCreate) -> User:
return await UserService.register_new_user(user)
@router.get("/me")
async def get_current_user(user: UserModel = Depends(get_current_verified_user)) -> User:
log.debug("Getting current user profile", extra={"user_id": str(user.id)})
return user
@auth_router.get("/verify/{token}")
async def verify_email(token: uuid.UUID):
await UserService.verify_email(token)
return {"status": True}
@router.get("/")
async def get_users(offset: int, limit: int, user: UserModel = Depends(get_current_superuser)):
log.info("Getting users list", extra={"offset": offset, "limit": limit})
return await UserService.get_users_list(offset=offset, limit=limit)
@auth_router.post("/send/verify_email")
async def resend_verify_email(user: UserModel = Depends(get_current_user)):
await UserService.send_verify_email(user)
return {"status": True, "message": "Successfully send email letter"}
@router.put("/me")
async def update_current_user(update_user: UserUpdate, user: UserModel = Depends(get_current_verified_user)) -> User:
return await UserService.update_user(user.id, update_user)
@auth_router.post("/login")
async def login(response: Response, credentials: OAuth2PasswordRequestForm = Depends()) -> Token:
user = await AuthService.authenticate_user(credentials.username, credentials.password)
if not user:
log.warning("Failed login attempt", extra={"email or username": credentials.username})
raise InvalidCredentialsException
token = await AuthService.create_token(user.id)
response.set_cookie(
'access_token',
token.access_token,
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
httponly=True
)
response.set_cookie(
'refresh_token',
str(token.refresh_token),
max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 30 * 24 * 60,
httponly=True
)
return token
@router.delete("/me")
async def delete_current_user(response: Response, user: UserModel = Depends(get_current_verified_user)) -> Dict:
log.debug("User deleting their account", extra={"user_id": user.id, "email": user.email})
response.delete_cookie('access_token')
response.delete_cookie('refresh_token')
@auth_router.post("/refresh")
async def refresh_token(request: Request, response: Response) -> Token:
new_token = await AuthService.refresh_token(uuid.UUID(request.cookies.get("refresh_token")))
response.set_cookie(
'access_token',
new_token.access_token,
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
httponly=True
)
response.set_cookie(
'refresh_token',
str(new_token.refresh_token),
max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 30 * 24 * 60,
httponly=True
)
log.debug("Token refreshed via endpoint")
return new_token
@auth_router.post("/logout")
async def logout(request: Request, response: Response, user: UserModel = Depends(get_current_user)):
response.delete_cookie("access_token")
response.delete_cookie("refresh_token")
await AuthService.logout(uuid.UUID(request.cookies.get("refresh_token")))
return {"status": True, "message": "Logged out successfully"}
await AuthService.abort_all_sessions(user.id)
await UserService.delete_user(user.id)
return {"status": True, "message": "User successfully deleted"}
+4 -7
View File
@@ -1,6 +1,5 @@
from typing import Optional
from datetime import date
import uuid
from pydantic import BaseModel, EmailStr
@@ -50,15 +49,13 @@ class UserCreateDB(UserBase):
class UserUpdateDB(UserBase):
email: Optional[str] = None
hashed_password: str
hashed_password: Optional[str] = None
description: Optional[str] = None
birth_day: Optional[date] = None
is_active: Optional[bool] = None
is_verified: Optional[bool] = None
is_superuser: Optional[bool] = None
class Token(BaseModel):
access_token: str
refresh_token: uuid.UUID
token_type: str
class ChangePassword(BaseModel):
old_password: str
new_password: str
+141 -110
View File
@@ -1,114 +1,24 @@
import logging
import uuid
from datetime import datetime, timedelta
from typing import Optional
from datetime import timedelta
from typing import List
from fastapi import HTTPException, status
from jose import jwt
from sqlalchemy import or_
from app.utils.hash_password import hash_password, verify_password
from app.utils.redis import get_redis
from app.exceptions import InvalidTokenException, TokenExpiredException
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.database import async_session_maker
from app.users.schemas import Token, UserCreate, UserCreateDB, User
from app.core.database import async_session_maker
from app.users.schemas import UserCreate, UserCreateDB, User, UserUpdate, UserUpdateDB, ChangePassword
from app.tasks.email_tasks import EmailTasks
from app.config import settings
from app.core.config import settings
log = logging.getLogger(__name__)
class AuthService:
@classmethod
async def create_token(cls, user_id: int) -> Token:
redis_client = await get_redis()
access_token = cls._create_access_token(user_id)
refresh_token_expires = timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
refresh_token = cls._create_refresh_token()
await redis_client.setex(f"refresh:{refresh_token}", int(refresh_token_expires.total_seconds()), user_id)
log.info("Token created has user", extra={"user_id": user_id})
return Token(access_token=access_token, refresh_token=refresh_token, token_type="bearer")
@classmethod
async def logout(cls, token: uuid.UUID) -> None:
redis_client = await get_redis()
user_id = await redis_client.getdel(f"refresh:{token}")
log.info("User logged out", extra={"user_id": user_id})
@classmethod
async def refresh_token(cls, token: uuid.UUID) -> Token:
redis_client = await get_redis()
async with async_session_maker() as session:
refresh_session = await redis_client.getdel(f"refresh:{token}")
if refresh_session is None:
log.warning("Refresh token not found")
raise InvalidTokenException
user = await UserDAO.find_one_or_none(session, id=int(refresh_session))
if user is None:
log.error("User not found during token refresh", extra={"user_id": str(refresh_session.user_id)})
raise InvalidTokenException
access_token = cls._create_access_token(user.id)
refresh_token_expires = timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
refresh_token = cls._create_refresh_token()
await redis_client.setex(
f"refresh:{refresh_token}",
int(refresh_token_expires.total_seconds()),
user.id
)
await session.commit()
log.info("Token refreshed for user", extra={"user_id": str(user.id)})
return Token(access_token=access_token, refresh_token=refresh_token, token_type="bearer")
@classmethod
async def authenticate_user(cls, email_or_username: str, password: str) -> Optional[UserModel]:
async with async_session_maker() as session:
db_user = await UserDAO.find_one_or_none(
session,
or_(
UserModel.email==email_or_username,
UserModel.username==email_or_username
)
)
if db_user and verify_password(password, db_user.hashed_password):
log.info("User authenticated successfully", extra={"username": db_user.username})
return db_user
log.warning("Authentication failed", extra={"email": email_or_username})
return None
# @classmethod
# async def abort_all_sessions(cls, user_id: uuid.UUID):
# async with async_session_maker() as session:
# await RefreshSessionDAO.delete(session, RefreshSessionModel.user_id == user_id)
# await session.commit()
@classmethod
def _create_access_token(cls, user_id: int) -> str:
to_encode = {
"sub": str(user_id),
"exp": datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
}
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return f'Bearer {encoded_jwt}'
@classmethod
def _create_refresh_token(cls) -> str:
return uuid.uuid4()
class UserService:
@classmethod
async def get_user(cls, user_id: int) -> User:
@@ -116,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
@@ -124,8 +34,6 @@ class UserService:
@classmethod
async def register_new_user(cls, user: UserCreate) -> User:
redis_client = await get_redis()
async with async_session_maker() as session:
user_exist = await UserDAO.find_one_or_none(session, or_(
UserModel.email==user.email,
@@ -155,25 +63,27 @@ class UserService:
@classmethod
async def send_verify_email(cls, user: UserModel):
redis_client = await get_redis()
token = cls._create_uuid_token()
url = f"{settings.URL}/verify-email/{token}"
email_token_expires = timedelta(minutes=settings.EMAIL_TOKEN_EXPIRE_MINUTES)
token = cls._create_email_verification_token()
url = f"{settings.URL}/api/v1/auth/verify/{token}"
await redis_client.setex(f"email:{token}", timedelta(minutes=60), user.id)
await EmailTokenStorage.save_token(
token,
user.id,
int(email_token_expires.total_seconds())
)
EmailTasks.send_verify_email_task.delay(email=user.email, username=user.username, url=url)
@classmethod
def _create_email_verification_token(cls) -> uuid.UUID:
def _create_uuid_token(cls) -> uuid.UUID:
return uuid.uuid4()
@classmethod
async def verify_email(cls, token: uuid.UUID):
redis_client = await get_redis()
async with async_session_maker() as session:
user_id = await redis_client.getdel(f"email:{token}")
user_id = await EmailTokenStorage.getdel_token(token)
if user_id is None:
raise TokenExpiredException
@@ -186,8 +96,129 @@ 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
async def get_users_list(cls, offset: int = 0, limit: int = 10) -> List[UserModel]:
async with async_session_maker() as session:
users = await UserDAO.find_all(session, offset, limit)
if users is None:
log.warning("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) -> 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 UserNotFoundException
if user_exist.username != update_user.username:
username_exist = await UserDAO.find_one_or_none(session, username=update_user.username)
if username_exist:
log.warning("Username is taken", extra={"user_id": user_id})
raise HTTPException(status.HTTP_409_CONFLICT, detail="Username is taken")
update_user_db = await UserDAO.update(
session,
UserModel.id==user_id,
obj_in=UserUpdateDB(
**update_user.model_dump()
)
)
await session.commit()
log.info("User updated", extra={"user_id": user_id})
return update_user_db
@classmethod
async def delete_user(cls, user_id):
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 UserNotFoundException
await UserDAO.update(
session,
UserModel.id==user_id,
obj_in={"is_active": False}
)
await session.commit()
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})
+7 -2
View File
@@ -1,10 +1,13 @@
import json
import logging
from functools import wraps
from fastapi import Request
from fastapi.encoders import jsonable_encoder
from app.utils.redis import get_redis
from app.core.redis import get_redis
log = logging.getLogger(__name__)
def cache(ttl: int = 10):
if ttl <= 0:
@@ -17,10 +20,12 @@ def cache(ttl: int = 10):
request: Request = kwargs.get("request")
response_cache = await redis.get(str(request.url))
if response_cache is not None:
log.debug("Getting from cache")
return json.loads(response_cache)
response_cache = await func(*args, **kwargs)
await redis.setex(str(request.url), ttl, json.dumps(response_cache))
serializable_data = jsonable_encoder(response_cache)
await redis.setex(str(request.url), ttl, json.dumps(serializable_data))
return response_cache
return wrapper
+1 -1
View File
@@ -7,7 +7,7 @@ from email.mime.multipart import MIMEMultipart
from jinja2 import Environment, FileSystemLoader
from app.config import settings
from app.core.config import settings
log = logging.getLogger(__name__)
+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()],
})