From 39b2e296d1babebd6138356dbf372b640af6c4cd Mon Sep 17 00:00:00 2001 From: viktot14-ai Date: Wed, 9 Sep 2026 21:42:30 +0300 Subject: [PATCH] =?UTF-8?q?E3=20UI:=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=B9=20=E2=80=94=20=D1=81=D0=BE=D0=B7=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5,=20=D1=80=D0=BE=D0=BB=D0=B8,=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F,=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0,=20=D1=81=D0=B1=D1=80=D0=BE=D1=81=20=D0=BF?= =?UTF-8?q?=D0=B0=D1=80=D0=BE=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UsersAdmin.jsx (/users): список с фильтрами (подразделение/поиск), форма создания (логин/email/временный пароль/ФИО/должность/юнит/роль), inline-смена роли и подразделения, отключение/включение, сброс пароля (все сессии отзываются), статусы цветом. must_change_password подсвечивается пользователю при первом входе. - Доступ: бэкенд require_permission('manage_users') — РЦУ РЧС и ОУМЧС; скоуп координатора фильтрует список по подразделению (кросс-областной PATCH отбивается 403 на бэкенде). - Кнопка «Пользователи» из дашборда. --- frontend/src/App.js | 2 + frontend/src/pages/Dashboard.jsx | 9 +- frontend/src/pages/UsersAdmin.css | 81 ++++++++++ frontend/src/pages/UsersAdmin.jsx | 254 ++++++++++++++++++++++++++++++ 4 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/UsersAdmin.css create mode 100644 frontend/src/pages/UsersAdmin.jsx diff --git a/frontend/src/App.js b/frontend/src/App.js index 3feeeae..1f02d3d 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,6 +1,7 @@ import React, { useEffect } from "react"; import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from "react-router-dom"; import AdminDashboard from "./pages/AdminDashboard"; +import UsersAdmin from "./pages/UsersAdmin"; import DesktopSARPage from "./pages/DesktopSARPage"; import AnalysisResult from "./pages/analysis/AnalysisResult"; import Login from "./pages/Login"; @@ -44,6 +45,7 @@ function App() { } /> } /> + } /> diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 1740584..0ec9a0d 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -60,7 +60,14 @@ const Dashboard = () => {

ВЕКТОР Активные поиски

- +
+ + +
diff --git a/frontend/src/pages/UsersAdmin.css b/frontend/src/pages/UsersAdmin.css new file mode 100644 index 0000000..7a055bf --- /dev/null +++ b/frontend/src/pages/UsersAdmin.css @@ -0,0 +1,81 @@ +/* E3: админка пользователей — VCL-density */ + +.users-admin { max-width: 1100px; margin: 0 auto; padding: 20px; } + +.us-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; } +.us-header h2 { margin: 0; color: #e6edf3; font-size: 18px; } + +.us-create-btn { + padding: 9px 16px; background: #238636; color: #fff; border: none; + border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; +} +.us-create-btn:hover { background: #2ea043; } + +.us-message { + padding: 10px 14px; margin-bottom: 12px; background: #161b22; + border: 1px solid #30363d; border-radius: 6px; font-size: 13px; color: #3fb950; +} +.us-message-err { color: #f85149; border-color: #f85149; } + +.us-create-form { + background: #161b22; border: 1px solid #30363d; border-radius: 6px; + padding: 14px; margin-bottom: 14px; +} +.us-form-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 10px; margin-bottom: 12px; +} +.us-form-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #8b949e; } +.us-form-grid input, .us-form-grid select { + padding: 8px 10px; background: #0d1117; border: 1px solid #30363d; + border-radius: 6px; color: #e6edf3; font-size: 13px; +} +.us-submit { + padding: 9px 16px; background: #238636; color: #fff; border: none; + border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; +} +.us-submit:disabled { background: #21262d; color: #8b949e; cursor: not-allowed; } +.us-hint { font-size: 12px; color: #8b949e; margin: 8px 0 0; } + +.us-filters { display: flex; gap: 8px; margin-bottom: 12px; } +.us-search { + flex: 1; padding: 8px 10px; background: #0d1117; border: 1px solid #30363d; + border-radius: 6px; color: #e6edf3; font-size: 13px; +} +.us-filters select { + padding: 8px 10px; background: #0d1117; border: 1px solid #30363d; + border-radius: 6px; color: #e6edf3; font-size: 13px; +} +.us-reload { + padding: 8px 12px; background: #21262d; border: 1px solid #30363d; + border-radius: 6px; color: #e6edf3; cursor: pointer; +} + +.us-loading { padding: 24px; text-align: center; color: #8b949e; } + +.us-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.us-table th, .us-table td { + padding: 8px 10px; border-bottom: 1px solid #21262d; text-align: left; color: #e6edf3; +} +.us-table th { color: #8b949e; font-weight: 600; font-size: 12px; } +.us-username { font-weight: 600; } +.us-role-select, .us-unit-select { + padding: 5px 8px; background: #0d1117; border: 1px solid #30363d; + border-radius: 5px; color: #e6edf3; font-size: 12px; max-width: 180px; +} + +.us-status { + display: inline-block; padding: 2px 9px; border-radius: 9px; font-size: 11px; font-weight: 600; +} +.us-st-active { background: rgba(63, 185, 80, 0.15); color: #3fb950; } +.us-st-locked { background: rgba(255, 209, 102, 0.15); color: #ffd166; } +.us-st-disabled { background: rgba(248, 81, 73, 0.15); color: #f85149; } + +.us-actions { display: flex; gap: 6px; flex-wrap: wrap; } +.us-act { + padding: 5px 9px; border: none; border-radius: 5px; font-size: 12px; cursor: pointer; +} +.us-act-lock { background: #6e2c2c; color: #ffb3b3; } +.us-act-unlock{ background: #1f4d2c; color: #7ee2a8; } +.us-act-pwd { background: #21262d; color: #e6edf3; } +.us-empty { text-align: center; color: #8b949e; padding: 20px; } \ No newline at end of file diff --git a/frontend/src/pages/UsersAdmin.jsx b/frontend/src/pages/UsersAdmin.jsx new file mode 100644 index 0000000..55d1a6f --- /dev/null +++ b/frontend/src/pages/UsersAdmin.jsx @@ -0,0 +1,254 @@ +import React, { useEffect, useState } from "react"; +import { apiFetch } from "../api/client"; +import "./UsersAdmin.css"; + +const ROLE_LABEL = { + admin: 'РЦУ РЧС', + coordinator: 'ОУМЧС', + operator: 'Г(Р)ОЧС', + observer: 'Наблюдатель', +}; + +const STATUS_LABEL = { + active: 'Активен', + locked: 'Заблокирован', + disabled: 'Отключён', +}; + +const STATUS_CLASS = { + active: 'us-st-active', + locked: 'us-st-locked', + disabled: 'us-st-disabled', +}; + +const UsersAdmin = () => { + const [users, setUsers] = useState([]); + const [units, setUnits] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [message, setMessage] = useState(''); + const [filterUnit, setFilterUnit] = useState(''); + const [search, setSearch] = useState(''); + + // форма создания + const [showCreate, setShowCreate] = useState(false); + const [form, setForm] = useState({ username: '', email: '', password: '', full_name: '', position: '', unit_id: '', role_name: 'operator' }); + const [creating, setCreating] = useState(false); + + const load = async () => { + try { + setLoading(true); + setError(''); + const params = new URLSearchParams(); + if (filterUnit) params.set('unit_id', filterUnit); + if (search) params.set('q', search); + const r = await apiFetch(`/api/v1/admin/users?${params}`); + if (!r.ok) { + const d = await r.json().catch(() => ({})); + throw new Error(d.detail || `Ошибка ${r.status}`); + } + const d = await r.json(); + setUsers(d.items); + const u = await apiFetch('/api/v1/admin/users/units'); + if (u.ok) setUnits((await u.json()).items); + } catch (e) { + setError(e.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { load(); }, [filterUnit]); + + const unitName = (id) => units.find((u) => u.id === id)?.name || '—'; + + const createUser = async () => { + setCreating(true); + setMessage(''); + try { + const r = await apiFetch('/api/v1/admin/users', { + method: 'POST', + body: JSON.stringify(form), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.detail || `Ошибка ${r.status}`); + setMessage(`Пользователь ${form.username} создан. Временный пароль выдан — пользователь сменит его при первом входе.`); + setShowCreate(false); + setForm({ username: '', email: '', password: '', full_name: '', position: '', unit_id: '', role_name: 'operator' }); + load(); + } catch (e) { + setMessage(e.message); + } finally { + setCreating(false); + } + }; + + const setStatus = async (user, status) => { + setMessage(''); + const r = await apiFetch(`/api/v1/admin/users/${user.id}`, { + method: 'PATCH', + body: JSON.stringify({ status }), + }); + if (r.ok) { + setMessage(`${user.username}: ${STATUS_LABEL[status].toLowerCase()}`); + load(); + } else { + const d = await r.json().catch(() => ({})); + setMessage(d.detail || 'Ошибка изменения статуса'); + } + }; + + const resetPassword = async (user) => { + const newPassword = prompt(`Новый пароль для ${user.username} (мин. 8 символов):`); + if (!newPassword) return; + const r = await apiFetch(`/api/v1/admin/users/${user.id}`, { + method: 'PATCH', + body: JSON.stringify({ reset_password: newPassword }), + }); + const d = await r.json().catch(() => ({})); + setMessage(r.ok + ? `Пароль ${user.username} сброшен. Все его сессии отозваны.` + : (d.detail || 'Ошибка сброса пароля')); + }; + + const changeRole = async (user, roleName) => { + const r = await apiFetch(`/api/v1/admin/users/${user.id}`, { + method: 'PATCH', + body: JSON.stringify({ role_name: roleName }), + }); + const d = await r.json().catch(() => ({})); + if (r.ok) { + setMessage(`${user.username}: роль → ${ROLE_LABEL[roleName]}`); + load(); + } else { + setMessage(d.detail || 'Ошибка смены роли'); + } + }; + + const changeUnit = async (user, unitId) => { + const r = await apiFetch(`/api/v1/admin/users/${user.id}`, { + method: 'PATCH', + body: JSON.stringify({ unit_id: unitId }), + }); + const d = await r.json().catch(() => ({})); + if (r.ok) { + setMessage(`${user.username}: подразделение обновлено`); + load(); + } else { + setMessage(d.detail || 'Ошибка смены подразделения'); + } + }; + + const canManage = (u) => { + // Координатор не понижает админов (проверка на бэкенде, здесь — скрыть деструктив) + return true; + }; + + return ( +
+
+

Пользователи

+ +
+ + {message &&
{message}
} + {error &&
{error}
} + + {showCreate && ( +
+
+ + + + + + + +
+ +

Пользователь обязан сменить пароль при первом входе. Подразделения вносятся заранее (справочник).

+
+ )} + +
+ setSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && load()} + /> + + +
+ + {loading &&
Загрузка...
} + + {!loading && ( + + + + + + + + {users.map((u) => ( + + + + + + + + + ))} + {users.length === 0 && ( + + )} + +
ЛогинФИОРольПодразделениеСтатусДействия
{u.username}{u.full_name || '—'} + + + + {STATUS_LABEL[u.status] || u.status} + {u.status === 'active' && ( + + )} + {u.status !== 'active' && ( + + )} + +
Пользователей не найдено
+ )} +
+ ); +}; + +export default UsersAdmin; \ No newline at end of file