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:
@@ -138,6 +138,14 @@
|
||||
border:1px dashed var(--dv-fg-faint);background:transparent;color:var(--dv-fg-faint);font:600 12.5px/1 "Ubuntu",sans-serif;cursor:pointer;
|
||||
}
|
||||
.dv-tile-request:hover{border-color:var(--dv-accent);border-style:solid;color:var(--dv-accent)}
|
||||
.dv-pilot-badge{
|
||||
display:inline-block;font:700 10px/1 "Ubuntu Mono",monospace;letter-spacing:.04em;
|
||||
color:#0a1929;background:var(--dv-accent);border-radius:4px;padding:2px 5px;vertical-align:middle;
|
||||
}
|
||||
.dv-tile-invite{
|
||||
position:relative;z-index:2;margin-top:auto;align-self:flex-start;padding:7px 14px;border-radius:7px;
|
||||
border:1px solid var(--dv-line);background:transparent;color:var(--dv-fg-faint);font:600 12.5px/1 "Ubuntu",sans-serif;
|
||||
}
|
||||
|
||||
.dv-page-footer{margin-top:50px;padding-top:20px;border-top:1px solid var(--dv-line);display:flex;
|
||||
justify-content:space-between;flex-wrap:wrap;gap:8px;font:400 12px/1 "Ubuntu Mono",monospace;color:var(--dv-fg-faint)}
|
||||
@@ -252,7 +260,7 @@
|
||||
<a class="dv-tile-hit go-link" href="/go/{{ service.slug }}" aria-label="Открыть {{ service.name }}"></a>
|
||||
<div class="dv-tile-top">
|
||||
<div class="dv-tile-plate"><img src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="" /></div>
|
||||
<div class="dv-tile-name">{{ service.name }}</div>
|
||||
<div class="dv-tile-name">{{ service.name }}{% if service.id in pilot_ids %} <span class="dv-pilot-badge">ПИЛОТ</span>{% endif %}</div>
|
||||
</div>
|
||||
<div class="dv-tile-desc">{{ service_comment_html.get(service.id, '') }}</div>
|
||||
<a class="dv-tile-enter go-link" href="/go/{{ service.slug }}">Войти →</a>
|
||||
@@ -270,6 +278,16 @@
|
||||
<div class="dv-section-head"><h2>Доступно по запросу</h2><span class="dv-section-count">{{ '%02d'|format(locked_services|length) }}</span></div>
|
||||
<div class="dv-tile-grid">
|
||||
{% for service in locked_services %}
|
||||
{% if service.id in pilot_ids %}
|
||||
<div class="dv-tile locked pilot-locked">
|
||||
<div class="dv-tile-top">
|
||||
<div class="dv-tile-plate"><img src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="" /></div>
|
||||
<div class="dv-tile-name">{{ service.name }}</div>
|
||||
</div>
|
||||
<div class="dv-tile-desc">{{ service_comment_html.get(service.id, '') }}</div>
|
||||
<span class="dv-tile-invite" title="Доступ выдаёт администратор полигона">По приглашению</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="dv-tile locked">
|
||||
<div class="dv-tile-top">
|
||||
<div class="dv-tile-plate"><img src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="" /></div>
|
||||
@@ -279,6 +297,7 @@
|
||||
<button type="button" class="dv-tile-request" data-product="{{ service.name }}">Запросить доступ</button>
|
||||
<span class="dv-tile-lock" title="Нет доступа">🔒</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
@@ -352,7 +371,10 @@
|
||||
const modalBody = document.getElementById('req-modal-body');
|
||||
|
||||
function allLockedTiles() {
|
||||
return Array.prototype.slice.call(document.querySelectorAll('.dv-tile.locked'));
|
||||
// Pilots (.pilot-locked) are invite-only and must never end up in the
|
||||
// self-service "request access" checklist, even via another product's
|
||||
// request button pulling in every locked tile on the page.
|
||||
return Array.prototype.slice.call(document.querySelectorAll('.dv-tile.locked:not(.pilot-locked)'));
|
||||
}
|
||||
|
||||
function openRequestModal(preselect) {
|
||||
|
||||
Reference in New Issue
Block a user