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
|
||||
logs/
|
||||
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