Add user registration and auth web app
FastAPI + Jinja2 + MariaDB web application with registration, login, profile, password reset, and email confirmation flows. All UI in Russian. Styled with Evotor brand colors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
DATABASE_URL=mysql+pymysql://evosync:evosync@db:3306/evosync
|
||||||
|
SECRET_KEY=your-random-secret-key-here
|
||||||
|
BASE_URL=http://localhost:8000
|
||||||
|
|
||||||
|
DB_ROOT_PASSWORD=rootpass
|
||||||
|
DB_NAME=evosync
|
||||||
|
DB_USER=evosync
|
||||||
|
DB_PASSWORD=evosync
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,3 +13,6 @@ run/test.log
|
|||||||
vk/whitelist
|
vk/whitelist
|
||||||
logs/
|
logs/
|
||||||
passwords.txt
|
passwords.txt
|
||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
10
Dockerfile.web
Normal file
10
Dockerfile.web
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY web/ ./web/
|
||||||
|
|
||||||
|
CMD ["uvicorn", "web.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
42
docker-compose.yml
Normal file
42
docker-compose.yml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: mariadb:10.11
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootpass}
|
||||||
|
MYSQL_DATABASE: ${DB_NAME:-evosync}
|
||||||
|
MYSQL_USER: ${DB_USER:-evosync}
|
||||||
|
MYSQL_PASSWORD: ${DB_PASSWORD:-evosync}
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/mysql
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.web
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=mysql+pymysql://${DB_USER:-evosync}:${DB_PASSWORD:-evosync}@db:3306/${DB_NAME:-evosync}
|
||||||
|
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
||||||
|
- BASE_URL=${BASE_URL:-http://localhost:8000}
|
||||||
|
volumes:
|
||||||
|
- ./web:/app/web
|
||||||
|
|
||||||
|
sync:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
volumes:
|
||||||
|
- ./evo:/var/www/evo
|
||||||
|
- ./vk:/var/www/vk
|
||||||
|
- ./run:/var/www/run
|
||||||
|
- ./logs:/var/www/logs
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.0
|
||||||
|
sqlalchemy==2.0.35
|
||||||
|
pymysql==1.1.1
|
||||||
|
jinja2==3.1.4
|
||||||
|
python-multipart==0.0.12
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
bcrypt==4.2.0
|
||||||
|
pydantic-settings==2.5.2
|
||||||
0
web/__init__.py
Normal file
0
web/__init__.py
Normal file
23
web/auth.py
Normal file
23
web/auth.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from fastapi import Request, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from web.database import get_db
|
||||||
|
from web.models import User
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return pwd_context.verify(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User | None:
|
||||||
|
user_id = request.session.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
return None
|
||||||
|
return db.query(User).filter(User.id == user_id).first()
|
||||||
13
web/config.py
Normal file
13
web/config.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
DATABASE_URL: str = "mysql+pymysql://evosync:evosync@localhost:3306/evosync"
|
||||||
|
SECRET_KEY: str = "change-me-in-production"
|
||||||
|
BASE_URL: str = "http://localhost:8000"
|
||||||
|
PASSWORD_RESET_EXPIRE_MINUTES: int = 60
|
||||||
|
|
||||||
|
model_config = {"env_file": ".env"}
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
19
web/database.py
Normal file
19
web/database.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||||
|
|
||||||
|
from web.config import settings
|
||||||
|
|
||||||
|
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
22
web/main.py
Normal file
22
web/main.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
|
from web.config import settings
|
||||||
|
from web.database import engine, Base
|
||||||
|
from web.models import User # noqa: F401 — registers model with Base
|
||||||
|
from web.routes import auth, profile, reset
|
||||||
|
|
||||||
|
app = FastAPI(title="EvoSync — Личный кабинет")
|
||||||
|
|
||||||
|
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)
|
||||||
|
app.mount("/static", StaticFiles(directory="web/static"), name="static")
|
||||||
|
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(profile.router)
|
||||||
|
app.include_router(reset.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def on_startup():
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
21
web/models.py
Normal file
21
web/models.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from web.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
first_name = Column(String(100), nullable=False)
|
||||||
|
last_name = Column(String(100), nullable=False)
|
||||||
|
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||||
|
phone = Column(String(20), unique=True, nullable=False, index=True)
|
||||||
|
password_hash = Column(String(255), nullable=False)
|
||||||
|
is_email_confirmed = Column(Boolean, default=False, nullable=False)
|
||||||
|
email_confirm_token = Column(String(255), nullable=True)
|
||||||
|
password_reset_token = Column(String(255), nullable=True)
|
||||||
|
password_reset_expires = Column(DateTime, nullable=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
0
web/routes/__init__.py
Normal file
0
web/routes/__init__.py
Normal file
123
web/routes/auth.py
Normal file
123
web/routes/auth.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Depends
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from web.auth import hash_password, verify_password, get_current_user
|
||||||
|
from web.config import settings
|
||||||
|
from web.database import get_db
|
||||||
|
from web.models import User
|
||||||
|
from web.schemas import validate_registration, validate_login
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="web/templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/register")
|
||||||
|
def register_form(request: Request, user: User | None = Depends(get_current_user)):
|
||||||
|
if user:
|
||||||
|
return RedirectResponse("/profile", 303)
|
||||||
|
return templates.TemplateResponse("register.html", {"request": request, "user": None})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register")
|
||||||
|
async def register_submit(request: Request, db: Session = Depends(get_db)):
|
||||||
|
form = await request.form()
|
||||||
|
data = dict(form)
|
||||||
|
|
||||||
|
errors = validate_registration(data)
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
existing = db.query(User).filter(
|
||||||
|
(User.email == data["email"].strip()) | (User.phone == data["phone"].strip())
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
if existing.email == data["email"].strip():
|
||||||
|
errors.append("Пользователь с таким email уже существует")
|
||||||
|
else:
|
||||||
|
errors.append("Пользователь с таким телефоном уже существует")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
return templates.TemplateResponse("register.html", {
|
||||||
|
"request": request, "user": None, "errors": errors, "form": data,
|
||||||
|
})
|
||||||
|
|
||||||
|
token = uuid.uuid4().hex
|
||||||
|
user = User(
|
||||||
|
first_name=data["first_name"].strip(),
|
||||||
|
last_name=data["last_name"].strip(),
|
||||||
|
email=data["email"].strip(),
|
||||||
|
phone=data["phone"].strip(),
|
||||||
|
password_hash=hash_password(data["password"]),
|
||||||
|
email_confirm_token=token,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
confirm_url = f"{settings.BASE_URL}/confirm-email?token={token}"
|
||||||
|
print("=" * 40)
|
||||||
|
print("ПОДТВЕРЖДЕНИЕ EMAIL")
|
||||||
|
print(f"Пользователь: {user.email}")
|
||||||
|
print(f"Ссылка: {confirm_url}")
|
||||||
|
print("=" * 40)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("confirm_email.html", {"request": request, "user": None})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/confirm-email")
|
||||||
|
def confirm_email(request: Request, token: str, db: Session = Depends(get_db)):
|
||||||
|
user = db.query(User).filter(User.email_confirm_token == token).first()
|
||||||
|
if not user:
|
||||||
|
return templates.TemplateResponse("message.html", {
|
||||||
|
"request": request, "user": None,
|
||||||
|
"title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
|
||||||
|
})
|
||||||
|
|
||||||
|
user.is_email_confirmed = True
|
||||||
|
user.email_confirm_token = None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return templates.TemplateResponse("email_confirmed.html", {"request": request, "user": None})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login")
|
||||||
|
def login_form(request: Request, user: User | None = Depends(get_current_user)):
|
||||||
|
if user:
|
||||||
|
return RedirectResponse("/profile", 303)
|
||||||
|
return templates.TemplateResponse("login.html", {"request": request, "user": None})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login_submit(request: Request, db: Session = Depends(get_db)):
|
||||||
|
form = await request.form()
|
||||||
|
data = dict(form)
|
||||||
|
|
||||||
|
errors = validate_login(data)
|
||||||
|
if errors:
|
||||||
|
return templates.TemplateResponse("login.html", {
|
||||||
|
"request": request, "user": None, "errors": errors, "form": data,
|
||||||
|
})
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.email == data["email"].strip()).first()
|
||||||
|
if not user or not verify_password(data["password"], user.password_hash):
|
||||||
|
return templates.TemplateResponse("login.html", {
|
||||||
|
"request": request, "user": None,
|
||||||
|
"errors": ["Неверный email или пароль"], "form": data,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not user.is_email_confirmed:
|
||||||
|
return templates.TemplateResponse("login.html", {
|
||||||
|
"request": request, "user": None,
|
||||||
|
"errors": ["Пожалуйста, подтвердите ваш email"], "form": data,
|
||||||
|
})
|
||||||
|
|
||||||
|
request.session["user_id"] = user.id
|
||||||
|
return RedirectResponse("/profile", 303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logout")
|
||||||
|
def logout(request: Request):
|
||||||
|
request.session.clear()
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
55
web/routes/profile.py
Normal file
55
web/routes/profile.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from fastapi import APIRouter, Request, Depends
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from web.auth import get_current_user
|
||||||
|
from web.database import get_db
|
||||||
|
from web.models import User
|
||||||
|
from web.schemas import validate_profile
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="web/templates")
|
||||||
|
|
||||||
|
|
||||||
|
@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})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profile")
|
||||||
|
async def profile_update(
|
||||||
|
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 = validate_profile(data)
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
existing = db.query(User).filter(
|
||||||
|
User.phone == data["phone"].strip(), User.id != user.id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
errors.append("Пользователь с таким телефоном уже существует")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
return templates.TemplateResponse("profile.html", {
|
||||||
|
"request": request, "user": user, "errors": errors, "form": data,
|
||||||
|
})
|
||||||
|
|
||||||
|
user.first_name = data["first_name"].strip()
|
||||||
|
user.last_name = data["last_name"].strip()
|
||||||
|
user.phone = data["phone"].strip()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return templates.TemplateResponse("profile.html", {
|
||||||
|
"request": request, "user": user, "success": "Профиль обновлен",
|
||||||
|
})
|
||||||
108
web/routes/reset.py
Normal file
108
web/routes/reset.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Depends
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from web.auth import hash_password
|
||||||
|
from web.config import settings
|
||||||
|
from web.database import get_db
|
||||||
|
from web.models import User
|
||||||
|
from web.schemas import validate_reset_password
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="web/templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/forgot-password")
|
||||||
|
def forgot_form(request: Request):
|
||||||
|
return templates.TemplateResponse("forgot_password.html", {"request": request, "user": None})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/forgot-password")
|
||||||
|
async def forgot_submit(request: Request, db: Session = Depends(get_db)):
|
||||||
|
form = await request.form()
|
||||||
|
email = form.get("email", "").strip()
|
||||||
|
|
||||||
|
if email:
|
||||||
|
user = db.query(User).filter(User.email == email).first()
|
||||||
|
if user:
|
||||||
|
token = uuid.uuid4().hex
|
||||||
|
user.password_reset_token = token
|
||||||
|
user.password_reset_expires = datetime.now(timezone.utc) + timedelta(
|
||||||
|
minutes=settings.PASSWORD_RESET_EXPIRE_MINUTES
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
reset_url = f"{settings.BASE_URL}/reset-password?token={token}"
|
||||||
|
print("=" * 40)
|
||||||
|
print("СБРОС ПАРОЛЯ")
|
||||||
|
print(f"Пользователь: {user.email}")
|
||||||
|
print(f"Ссылка: {reset_url}")
|
||||||
|
print(f"Действительна: {settings.PASSWORD_RESET_EXPIRE_MINUTES} мин.")
|
||||||
|
print("=" * 40)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("message.html", {
|
||||||
|
"request": request, "user": None,
|
||||||
|
"title": "Сброс пароля",
|
||||||
|
"message": "Если аккаунт с таким email существует, ссылка для сброса пароля выведена в консоль сервера.",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reset-password")
|
||||||
|
def reset_form(request: Request, token: str, db: Session = Depends(get_db)):
|
||||||
|
user = db.query(User).filter(User.password_reset_token == token).first()
|
||||||
|
if not user or not user.password_reset_expires:
|
||||||
|
return templates.TemplateResponse("message.html", {
|
||||||
|
"request": request, "user": None,
|
||||||
|
"title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
|
||||||
|
})
|
||||||
|
|
||||||
|
if datetime.now(timezone.utc) > user.password_reset_expires.replace(tzinfo=timezone.utc):
|
||||||
|
return templates.TemplateResponse("message.html", {
|
||||||
|
"request": request, "user": None,
|
||||||
|
"title": "Ошибка", "message": "Срок действия ссылки истек.",
|
||||||
|
})
|
||||||
|
|
||||||
|
return templates.TemplateResponse("reset_password.html", {
|
||||||
|
"request": request, "user": None, "token": token,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset-password")
|
||||||
|
async def reset_submit(request: Request, token: str, db: Session = Depends(get_db)):
|
||||||
|
user = db.query(User).filter(User.password_reset_token == token).first()
|
||||||
|
if not user or not user.password_reset_expires:
|
||||||
|
return templates.TemplateResponse("message.html", {
|
||||||
|
"request": request, "user": None,
|
||||||
|
"title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
|
||||||
|
})
|
||||||
|
|
||||||
|
if datetime.now(timezone.utc) > user.password_reset_expires.replace(tzinfo=timezone.utc):
|
||||||
|
return templates.TemplateResponse("message.html", {
|
||||||
|
"request": request, "user": None,
|
||||||
|
"title": "Ошибка", "message": "Срок действия ссылки истек.",
|
||||||
|
})
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
data = dict(form)
|
||||||
|
errors = validate_reset_password(data)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
return templates.TemplateResponse("reset_password.html", {
|
||||||
|
"request": request, "user": None, "token": token, "errors": errors,
|
||||||
|
})
|
||||||
|
|
||||||
|
user.password_hash = hash_password(data["password"])
|
||||||
|
user.password_reset_token = None
|
||||||
|
user.password_reset_expires = None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return templates.TemplateResponse("message.html", {
|
||||||
|
"request": request, "user": None,
|
||||||
|
"title": "Пароль изменен",
|
||||||
|
"message": "Ваш пароль успешно изменен. Теперь вы можете войти.",
|
||||||
|
"link": "/login", "link_text": "Войти",
|
||||||
|
})
|
||||||
52
web/schemas.py
Normal file
52
web/schemas.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def validate_registration(data: dict) -> list[str]:
|
||||||
|
errors = []
|
||||||
|
if not data.get("first_name", "").strip():
|
||||||
|
errors.append("Введите имя")
|
||||||
|
if not data.get("last_name", "").strip():
|
||||||
|
errors.append("Введите фамилию")
|
||||||
|
email = data.get("email", "").strip()
|
||||||
|
if not email or not re.match(r"^[^@]+@[^@]+\.[^@]+$", email):
|
||||||
|
errors.append("Введите корректный email")
|
||||||
|
phone = data.get("phone", "").strip()
|
||||||
|
if not phone or not re.match(r"^\+?[\d\s\-()]{7,20}$", phone):
|
||||||
|
errors.append("Введите корректный телефон")
|
||||||
|
password = data.get("password", "")
|
||||||
|
if len(password) < 8:
|
||||||
|
errors.append("Пароль должен быть не менее 8 символов")
|
||||||
|
if password != data.get("password_confirm", ""):
|
||||||
|
errors.append("Пароли не совпадают")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_login(data: dict) -> list[str]:
|
||||||
|
errors = []
|
||||||
|
if not data.get("email", "").strip():
|
||||||
|
errors.append("Введите email")
|
||||||
|
if not data.get("password", ""):
|
||||||
|
errors.append("Введите пароль")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_reset_password(data: dict) -> list[str]:
|
||||||
|
errors = []
|
||||||
|
password = data.get("password", "")
|
||||||
|
if len(password) < 8:
|
||||||
|
errors.append("Пароль должен быть не менее 8 символов")
|
||||||
|
if password != data.get("password_confirm", ""):
|
||||||
|
errors.append("Пароли не совпадают")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_profile(data: dict) -> list[str]:
|
||||||
|
errors = []
|
||||||
|
if not data.get("first_name", "").strip():
|
||||||
|
errors.append("Введите имя")
|
||||||
|
if not data.get("last_name", "").strip():
|
||||||
|
errors.append("Введите фамилию")
|
||||||
|
phone = data.get("phone", "").strip()
|
||||||
|
if not phone or not re.match(r"^\+?[\d\s\-()]{7,20}$", phone):
|
||||||
|
errors.append("Введите корректный телефон")
|
||||||
|
return errors
|
||||||
215
web/static/style.css
Normal file
215
web/static/style.css
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #F05023;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #F05023;
|
||||||
|
color: #fff;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #0986E2;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
43
web/templates/base.html
Normal file
43
web/templates/base.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}EvoSync{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="container nav-content">
|
||||||
|
<a href="/" class="nav-logo">EvoSync</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
{% if user %}
|
||||||
|
<a href="/profile">Личный кабинет</a>
|
||||||
|
<a href="/logout">Выход</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="/login">Вход</a>
|
||||||
|
<a href="/register">Регистрация</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
{% if errors %}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
{% for error in errors %}
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if success %}
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<p>{{ success }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
web/templates/confirm_email.html
Normal file
10
web/templates/confirm_email.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Подтверждение email — EvoSync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="message-card">
|
||||||
|
<h1>Подтвердите ваш email</h1>
|
||||||
|
<p>Ссылка для подтверждения email выведена в консоль сервера.</p>
|
||||||
|
<p>Скопируйте её и откройте в браузере.</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
10
web/templates/email_confirmed.html
Normal file
10
web/templates/email_confirmed.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Email подтвержден — EvoSync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="message-card">
|
||||||
|
<h1>Email подтвержден!</h1>
|
||||||
|
<p>Ваш email успешно подтвержден. Теперь вы можете войти в систему.</p>
|
||||||
|
<p><a href="/login">Войти</a></p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
21
web/templates/forgot_password.html
Normal file
21
web/templates/forgot_password.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Забыли пароль — EvoSync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="form-card">
|
||||||
|
<h1>Забыли пароль?</h1>
|
||||||
|
<p style="font-size: 14px; color: #555; margin-bottom: 20px;">
|
||||||
|
Введите email, указанный при регистрации.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/forgot-password">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" name="email" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Отправить ссылку для сброса</button>
|
||||||
|
</form>
|
||||||
|
<div class="form-links">
|
||||||
|
<a href="/login">Вернуться ко входу</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
23
web/templates/login.html
Normal file
23
web/templates/login.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Вход — EvoSync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="form-card">
|
||||||
|
<h1>Вход</h1>
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" name="email" value="{{ form.email if form else '' }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Пароль</label>
|
||||||
|
<input type="password" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Войти</button>
|
||||||
|
</form>
|
||||||
|
<div class="form-links">
|
||||||
|
<a href="/forgot-password">Забыли пароль?</a><br>
|
||||||
|
<a href="/register">Зарегистрироваться</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
12
web/templates/message.html
Normal file
12
web/templates/message.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ title }} — EvoSync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="message-card">
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
{% if link %}
|
||||||
|
<p><a href="{{ link }}">{{ link_text }}</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
30
web/templates/profile.html
Normal file
30
web/templates/profile.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Личный кабинет — EvoSync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="profile-card">
|
||||||
|
<h1>Личный кабинет</h1>
|
||||||
|
<form method="post" action="/profile">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="first_name">Имя</label>
|
||||||
|
<input type="text" id="first_name" name="first_name"
|
||||||
|
value="{{ form.first_name if form else user.first_name }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="last_name">Фамилия</label>
|
||||||
|
<input type="text" id="last_name" name="last_name"
|
||||||
|
value="{{ form.last_name if form else user.last_name }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" value="{{ user.email }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="phone">Телефон</label>
|
||||||
|
<input type="tel" id="phone" name="phone"
|
||||||
|
value="{{ form.phone if form else user.phone }}">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
38
web/templates/register.html
Normal file
38
web/templates/register.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Регистрация — EvoSync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="form-card">
|
||||||
|
<h1>Регистрация</h1>
|
||||||
|
<form method="post" action="/register">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="first_name">Имя</label>
|
||||||
|
<input type="text" id="first_name" name="first_name" value="{{ form.first_name if form else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="last_name">Фамилия</label>
|
||||||
|
<input type="text" id="last_name" name="last_name" value="{{ form.last_name if form else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email *</label>
|
||||||
|
<input type="email" id="email" name="email" value="{{ form.email if form else '' }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="phone">Телефон *</label>
|
||||||
|
<input type="tel" id="phone" name="phone" value="{{ form.phone if form else '' }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Пароль *</label>
|
||||||
|
<input type="password" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password_confirm">Подтверждение пароля *</label>
|
||||||
|
<input type="password" id="password_confirm" name="password_confirm" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Зарегистрироваться</button>
|
||||||
|
</form>
|
||||||
|
<div class="form-links">
|
||||||
|
<a href="/login">Уже есть аккаунт? Войти</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
19
web/templates/reset_password.html
Normal file
19
web/templates/reset_password.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Новый пароль — EvoSync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="form-card">
|
||||||
|
<h1>Новый пароль</h1>
|
||||||
|
<form method="post" action="/reset-password?token={{ token }}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Новый пароль</label>
|
||||||
|
<input type="password" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password_confirm">Подтверждение пароля</label>
|
||||||
|
<input type="password" id="password_confirm" name="password_confirm" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Сменить пароль</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user