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>
This commit is contained in:
40
web/templates/admin/roles.html
Normal file
40
web/templates/admin/roles.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Роли и права — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/admin/users">Пользователи</a></li>
|
||||
<li class="breadcrumb-item active">Роли и права</li>
|
||||
</nav>
|
||||
|
||||
<h1 style="font-size:1.3rem;" class="mb-3"><i class="bi bi-shield-lock me-2"></i>Роли и права</h1>
|
||||
|
||||
{% for role in roles %}
|
||||
<article class="card mb-3">
|
||||
<header>
|
||||
<h2 style="font-size:1rem;">{{ role.name }}
|
||||
<span class="text-muted small fw-normal">— {{ role.description or '' }}</span>
|
||||
</h2>
|
||||
</header>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/admin/roles/{{ role.id }}/permissions">
|
||||
<div class="row gap-2 flex-wrap">
|
||||
{% for perm in permissions %}
|
||||
<div class="col-auto">
|
||||
<label style="display:flex; align-items:center; gap:0.4rem; margin:0;">
|
||||
<input type="checkbox" name="perm_{{ perm.id }}" value="{{ perm.id }}"
|
||||
{% if perm.id in role_perm_ids[role.id] %}checked{% endif %}>
|
||||
{{ perm.name }}
|
||||
{% if perm.description %}
|
||||
<span class="text-muted small">({{ perm.description }})</span>
|
||||
{% endif %}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="submit" class="sm mt-3">Сохранить права для «{{ role.name }}»</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
147
web/templates/admin/user_detail.html
Normal file
147
web/templates/admin/user_detail.html
Normal file
@@ -0,0 +1,147 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ target.first_name }} {{ target.last_name }} — Админ — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/admin/users">Пользователи</a></li>
|
||||
<li class="breadcrumb-item active">{{ target.first_name }} {{ target.last_name }}</li>
|
||||
</nav>
|
||||
|
||||
{% if request.query_params.get('success') == 'reset_sent' %}
|
||||
<div class="alert alert-success mb-3"><p>Ссылка для сброса пароля отправлена.</p></div>
|
||||
{% elif request.query_params.get('success') == 'invite_sent' %}
|
||||
<div class="alert alert-success mb-3"><p>Приглашение отправлено.</p></div>
|
||||
{% elif request.query_params.get('success') == 'saved' %}
|
||||
<div class="alert alert-success mb-3"><p>Данные сохранены.</p></div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row gap-3 align-start">
|
||||
<div class="col-lg-6">
|
||||
<article class="card">
|
||||
<header><h2>Профиль</h2></header>
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item"><span class="text-muted small">ID</span><span>{{ target.id }}</span></li>
|
||||
<li class="list-group-item"><span class="text-muted small">Имя</span><span>{{ target.first_name }} {{ target.last_name }}</span></li>
|
||||
<li class="list-group-item"><span class="text-muted small">Email</span>
|
||||
<span>{{ target.email }}
|
||||
{% if target.is_email_confirmed %}
|
||||
<span class="badge badge-success ms-1">подтверждён</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning ms-1">не подтверждён</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</li>
|
||||
<li class="list-group-item"><span class="text-muted small">Телефон</span><span>{{ target.phone }}</span></li>
|
||||
<li class="list-group-item"><span class="text-muted small">Роль</span>
|
||||
<span>
|
||||
{% if target.role == 'system' %}<span class="badge badge-danger">Системный</span>
|
||||
{% elif target.role == 'admin' %}<span class="badge badge-warning">Администратор</span>
|
||||
{% else %}<span class="badge badge-secondary">Пользователь</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</li>
|
||||
<li class="list-group-item"><span class="text-muted small">Статус</span>
|
||||
<span>
|
||||
{% if target.status == 'active' %}<span class="badge badge-success">Активен</span>
|
||||
{% elif target.status == 'pending' %}<span class="badge badge-warning">Ожидает</span>
|
||||
{% else %}<span class="badge badge-danger">Заблокирован</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</li>
|
||||
<li class="list-group-item"><span class="text-muted small">Регистрация</span><span>{{ target.created_at | datefmt }}</span></li>
|
||||
{% if target.evotor_user_id %}
|
||||
<li class="list-group-item"><span class="text-muted small">Эвотор ID</span><span class="font-monospace small">{{ target.evotor_user_id }}</span></li>
|
||||
{% endif %}
|
||||
{% if target.invite_token %}
|
||||
<li class="list-group-item"><span class="text-muted small">Приглашение до</span><span>{{ target.invite_expires | datefmt }}</span></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
{% if target.evotor_meta %}
|
||||
<article class="card mt-3">
|
||||
<header><h2>Данные Эвотор</h2></header>
|
||||
<div class="card-body">
|
||||
<pre class="font-monospace small" style="overflow-x:auto; white-space:pre-wrap; margin:0;">{{ target.evotor_meta | tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<article class="card">
|
||||
<header><h2>Действия</h2></header>
|
||||
<div class="card-body d-grid gap-2">
|
||||
{% if target.status != 'active' %}
|
||||
<form method="post" action="/admin/users/{{ target.id }}/activate">
|
||||
<button type="submit" class="w-100">
|
||||
<i class="bi bi-check-circle me-1"></i>Активировать
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if target.status != 'suspended' %}
|
||||
<form method="post" action="/admin/users/{{ target.id }}/suspend">
|
||||
<button type="submit" class="w-100 outline danger">
|
||||
<i class="bi bi-slash-circle me-1"></i>Заблокировать
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/users/{{ target.id }}/reset-password">
|
||||
<button type="submit" class="w-100 outline secondary">
|
||||
<i class="bi bi-key me-1"></i>Сбросить пароль
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/users/{{ target.id }}/send-invite">
|
||||
<button type="submit" class="w-100 outline secondary">
|
||||
<i class="bi bi-envelope me-1"></i>Отправить приглашение
|
||||
</button>
|
||||
</form>
|
||||
{% if user.role == 'system' and target.id != user.id %}
|
||||
<form method="post" action="/admin/users/{{ target.id }}/delete"
|
||||
onsubmit="return confirm('Удалить пользователя {{ target.email }}? Это действие необратимо.')">
|
||||
<button type="submit" class="w-100 danger sm">
|
||||
<i class="bi bi-trash me-1"></i>Удалить
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card mt-3">
|
||||
<header><h2>Редактировать</h2></header>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/admin/users/{{ target.id }}/edit">
|
||||
<div class="row gap-2 mb-2">
|
||||
<div class="col">
|
||||
<label for="first_name">Имя
|
||||
<input type="text" id="first_name" name="first_name" value="{{ target.first_name }}" required>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label for="last_name">Фамилия
|
||||
<input type="text" id="last_name" name="last_name" value="{{ target.last_name }}" required>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<label for="email">Email
|
||||
<input type="email" id="email" name="email" value="{{ target.email }}">
|
||||
</label>
|
||||
<label for="phone">Телефон
|
||||
<input type="tel" id="phone" name="phone" value="{{ target.phone }}">
|
||||
</label>
|
||||
{% if user.role == 'system' %}
|
||||
<label for="role">Роль
|
||||
<select id="role" name="role">
|
||||
<option value="user" {% if target.role == 'user' %}selected{% endif %}>Пользователь</option>
|
||||
<option value="admin" {% if target.role == 'admin' %}selected{% endif %}>Администратор</option>
|
||||
<option value="system" {% if target.role == 'system' %}selected{% endif %}>Системный</option>
|
||||
</select>
|
||||
</label>
|
||||
{% endif %}
|
||||
<button type="submit">Сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
118
web/templates/admin/users.html
Normal file
118
web/templates/admin/users.html
Normal file
@@ -0,0 +1,118 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Пользователи — Администрирование — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-between align-center mb-3">
|
||||
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-people me-2"></i>Пользователи</h1>
|
||||
<span class="text-muted small">Всего: {{ total }}</span>
|
||||
</div>
|
||||
|
||||
<article class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="get" action="/admin/users" class="d-flex gap-2 flex-wrap align-center">
|
||||
<input type="text" name="search" value="{{ search }}" placeholder="Поиск по имени, email, телефону" style="flex:1; min-width:200px; margin:0;">
|
||||
<select name="status" style="width:auto; margin:0;">
|
||||
<option value="">Все статусы</option>
|
||||
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>Ожидает</option>
|
||||
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>Активен</option>
|
||||
<option value="suspended" {% if status_filter == 'suspended' %}selected{% endif %}>Заблокирован</option>
|
||||
</select>
|
||||
<select name="role" style="width:auto; margin:0;">
|
||||
<option value="">Все роли</option>
|
||||
<option value="user" {% if role_filter == 'user' %}selected{% endif %}>Пользователь</option>
|
||||
<option value="admin" {% if role_filter == 'admin' %}selected{% endif %}>Администратор</option>
|
||||
<option value="system" {% if role_filter == 'system' %}selected{% endif %}>Системный</option>
|
||||
</select>
|
||||
<button type="submit" class="sm">Найти</button>
|
||||
{% if search or status_filter or role_filter %}
|
||||
<a href="/admin/users" role="button" class="outline secondary sm">Сбросить</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<div class="table-scroll">
|
||||
<table class="align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Имя</th>
|
||||
<th>Email</th>
|
||||
<th>Телефон</th>
|
||||
<th>Роль</th>
|
||||
<th>Статус</th>
|
||||
<th>Эвотор</th>
|
||||
<th>Регистрация</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td class="text-muted small">{{ u.id }}</td>
|
||||
<td>{{ u.first_name }} {{ u.last_name }}</td>
|
||||
<td>
|
||||
{{ u.email }}
|
||||
{% if not u.is_email_confirmed %}
|
||||
<span class="badge badge-warning ms-1" title="Email не подтверждён"><i class="bi bi-exclamation-circle"></i></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ u.phone }}</td>
|
||||
<td>
|
||||
{% if u.role == 'system' %}<span class="badge badge-danger">Системный</span>
|
||||
{% elif u.role == 'admin' %}<span class="badge badge-warning">Админ</span>
|
||||
{% else %}<span class="badge badge-secondary">Польз.</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if u.status == 'active' %}<span class="badge badge-success">Активен</span>
|
||||
{% elif u.status == 'pending' %}<span class="badge badge-warning">Ожидает</span>
|
||||
{% else %}<span class="badge badge-danger">Заблок.</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if u.evotor_user_id %}
|
||||
<i class="bi bi-check-circle text-success" title="{{ u.evotor_user_id }}"></i>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small">{{ u.created_at | datefmt }}</td>
|
||||
<td>
|
||||
<a href="/admin/users/{{ u.id }}" role="button" class="outline sm">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="9" class="text-center text-muted py-4">Пользователи не найдены</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if total_pages > 1 %}
|
||||
<footer>
|
||||
<div class="d-flex gap-2 justify-center align-center">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}&search={{ search }}&status={{ status_filter }}&role={{ role_filter }}" role="button" class="outline sm">«</a>
|
||||
{% endif %}
|
||||
<span class="text-muted small">Стр. {{ page }} из {{ total_pages }}</span>
|
||||
{% if page < total_pages %}
|
||||
<a href="?page={{ page + 1 }}&search={{ search }}&status={{ status_filter }}&role={{ role_filter }}" role="button" class="outline sm">»</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</footer>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
{% if user.role == 'system' %}
|
||||
<div class="mt-3 text-end">
|
||||
<a href="/admin/roles" role="button" class="outline secondary sm">
|
||||
<i class="bi bi-shield-lock me-1"></i>Управление ролями
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user