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:
2026-10-02 06:32:05 +00:00
parent 9e525a44c3
commit 46e5b5a41e
8 changed files with 327 additions and 31 deletions
+73 -9
View File
@@ -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">&#9679; running</span>'
: '<span style="color:#e07b39">&#9679; 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';