E3 UI: админка пользователей — создание, роли, подразделения, блокировка, сброс пароля
- UsersAdmin.jsx (/users): список с фильтрами (подразделение/поиск),
форма создания (логин/email/временный пароль/ФИО/должность/юнит/роль),
inline-смена роли и подразделения, отключение/включение, сброс пароля
(все сессии отзываются), статусы цветом. must_change_password
подсвечивается пользователю при первом входе.
- Доступ: бэкенд require_permission('manage_users') — РЦУ РЧС и ОУМЧС;
скоуп координатора фильтрует список по подразделению (кросс-областной
PATCH отбивается 403 на бэкенде).
- Кнопка «Пользователи» из дашборда.
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user