E3 UI: админка пользователей — создание, роли, подразделения, блокировка, сброс пароля

- UsersAdmin.jsx (/users): список с фильтрами (подразделение/поиск),
  форма создания (логин/email/временный пароль/ФИО/должность/юнит/роль),
  inline-смена роли и подразделения, отключение/включение, сброс пароля
  (все сессии отзываются), статусы цветом. must_change_password
  подсвечивается пользователю при первом входе.
- Доступ: бэкенд require_permission('manage_users') — РЦУ РЧС и ОУМЧС;
  скоуп координатора фильтрует список по подразделению (кросс-областной
  PATCH отбивается 403 на бэкенде).
- Кнопка «Пользователи» из дашборда.
This commit is contained in:
2026-09-09 21:42:30 +03:00
parent cc2f597bf2
commit 39b2e296d1
4 changed files with 345 additions and 1 deletions
+2
View File
@@ -1,6 +1,7 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from "react-router-dom"; import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from "react-router-dom";
import AdminDashboard from "./pages/AdminDashboard"; import AdminDashboard from "./pages/AdminDashboard";
import UsersAdmin from "./pages/UsersAdmin";
import DesktopSARPage from "./pages/DesktopSARPage"; import DesktopSARPage from "./pages/DesktopSARPage";
import AnalysisResult from "./pages/analysis/AnalysisResult"; import AnalysisResult from "./pages/analysis/AnalysisResult";
import Login from "./pages/Login"; import Login from "./pages/Login";
@@ -44,6 +45,7 @@ function App() {
<Route path="/analysis/:caseId" element={<ProtectedRoute><AnalysisResult /></ProtectedRoute>} /> <Route path="/analysis/:caseId" element={<ProtectedRoute><AnalysisResult /></ProtectedRoute>} />
<Route path="/admin/*" element={<ProtectedRoute roles={["admin"]}><AdminDashboard /></ProtectedRoute>} /> <Route path="/admin/*" element={<ProtectedRoute roles={["admin"]}><AdminDashboard /></ProtectedRoute>} />
<Route path="/users" element={<ProtectedRoute><UsersAdmin /></ProtectedRoute>} />
</Routes> </Routes>
</ThemeProvider> </ThemeProvider>
</AuthProvider> </AuthProvider>
+7
View File
@@ -60,7 +60,14 @@ const Dashboard = () => {
<div className="dashboard"> <div className="dashboard">
<div className="dash-header"> <div className="dash-header">
<h1><span className="logo">ВЕКТОР</span> Активные поиски</h1> <h1><span className="logo">ВЕКТОР</span> Активные поиски</h1>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="dash-new-btn" onClick={startNewSearch}>+ Новый поиск</button> <button className="dash-new-btn" onClick={startNewSearch}>+ Новый поиск</button>
<button
className="dash-new-btn"
style={{ background: '#21262d' }}
onClick={() => navigate('/users')}
>Пользователи</button>
</div>
</div> </div>
<div className="dash-counters"> <div className="dash-counters">
+81
View File
@@ -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; }
+254
View File
@@ -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 (
<div className="users-admin">
<div className="us-header">
<h2>Пользователи</h2>
<button className="us-create-btn" onClick={() => setShowCreate(!showCreate)}>
{showCreate ? '— Отмена' : '+ Новый пользователь'}
</button>
</div>
{message && <div className={`us-message ${message.includes('Ошибка') || message.includes('не найден') ? 'us-message-err' : ''}`}>{message}</div>}
{error && <div className="error-banner">{error}</div>}
{showCreate && (
<div className="us-create-form">
<div className="us-form-grid">
<label>Логин *<input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} /></label>
<label>Email *<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></label>
<label>Временный пароль * (мин. 8)<input value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} /></label>
<label>ФИО<input value={form.full_name} onChange={(e) => setForm({ ...form, full_name: e.target.value })} /></label>
<label>Должность<input value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} /></label>
<label>Подразделение
<select value={form.unit_id} onChange={(e) => setForm({ ...form, unit_id: e.target.value })}>
<option value="">— не выбрано —</option>
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</label>
<label>Роль
<select value={form.role_name} onChange={(e) => setForm({ ...form, role_name: e.target.value })}>
<option value="operator">Г(Р)ОЧС — оператор</option>
<option value="coordinator">ОУМЧС — координатор</option>
<option value="observer">Наблюдатель</option>
<option value="admin">РЦУ РЧС — администратор</option>
</select>
</label>
</div>
<button className="us-submit" onClick={createUser} disabled={creating || !form.username || !form.email || !form.password}>
{creating ? 'Создание...' : 'Создать пользователя'}
</button>
<p className="us-hint">Пользователь обязан сменить пароль при первом входе. Подразделения вносятся заранее (справочник).</p>
</div>
)}
<div className="us-filters">
<input
className="us-search"
placeholder="Поиск по логину/ФИО..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && load()}
/>
<select value={filterUnit} onChange={(e) => setFilterUnit(e.target.value)}>
<option value="">Все подразделения</option>
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
<button className="us-reload" onClick={load}>↻</button>
</div>
{loading && <div className="us-loading">Загрузка...</div>}
{!loading && (
<table className="us-table">
<thead>
<tr>
<th>Логин</th><th>ФИО</th><th>Роль</th><th>Подразделение</th><th>Статус</th><th>Действия</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td className="us-username">{u.username}</td>
<td>{u.full_name || '—'}</td>
<td>
<select value={u.role} onChange={(e) => changeRole(u, e.target.value)} className="us-role-select">
<option value="operator">{ROLE_LABEL.operator}</option>
<option value="coordinator">{ROLE_LABEL.coordinator}</option>
<option value="observer">{ROLE_LABEL.observer}</option>
<option value="admin">{ROLE_LABEL.admin}</option>
</select>
</td>
<td>
<select value={u.unit_id || ''} onChange={(e) => changeUnit(u, e.target.value)} className="us-unit-select">
<option value="">—</option>
{units.map((un) => <option key={un.id} value={un.id}>{un.name}</option>)}
</select>
</td>
<td><span className={`us-status ${STATUS_CLASS[u.status] || ''}`}>{STATUS_LABEL[u.status] || u.status}</span></td>
<td className="us-actions">
{u.status === 'active' && (
<button className="us-act us-act-lock" onClick={() => setStatus(u, 'disabled')}>Отключить</button>
)}
{u.status !== 'active' && (
<button className="us-act us-act-unlock" onClick={() => setStatus(u, 'active')}>Включить</button>
)}
<button className="us-act us-act-pwd" onClick={() => resetPassword(u)}>Сброс пароля</button>
</td>
</tr>
))}
{users.length === 0 && (
<tr><td colSpan={6} className="us-empty">Пользователей не найдено</td></tr>
)}
</tbody>
</table>
)}
</div>
);
};
export default UsersAdmin;