diff --git a/docker-compose.yml b/docker-compose.yml index e8a8791..81eb333 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: ports: - "8080:8000" environment: - - DATABASE_URL=mysql+pymysql://${DB_USER:-evosync}:${DB_PASSWORD:-evosync}@localhost:3306/${DB_NAME:-evosync} + - DATABASE_URL=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@172.25.0.1:3306/${DB_NAME} - SECRET_KEY=${SECRET_KEY:-change-me-in-production} - BASE_URL=${BASE_URL:-http://localhost:8080} volumes: diff --git a/requirements.txt b/requirements.txt index 763d5ad..98a6112 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,8 +2,10 @@ fastapi==0.115.0 uvicorn[standard]==0.30.0 sqlalchemy==2.0.35 pymysql==1.1.1 +cryptography>=41.0.0 jinja2==3.1.4 python-multipart==0.0.12 passlib[bcrypt]==1.7.4 bcrypt==4.2.0 pydantic-settings==2.5.2 +itsdangerous==2.1.2 diff --git a/web/config.py b/web/config.py index 8dc8126..7916f41 100644 --- a/web/config.py +++ b/web/config.py @@ -7,7 +7,7 @@ class Settings(BaseSettings): BASE_URL: str = "http://localhost:8000" PASSWORD_RESET_EXPIRE_MINUTES: int = 60 - model_config = {"env_file": ".env"} + model_config = {"env_file": ".env", "case_sensitive": False} settings = Settings() diff --git a/web/routes/profile.py b/web/routes/profile.py index 76409f0..828474e 100644 --- a/web/routes/profile.py +++ b/web/routes/profile.py @@ -3,24 +3,33 @@ from fastapi.responses import RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy.orm import Session -from web.auth import get_current_user +from web.auth import get_current_user, verify_password, hash_password from web.database import get_db from web.models import User -from web.schemas import validate_profile +from web.schemas import validate_profile, validate_reset_password router = APIRouter() templates = Jinja2Templates(directory="web/templates") +# VIEW PROFILE @router.get("/profile") def profile_view(request: Request, user: User | None = Depends(get_current_user)): if not user: return RedirectResponse("/login", 303) - return templates.TemplateResponse("profile.html", {"request": request, "user": user}) + return templates.TemplateResponse("profile_view.html", {"request": request, "user": user}) -@router.post("/profile") -async def profile_update( +# EDIT PROFILE +@router.get("/profile/edit") +def profile_edit_form(request: Request, user: User | None = Depends(get_current_user)): + if not user: + return RedirectResponse("/login", 303) + return templates.TemplateResponse("profile_edit.html", {"request": request, "user": user}) + + +@router.post("/profile/edit") +async def profile_edit_submit( request: Request, db: Session = Depends(get_db), user: User | None = Depends(get_current_user), @@ -41,7 +50,7 @@ async def profile_update( errors.append("Пользователь с таким телефоном уже существует") if errors: - return templates.TemplateResponse("profile.html", { + return templates.TemplateResponse("profile_edit.html", { "request": request, "user": user, "errors": errors, "form": data, }) @@ -50,6 +59,87 @@ async def profile_update( user.phone = data["phone"].strip() db.commit() - return templates.TemplateResponse("profile.html", { + return templates.TemplateResponse("profile_edit.html", { "request": request, "user": user, "success": "Профиль обновлен", }) + + +# CHANGE PASSWORD +@router.get("/profile/change-password") +def change_password_form(request: Request, user: User | None = Depends(get_current_user)): + if not user: + return RedirectResponse("/login", 303) + return templates.TemplateResponse("profile_change_password.html", {"request": request, "user": user}) + + +@router.post("/profile/change-password") +async def change_password_submit( + request: Request, + db: Session = Depends(get_db), + user: User | None = Depends(get_current_user), +): + if not user: + return RedirectResponse("/login", 303) + + form = await request.form() + data = dict(form) + + errors = [] + current_password = data.get("current_password", "") + if not current_password: + errors.append("Введите текущий пароль") + elif not verify_password(current_password, user.password_hash): + errors.append("Неверный текущий пароль") + + password_errors = validate_reset_password(data) + errors.extend(password_errors) + + if errors: + return templates.TemplateResponse("profile_change_password.html", { + "request": request, "user": user, "errors": errors, + }) + + user.password_hash = hash_password(data["password"]) + db.commit() + + return templates.TemplateResponse("profile_change_password.html", { + "request": request, "user": user, "success": "Пароль изменен", + }) + + +# DELETE ACCOUNT +@router.get("/profile/delete") +def delete_account_form(request: Request, user: User | None = Depends(get_current_user)): + if not user: + return RedirectResponse("/login", 303) + return templates.TemplateResponse("profile_delete.html", {"request": request, "user": user}) + + +@router.post("/profile/delete") +async def delete_account_submit( + request: Request, + db: Session = Depends(get_db), + user: User | None = Depends(get_current_user), +): + if not user: + return RedirectResponse("/login", 303) + + form = await request.form() + data = dict(form) + + password = data.get("password", "") + if not password: + return templates.TemplateResponse("profile_delete.html", { + "request": request, "user": user, "errors": ["Введите пароль для подтверждения"], + }) + + if not verify_password(password, user.password_hash): + return templates.TemplateResponse("profile_delete.html", { + "request": request, "user": user, "errors": ["Неверный пароль"], + }) + + db.delete(user) + db.commit() + request.session.clear() + + return RedirectResponse("/", 303) diff --git a/web/static/style.css b/web/static/style.css index 059228d..6270d70 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -1,215 +1,39 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; +/* Brand overrides */ +:root { + --bs-primary: #F05023; + --bs-primary-rgb: 240, 80, 35; + --bs-link-color: #0986E2; + --bs-link-hover-color: #0670c0; } -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - color: #333; - background: #fff; - line-height: 1.6; -} - -.container { - max-width: 960px; - margin: 0 auto; - padding: 0 20px; -} - -/* Navbar */ -.navbar { - background: #fff; - border-bottom: 2px solid #F05023; - padding: 14px 0; -} - -.nav-content { - display: flex; - justify-content: space-between; - align-items: center; -} - -.nav-logo { +.brand-logo { font-size: 22px; font-weight: 700; - color: #F05023; - text-decoration: none; + color: #F05023 !important; } -.nav-links a { - margin-left: 24px; - color: #333; - text-decoration: none; - font-size: 15px; -} - -.nav-links a:hover { - color: #F05023; -} - -/* Forms */ -.form-card { - max-width: 440px; - margin: 60px auto; - background: #F4F6F8; - border-radius: 8px; - padding: 36px; -} - -.form-card h1 { - font-size: 24px; - margin-bottom: 24px; - color: #333; -} - -.form-group { - margin-bottom: 18px; -} - -.form-group label { - display: block; - margin-bottom: 6px; - font-size: 14px; - font-weight: 500; - color: #333; -} - -.form-group input { - width: 100%; - padding: 10px 12px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 15px; - transition: border-color 0.2s; -} - -.form-group input:focus { - outline: none; - border-color: #F05023; -} - -.btn { - display: inline-block; - padding: 10px 24px; - border: none; - border-radius: 4px; - font-size: 15px; - font-weight: 500; - cursor: pointer; - text-decoration: none; - transition: opacity 0.2s; -} - -.btn:hover { - opacity: 0.85; +.brand-border { + border-color: #F05023 !important; } .btn-primary { - background: #F05023; - color: #fff; - width: 100%; + --bs-btn-bg: #F05023; + --bs-btn-border-color: #F05023; + --bs-btn-hover-bg: #d44420; + --bs-btn-hover-border-color: #d44420; + --bs-btn-active-bg: #c03d1c; + --bs-btn-active-border-color: #c03d1c; } .btn-secondary { - background: #0986E2; - color: #fff; + --bs-btn-bg: #0986E2; + --bs-btn-border-color: #0986E2; + --bs-btn-hover-bg: #0770c0; + --bs-btn-hover-border-color: #0770c0; + --bs-btn-active-bg: #065fa3; + --bs-btn-active-border-color: #065fa3; } -/* Alerts */ -.alert { - padding: 14px 18px; - border-radius: 4px; - margin: 20px 0; - font-size: 14px; -} - -.alert-error { - background: #fef2f0; - border: 1px solid #F05023; - color: #c0392b; -} - -.alert-success { - background: #eafaf3; - border: 1px solid #4DD1A2; - color: #1a7a4c; -} - -.alert p { - margin: 2px 0; -} - -/* Links */ -.form-links { - margin-top: 18px; - text-align: center; - font-size: 14px; -} - -.form-links a { - color: #0986E2; - text-decoration: none; -} - -.form-links a:hover { - text-decoration: underline; -} - -/* Message page */ -.message-card { - max-width: 500px; - margin: 80px auto; - text-align: center; - background: #F4F6F8; - border-radius: 8px; - padding: 40px; -} - -.message-card h1 { - font-size: 22px; - margin-bottom: 16px; - color: #333; -} - -.message-card p { - font-size: 15px; - color: #555; - margin-bottom: 12px; -} - -.message-card a { - color: #0986E2; - text-decoration: none; -} - -.message-card a:hover { - text-decoration: underline; -} - -/* Profile */ -.profile-card { - max-width: 540px; - margin: 60px auto; - background: #F4F6F8; - border-radius: 8px; - padding: 36px; -} - -.profile-card h1 { - font-size: 24px; - margin-bottom: 24px; - color: #333; -} - -.profile-info { - margin-bottom: 18px; - font-size: 15px; -} - -.profile-info span { - color: #777; - display: block; - font-size: 13px; - margin-bottom: 2px; +.nav-link:hover { + color: #F05023 !important; } diff --git a/web/templates/base.html b/web/templates/base.html index a16f083..0f9da67 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -4,40 +4,57 @@