Files
evo-sync/web/routes/reset.py
mguschin 5ead89e0cf feat: Evotor user lifecycle, RBAC, admin panel
- Receive Evotor webhooks: POST /user/create, /user/verify, /user/token
- Create users in pending status; match to existing users by email/phone
- Send invite link via Celery notification task; user sets password at /invite
- Abstract EmailProvider/SMSProvider with ConsoleEmailProvider default
- Role-based access control: role enum on users + roles/permissions tables
- Admin panel: /admin/users (list, filter, search, paginate), user detail card
  with activate/suspend/reset-password/send-invite/edit/delete actions
- Admin roles management: /admin/roles with per-role permission assignment
- Extend user profile card: role, status, Evotor ID, email confirmation badge
- Auth routes: register, login, logout, confirm-email, forgot/reset password
- Alembic migrations 0002 (full schema + new fields) and 0003 (RBAC + seeds)
- Port Pico CSS + Bootstrap Icons UI from Node.js commit (854c912)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:01:36 +03:00

102 lines
4.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import secrets
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from web.auth.password import hash_password
from web.config import settings
from web.database import get_db
from web.models.user import User
from web.notifications.tasks import send_email_task
from web.templates_env import templates
router = APIRouter()
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(template, ctx)
@router.get("/forgot-password")
async def forgot_get(request: Request):
return _render(request, "forgot_password.html", {"user": None})
@router.post("/forgot-password")
async def forgot_post(request: Request, db: Session = Depends(get_db)):
form = await request.form()
email = str(form.get("email", "")).strip()
user = db.query(User).filter(User.email == email).first()
if user:
token = secrets.token_urlsafe(32)
user.password_reset_token = token
user.password_reset_expires = datetime.utcnow() + timedelta(
minutes=settings.PASSWORD_RESET_EXPIRE_MINUTES
)
db.commit()
reset_url = f"{settings.BASE_URL}/reset-password?token={token}"
html = f'<p>Сброс пароля: <a href="{reset_url}">{reset_url}</a></p>'
send_email_task.delay(user.email, "Сброс пароля — ЭВОСИНК", html)
# Always show same message to prevent user enumeration
return _render(request, "message.html", {
"user": None,
"title": "Ссылка отправлена",
"message": "Если указанный email зарегистрирован, вы получите ссылку для сброса пароля.",
"link": "/login", "link_text": "Войти",
})
@router.get("/reset-password")
async def reset_get(request: Request, db: Session = Depends(get_db)):
token = request.query_params.get("token", "")
user = db.query(User).filter(User.password_reset_token == token).first()
if not user or not user.password_reset_expires or user.password_reset_expires < datetime.utcnow():
return _render(request, "message.html", {
"user": None, "title": "Ссылка недействительна",
"message": "Ссылка для сброса пароля устарела или недействительна.",
"link": "/forgot-password", "link_text": "Запросить новую ссылку",
})
return _render(request, "reset_password.html", {"user": None, "token": token})
@router.post("/reset-password")
async def reset_post(request: Request, db: Session = Depends(get_db)):
token = request.query_params.get("token", "")
form = await request.form()
password = str(form.get("password", ""))
password_confirm = str(form.get("password_confirm", ""))
errors = []
user = db.query(User).filter(User.password_reset_token == token).first()
if not user or not user.password_reset_expires or user.password_reset_expires < datetime.utcnow():
return _render(request, "message.html", {
"user": None, "title": "Ссылка недействительна",
"message": "Ссылка для сброса пароля устарела.",
"link": "/forgot-password", "link_text": "Запросить новую ссылку",
})
if len(password) < 8:
errors.append("Пароль должен содержать минимум 8 символов")
if password != password_confirm:
errors.append("Пароли не совпадают")
if errors:
return _render(request, "reset_password.html", {
"user": None, "token": token, "errors": errors,
})
user.password_hash = hash_password(password)
user.password_reset_token = None
user.password_reset_expires = None
db.commit()
return _render(request, "message.html", {
"user": None, "title": "Пароль изменён",
"message": "Ваш пароль успешно изменён.",
"link": "/login", "link_text": "Войти",
})