// ════════════════════════════════════════════════════════════════ // /admin/users — Users management + InviteModal // ════════════════════════════════════════════════════════════════ function UsersTable({ currentUser, onRoute }) { const A = window.SDH_ADMIN; const [search, setSearch] = React.useState(''); const [roleFilter, setRoleFilter] = React.useState('all'); const [statusFilter, setStatusFilter] = React.useState('all'); const [openInvite, setOpenInvite] = React.useState(false); const [createdInvite, setCreatedInvite] = React.useState(null); const isSuperAdmin = currentUser.role === 'super_admin'; // RBAC: admin не видит super_admin'ов let users = A.users; if (!isSuperAdmin) users = users.filter(u => u.role !== 'super_admin'); if (search) { const q = search.toLowerCase(); users = users.filter(u => u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)); } if (roleFilter !== 'all') users = users.filter(u => u.role === roleFilter); if (statusFilter !== 'all') users = users.filter(u => u.status === statusFilter); const activeCount = A.users.filter(u => u.status === 'active').length; const invitedCount = A.invites.filter(i => i.status === 'pending').length; return (
} />
setSearch(e.target.value)} placeholder="поиск по имени или email" style={{ background: 'var(--surface-elevated)', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-md)', padding: '7px 12px 7px 30px', fontSize: 13, width: 240, color: 'var(--text-primary)', outline: 'none', }} />
роль {A.ROLES.filter(r => isSuperAdmin || r.id !== 'super_admin').map(r => ( ))} } right={ } />
{users.length} пользователей в выборке
пользователь
роль
статус
токен
действия
{users.map(u => ( ))} {users.length === 0 && ( )}
{openInvite && ( setOpenInvite(false)} onCreated={(inv) => { setOpenInvite(false); setCreatedInvite(inv); }} /> )} {createdInvite && ( setCreatedInvite(null)} /> )} ); } function UserRow({ user, currentUser }) { const isSelf = user.id === currentUser.id; const canEditRole = currentUser.role === 'super_admin' && !isSelf; const canRevoke = (currentUser.role === 'super_admin' && !isSelf) || (currentUser.role === 'admin' && !isSelf && ['collaborator', 'viewer', 'agent'].includes(user.role)); return (
{user.name} {isSelf && вы}
{user.email}
{user.note && (
{user.note}
)}
{user.token_expires_at === null ? ∞ бессрочный : user.status === 'expired' ? истёк : user.token_warning ? {user.days_left} дн : {user.days_left} дн}
{user.token_warning && ( )} {canEditRole && ( )} {canRevoke && user.status === 'active' && ( )}
); } // ── Invite modal ──────────────────────────────────────────────── function InviteModal({ currentUser, onClose, onCreated }) { const A = window.SDH_ADMIN; const isSuperAdmin = currentUser.role === 'super_admin'; const availableRoles = A.ROLES.filter(r => { if (r.id === 'super_admin') return false; if (r.id === 'admin' && !isSuperAdmin) return false; return true; }); const [email, setEmail] = React.useState(''); const [role, setRole] = React.useState('collaborator'); const [ttl, setTtl] = React.useState(A.TTL.collaborator.days); const [note, setNote] = React.useState(''); const [error, setError] = React.useState(null); React.useEffect(() => { setTtl(A.TTL[role].days || 365); }, [role]); React.useEffect(() => { function onKey(e) { if (e.key === 'Escape') onClose(); } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); async function submit(e) { e.preventDefault(); if (!email.includes('@')) { setError('Введите корректный email'); return; } setError(null); // API mode if (window.SDH_USE_API && window.SDH_API) { try { const resp = await window.SDH_API.post('/api/admin/invites', { email, role, ttl_days: ttl || undefined, note: note || undefined, }); const newInvite = { id: resp.id, email, role, ttl_days: ttl, note, token: resp.invite_token, status: 'pending', sent_at: new Date().toISOString(), inviter: currentUser.name, url: resp.redeem_url, }; onCreated(newInvite); if (window.SDH_HYDRATE && window.SDH_HYDRATE.refreshInvites) { window.SDH_HYDRATE.refreshInvites(); } } catch (err) { const msg = err && err.status === 403 ? 'Эта роль вам не разрешена (нужен super-admin)' : err && err.status === 409 ? 'Email уже активен в системе' : (err && err.message) || 'Ошибка создания invite'; setError(msg); } return; } // Mock const token = 'inv_' + Math.random().toString(36).slice(2, 8) + Math.random().toString(36).slice(2, 8); const newInvite = { id: 'inv_new', email, role, ttl_days: ttl, note, token, status: 'pending', sent_at: new Date().toISOString(), inviter: currentUser.name, url: `https://analytics.simbiosislab.online/invite/${token}`, }; onCreated(newInvite); } const roleCfg = A.ROLES.find(r => r.id === role); return (
e.stopPropagation()} onSubmit={submit} className="sdh-fade" style={modalCardStyle}> пригласить пользователя

Новое приглашение

Email на этом этапе не отправляется. Создаём ссылку, вы передаёте её получателю через свой канал (Telegram, встреча, ваш почтовый клиент).

setEmail(e.target.value)} placeholder="ivan@example.ru" style={authFieldStyle} />
Роль
{availableRoles.map(r => ( ))}
setTtl(Number(e.target.value))} style={authFieldStyle} />