Add pilots: invite-only products with per-user RDP slots
New Service.is_pilot flag - a pilot is a Service (type RDP) that: - is excluded from /api/public/services-by-category and /api/request-more-access (admin-granted only, no self-service) - still shows as a locked card on the dashboard for users without access (Доступно по запросу section), but with a По приглашению badge instead of the self-request button/flow - gets its own non-clickable teaser row on the public /login page (logos only, informational) RdpSlot.assigned_user_id (nullable) - pilot slots are bound to one specific user instead of being drawn from the shared pool; regular RDP services are unaffected (field stays NULL, same pool behaviour as before). The /go/ allocator branches on service.is_pilot to pick the caller's assigned slot instead of any free one. Slots release automatically (cleanup_loop) when the owning grant is revoked or expires, and immediately on manual ACL revoke. UserServiceAccess.expires_at (nullable) - per-grant access window, used by pilots so their access can be shorter than the account's own expires_at; NULL (unchanged default) means "follow the account". has_access() and the dashboard's granted/locked split both honour it. Admin UI: "Это пилот" checkbox on the RDP service form, an assign-user dropdown on a pilot's slot table (replaces the occupied-by column), and a per-pilot expiry date field in the user ACL grid. Schema is applied via the existing ensure_schema_compatibility() idempotent ALTER TABLE pattern (no alembic in this project) - no manual migration step needed, it runs at container startup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,11 @@
|
||||
--av-good:#1f9d63; --av-warn:#b5680a; --av-bad:#d3453f;
|
||||
}
|
||||
|
||||
.pilot-badge{
|
||||
display:inline-block;font:700 10px/1 "Ubuntu Mono",monospace;letter-spacing:.04em;
|
||||
color:#fff;background:var(--av-accent);border-radius:4px;padding:2px 5px;vertical-align:middle;margin-left:4px;
|
||||
}
|
||||
|
||||
body.admin-page-v2{
|
||||
background:var(--av-bg) !important; color:var(--av-fg);
|
||||
font-family:"Ubuntu","IBM Plex Sans",system-ui,sans-serif;
|
||||
@@ -259,7 +264,13 @@
|
||||
<div class="list-title">ACL выбранного пользователя</div>
|
||||
<div class="acl-grid">
|
||||
{% for s in services %}
|
||||
<label><input type="checkbox" class="acl_service" value="{{s.id}}" data-stype="{{s.type.value}}" /> {{s.name}} ({{s.slug}})<span class="acl-owner"></span></label>
|
||||
<label>
|
||||
<input type="checkbox" class="acl_service" value="{{s.id}}" data-stype="{{s.type.value}}" {% if s.id in pilot_service_ids %}data-pilot="1" onchange="toggleAclExpiryInput(this)"{% endif %} />
|
||||
{{s.name}} ({{s.slug}}){% if s.id in pilot_service_ids %} <span class="pilot-badge">ПИЛОТ</span>
|
||||
<input type="date" class="acl-expiry" data-service="{{s.id}}" title="Доступ к пилоту истекает (необязательно — иначе по сроку аккаунта)" style="display:none;margin-left:.4rem;width:auto" />
|
||||
{% endif %}
|
||||
<span class="acl-owner"></span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button onclick="saveAclForSelectedUser()">Save ACL</button>
|
||||
@@ -466,10 +477,10 @@
|
||||
<input class="list-search" id="rdp_search" placeholder="Поиск RDP сервиса..." oninput="filterList('rdp_search', '#rdp_list .rdp-item')" />
|
||||
<div class="list-box" id="rdp_list">
|
||||
{% for s in rdp_services %}
|
||||
<button class="list-item service-row rdp-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectRdpService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.warm_pool_size}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}}, {{s.svc_cred_hint|tojson}})'>
|
||||
<button class="list-item service-row rdp-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectRdpService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.warm_pool_size}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}}, {{s.svc_cred_hint|tojson}}, {{(s.id in pilot_service_ids)|tojson}})'>
|
||||
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
|
||||
<div>
|
||||
<div>{{s.name}}</div>
|
||||
<div>{{s.name}}{% if s.id in pilot_service_ids %} <span class="pilot-badge">ПИЛОТ</span>{% endif %}</div>
|
||||
<small>
|
||||
<span class="status-dot status-{{ service_health[s.id].health }}"></span>
|
||||
{{service_health[s.id].health}} | {{service_health[s.id].running}} / {{service_health[s.id].desired}} | active: {{service_health[s.id].active_sessions}}
|
||||
@@ -502,6 +513,10 @@
|
||||
<input id="r_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" />
|
||||
<input id="r_pool" type="number" min="0" placeholder="Количество заранее прогретых слотов" />
|
||||
<select id="r_active"><option value="true">active</option><option value="false">inactive</option></select>
|
||||
<label class="field-col" style="flex-direction:row;align-items:center;gap:.4rem">
|
||||
<input id="r_is_pilot" type="checkbox" style="width:auto" onchange="renderRdpSlots(document.getElementById('r_id').value)" />
|
||||
<span>Это пилот (слоты закрепляются за конкретным пользователем, продукт скрыт из самостоятельного запроса доступа)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="list-title">Категории</div>
|
||||
<div class="acl-grid compact-grid" id="r_categories">
|
||||
@@ -588,6 +603,10 @@
|
||||
<input id="new_r_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" />
|
||||
<input id="new_r_pool" type="number" min="0" value="1" placeholder="Количество прогретых слотов" />
|
||||
<select id="new_r_active"><option value="true">active</option><option value="false">inactive</option></select>
|
||||
<label class="field-col" style="flex-direction:row;align-items:center;gap:.4rem">
|
||||
<input id="new_r_is_pilot" type="checkbox" style="width:auto" />
|
||||
<span>Это пилот</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="list-title">Категории</div>
|
||||
<div class="acl-grid compact-grid" id="new_r_categories">
|
||||
@@ -737,8 +756,10 @@
|
||||
<script>
|
||||
const csrf = "{{ csrf_token }}";
|
||||
const aclMap = {{ acl | tojson }};
|
||||
const aclExpiresMap = {{ acl_expires | tojson }};
|
||||
const serviceCategoryMap = {{ service_category_map | tojson }};
|
||||
const rdpSlotsMap = {{ rdp_slots | tojson }};
|
||||
const usersList = {{ users_json | tojson }};
|
||||
const placeholderIcon = '/static/service-placeholder.svg';
|
||||
let activeTab = 'users';
|
||||
|
||||
@@ -911,14 +932,27 @@
|
||||
document.querySelectorAll('.user-item').forEach((el) => el.classList.remove('selected-item'));
|
||||
}
|
||||
|
||||
function toggleAclExpiryInput(box) {
|
||||
const input = box.closest('label').querySelector('.acl-expiry');
|
||||
if (input) input.style.display = box.checked ? 'inline-block' : 'none';
|
||||
}
|
||||
|
||||
function syncAclForSelectedUser() {
|
||||
const userId = parseInt(document.getElementById('u_id').value || '0', 10);
|
||||
const allowed = new Set((aclMap[userId] || []));
|
||||
const expiresMap = aclExpiresMap[userId] || {};
|
||||
document.querySelectorAll('.acl_service').forEach((box) => {
|
||||
const sid = parseInt(box.value, 10);
|
||||
box.checked = allowed.has(sid);
|
||||
box.disabled = false;
|
||||
box.closest('label').style.opacity = '';
|
||||
if (box.dataset.pilot) {
|
||||
const input = box.closest('label').querySelector('.acl-expiry');
|
||||
if (input) {
|
||||
input.value = expiresMap[sid] ? expiresMap[sid].slice(0, 10) : '';
|
||||
input.style.display = box.checked ? 'inline-block' : 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -926,7 +960,14 @@
|
||||
const userId = document.getElementById('u_id').value;
|
||||
if (!userId) return alert('Сначала выберите пользователя');
|
||||
const serviceIds = [...document.querySelectorAll('.acl_service:checked')].map(x => parseInt(x.value, 10));
|
||||
await api(`/api/admin/users/${userId}/acl`, 'PUT', {service_ids: serviceIds});
|
||||
const expiresAtByService = {};
|
||||
document.querySelectorAll('.acl-expiry').forEach((input) => {
|
||||
const sid = input.dataset.service;
|
||||
if (serviceIds.includes(parseInt(sid, 10))) {
|
||||
expiresAtByService[sid] = input.value ? `${input.value}T23:59:59+00:00` : null;
|
||||
}
|
||||
});
|
||||
await api(`/api/admin/users/${userId}/acl`, 'PUT', {service_ids: serviceIds, expires_at_by_service: expiresAtByService});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
@@ -1036,9 +1077,14 @@
|
||||
|
||||
function renderRdpSlots(serviceId) {
|
||||
const box = document.getElementById('rdp_slots_box');
|
||||
const thead = document.querySelector('#rdp_slots_table thead tr');
|
||||
const tbody = document.querySelector('#rdp_slots_table tbody');
|
||||
const slots = rdpSlotsMap[serviceId] || [];
|
||||
const isPilot = document.getElementById('r_is_pilot').checked;
|
||||
box.style.display = 'block';
|
||||
thead.innerHTML = isPilot
|
||||
? '<th>Логин RDP</th><th>Контейнер</th><th>Статус</th><th>Закреплён за</th><th></th>'
|
||||
: '<th>Логин RDP</th><th>Контейнер</th><th>Статус</th><th>Занят</th><th></th>';
|
||||
tbody.innerHTML = '';
|
||||
if (!slots.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="color:#888">Нет слотов. Добавьте RDP пользователей ниже.</td></tr>';
|
||||
@@ -1048,15 +1094,29 @@
|
||||
const statusBadge = s.running
|
||||
? '<span style="color:#4caf50">● running</span>'
|
||||
: '<span style="color:#e07b39">● stopped</span>';
|
||||
const occupiedCell = s.occupied_username
|
||||
? `<span style="color:#e07b39">${s.occupied_username}</span>`
|
||||
: '<span style="color:#888">свободен</span>';
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `<td>${s.rdp_username}</td><td style="font-size:.8em;color:#888">${s.container_name||'—'}</td><td>${statusBadge}</td><td>${occupiedCell}</td><td><button onclick="deleteRdpSlot(${s.id})">✕</button></td>`;
|
||||
if (isPilot) {
|
||||
const options = ['<option value="">— свободен —</option>']
|
||||
.concat(usersList.map(u => `<option value="${u.id}" ${s.assigned_user_id === u.id ? 'selected' : ''}>${u.label}</option>`))
|
||||
.join('');
|
||||
tr.innerHTML = `<td>${s.rdp_username}</td><td style="font-size:.8em;color:#888">${s.container_name||'—'}</td><td>${statusBadge}</td>` +
|
||||
`<td><select onchange="assignRdpSlot(${s.id}, this.value)">${options}</select></td>` +
|
||||
`<td><button onclick="deleteRdpSlot(${s.id})">✕</button></td>`;
|
||||
} else {
|
||||
const occupiedCell = s.occupied_username
|
||||
? `<span style="color:#e07b39">${s.occupied_username}</span>`
|
||||
: '<span style="color:#888">свободен</span>';
|
||||
tr.innerHTML = `<td>${s.rdp_username}</td><td style="font-size:.8em;color:#888">${s.container_name||'—'}</td><td>${statusBadge}</td><td>${occupiedCell}</td><td><button onclick="deleteRdpSlot(${s.id})">✕</button></td>`;
|
||||
}
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function assignRdpSlot(slotId, userId) {
|
||||
await api(`/api/admin/rdp-slots/${slotId}/assign`, 'PUT', {user_id: userId || null});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function addRdpSlot() {
|
||||
const serviceId = document.getElementById('r_id').value;
|
||||
if (!serviceId) return alert('Выберите RDP сервис');
|
||||
@@ -1073,7 +1133,7 @@
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function selectRdpService(id, name, slug, target, comment, iconPath, active, pool, svcLogin, svcPassword, svcCredHint) {
|
||||
function selectRdpService(id, name, slug, target, comment, iconPath, active, pool, svcLogin, svcPassword, svcCredHint, isPilot) {
|
||||
const cfg = parseRdpTarget(target);
|
||||
document.getElementById('r_id').value = id;
|
||||
document.getElementById('r_name').value = name;
|
||||
@@ -1089,6 +1149,7 @@
|
||||
document.getElementById('r_svc_cred_hint').value = svcCredHint || '';
|
||||
document.getElementById('r_active').value = String(active);
|
||||
document.getElementById('r_pool').value = pool;
|
||||
document.getElementById('r_is_pilot').checked = !!isPilot;
|
||||
setCategoryChecks('.r_cat', serviceCategoryMap[id] || []);
|
||||
document.getElementById('r_icon_preview').src = iconPath || placeholderIcon;
|
||||
document.getElementById('r_health_box').style.display = 'block';
|
||||
@@ -1112,6 +1173,7 @@
|
||||
category_ids: checkedCategoryIds('.new_r_cat'),
|
||||
warm_pool_size: parseInt(document.getElementById('new_r_pool').value || '0', 10),
|
||||
active: document.getElementById('new_r_active').value === 'true',
|
||||
is_pilot: document.getElementById('new_r_is_pilot').checked,
|
||||
});
|
||||
location.reload();
|
||||
}
|
||||
@@ -1132,6 +1194,7 @@
|
||||
category_ids: checkedCategoryIds('.r_cat'),
|
||||
warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10),
|
||||
active: document.getElementById('r_active').value === 'true',
|
||||
is_pilot: document.getElementById('r_is_pilot').checked,
|
||||
});
|
||||
location.reload();
|
||||
}
|
||||
@@ -1141,6 +1204,7 @@
|
||||
document.getElementById('rdp_slots_box').style.display = 'none';
|
||||
document.getElementById('r_sec').value = '';
|
||||
document.getElementById('r_active').value = 'true';
|
||||
document.getElementById('r_is_pilot').checked = false;
|
||||
setCategoryChecks('.r_cat', []);
|
||||
document.getElementById('r_icon_preview').src = placeholderIcon;
|
||||
document.getElementById('r_health_box').style.display = 'none';
|
||||
|
||||
Reference in New Issue
Block a user