// ════════════════════════════════════════════════════════════════
// /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 (
);
}
// ── Invite created confirmation ─────────────────────────────────
function InviteCreatedModal({ invite, onClose }) {
return (
e.stopPropagation()} className="sdh-fade" style={{ ...modalCardStyle, maxWidth: 560 }}>
приглашение создано
Скопируйте ссылку и передайте
Действительна 7 дней. После — нужно создать новое приглашение. Адресат: {invite.email}, роль .
email уведомление: отключено (deferred · v1.1)
);
}
const selectStyle = {
background: 'var(--surface-elevated)',
border: '1px solid var(--border-default)',
borderRadius: 'var(--radius-md)',
padding: '7px 28px 7px 12px',
fontSize: 13, color: 'var(--text-primary)',
appearance: 'none',
backgroundImage: `url("data:image/svg+xml;utf8,")`,
backgroundRepeat: 'no-repeat', backgroundPosition: 'right 10px center',
};
const modalShellStyle = {
position: 'fixed', inset: 0, background: 'var(--surface-overlay)', zIndex: 100,
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
padding: 'var(--space-8) var(--space-4)', overflowY: 'auto',
backdropFilter: 'blur(2px)',
};
const modalCardStyle = {
background: 'var(--surface-elevated)',
borderRadius: 'var(--radius-2xl)',
border: '1px solid var(--border-subtle)',
maxWidth: 500, width: '100%',
padding: 'var(--space-8)',
boxShadow: 'var(--shadow-xl)',
position: 'relative',
};
window.UsersTable = UsersTable;
window.InviteModal = InviteModal;
window.InviteCreatedModal = InviteCreatedModal;
window.modalShellStyle = modalShellStyle;
window.modalCardStyle = modalCardStyle;
window.selectStyle = selectStyle;