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:
mguschin
2026-03-04 22:01:58 +03:00
parent 053fa62395
commit 951c12a208
26 changed files with 929 additions and 0 deletions

8
.env.example Normal file
View 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
View File

@@ -13,3 +13,6 @@ run/test.log
vk/whitelist
logs/
passwords.txt
.env
__pycache__/
*.pyc

10
Dockerfile.web Normal file
View 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
View 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
View 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
View File

23
web/auth.py Normal file
View 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
View 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
View 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
View 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
View 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
View File

123
web/routes/auth.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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>

View 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 %}

View 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 %}

View 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
View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}