Pilots: one form (add/edit merged), inline deactivate, unify access grant with slot assign
Admin Pilots tab: - list and create/edit form merged into one - "+ Добавить пилота" at the top of the list clears the form into create mode, clicking a pilot switches it into edit mode (title, Delete button and icon box toggle accordingly) instead of keeping two separate stacked forms - each list row gets an inline on/off toggle (⏻/▶) to flip active without opening the form - categories and the login/password hint field removed from the pilot form per earlier feedback - not applicable to pilots Access model: assigning a pilot's RDP slot to a user is now the single action that grants them the pilot - it creates/updates their UserServiceAccess row (with the optional expiry typed right next to the assign dropdown) in the same call, and unassigning revokes it. Previously slot assignment and the ACL grant were two separate steps an admin could forget to pair up, leaving a user "granted" with no working slot or a slot with no visible access. Consequently: pilots are no longer listed in the Users tab's ACL grid at all - the Pilots tab is now the only place pilot access is managed. set_acl() was fixed to never touch pilot grants regardless of what's posted (it used to free a user's pilot slot whenever it saw a pilot id missing from service_ids - which happens on every save now that the grid never includes pilots, so saving any other product's ACL would have silently stripped every pilot grant). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+71
-38
@@ -1166,11 +1166,8 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
|
|||||||
service_category_map.setdefault(service_id, []).append(category_id)
|
service_category_map.setdefault(service_id, []).append(category_id)
|
||||||
acl_rows = db.scalars(select(UserServiceAccess)).all()
|
acl_rows = db.scalars(select(UserServiceAccess)).all()
|
||||||
acl = {}
|
acl = {}
|
||||||
acl_expires = {}
|
|
||||||
for row in acl_rows:
|
for row in acl_rows:
|
||||||
acl.setdefault(row.user_id, []).append(row.service_id)
|
acl.setdefault(row.user_id, []).append(row.service_id)
|
||||||
if row.expires_at is not None:
|
|
||||||
acl_expires.setdefault(row.user_id, {})[row.service_id] = row.expires_at.isoformat()
|
|
||||||
for user_id in acl:
|
for user_id in acl:
|
||||||
acl[user_id] = sorted(acl[user_id])
|
acl[user_id] = sorted(acl[user_id])
|
||||||
pool_status = {s.id: get_pool_status_for_service(s) for s in services}
|
pool_status = {s.id: get_pool_status_for_service(s) for s in services}
|
||||||
@@ -1258,9 +1255,18 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
|
|||||||
u = db.get(User, active_sess.user_id)
|
u = db.get(User, active_sess.user_id)
|
||||||
occupied_username = u.username if u else f"id={active_sess.user_id}"
|
occupied_username = u.username if u else f"id={active_sess.user_id}"
|
||||||
assigned_username = None
|
assigned_username = None
|
||||||
|
assigned_expires_at = None
|
||||||
if slot.assigned_user_id:
|
if slot.assigned_user_id:
|
||||||
au = db.get(User, slot.assigned_user_id)
|
au = db.get(User, slot.assigned_user_id)
|
||||||
assigned_username = au.username if au else f"id={slot.assigned_user_id}"
|
assigned_username = au.username if au else f"id={slot.assigned_user_id}"
|
||||||
|
access_row = db.scalar(
|
||||||
|
select(UserServiceAccess).where(
|
||||||
|
UserServiceAccess.user_id == slot.assigned_user_id,
|
||||||
|
UserServiceAccess.service_id == svc.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if access_row and access_row.expires_at:
|
||||||
|
assigned_expires_at = access_row.expires_at.isoformat()
|
||||||
slot_list.append({
|
slot_list.append({
|
||||||
"id": slot.id,
|
"id": slot.id,
|
||||||
"rdp_username": slot.rdp_username,
|
"rdp_username": slot.rdp_username,
|
||||||
@@ -1269,6 +1275,7 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
|
|||||||
"occupied_username": occupied_username,
|
"occupied_username": occupied_username,
|
||||||
"assigned_user_id": slot.assigned_user_id,
|
"assigned_user_id": slot.assigned_user_id,
|
||||||
"assigned_username": assigned_username,
|
"assigned_username": assigned_username,
|
||||||
|
"assigned_expires_at": assigned_expires_at,
|
||||||
})
|
})
|
||||||
rdp_slots[svc.id] = slot_list
|
rdp_slots[svc.id] = slot_list
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
@@ -1289,7 +1296,6 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
|
|||||||
"categories": categories,
|
"categories": categories,
|
||||||
"service_category_map": service_category_map,
|
"service_category_map": service_category_map,
|
||||||
"acl": acl,
|
"acl": acl,
|
||||||
"acl_expires": acl_expires,
|
|
||||||
"pool_status": pool_status,
|
"pool_status": pool_status,
|
||||||
"service_health": service_health,
|
"service_health": service_health,
|
||||||
"web_totals": web_totals,
|
"web_totals": web_totals,
|
||||||
@@ -2843,9 +2849,16 @@ def delete_rdp_slot(slot_id: int, request: Request, _: User = Depends(require_ad
|
|||||||
@app.put("/api/admin/rdp-slots/{slot_id}/assign")
|
@app.put("/api/admin/rdp-slots/{slot_id}/assign")
|
||||||
def assign_rdp_slot(slot_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
def assign_rdp_slot(slot_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||||||
"""Bind (or unbind, with user_id null) a pilot's RDP slot to one
|
"""Bind (or unbind, with user_id null) a pilot's RDP slot to one
|
||||||
specific person. Only meaningful for slots that belong to a pilot
|
specific person. This is the single action that gives someone a pilot:
|
||||||
service - a slot on a regular pooled RDP service doesn't need this,
|
it both points the slot at them AND grants/revokes their
|
||||||
since any free slot in the pool already works for anyone with access."""
|
UserServiceAccess row for the pilot service, so assigning a slot here
|
||||||
|
is enough to make the pilot usable for them - no separate ACL step on
|
||||||
|
the Users tab. Optional "expires_at" (ISO string or null) sets a
|
||||||
|
per-grant expiry shorter than the account's own.
|
||||||
|
|
||||||
|
Only meaningful for slots that belong to a pilot service - a slot on a
|
||||||
|
regular pooled RDP service doesn't need this, since any free slot in
|
||||||
|
the pool already works for anyone with access."""
|
||||||
validate_csrf(request)
|
validate_csrf(request)
|
||||||
slot = db.get(RdpSlot, slot_id)
|
slot = db.get(RdpSlot, slot_id)
|
||||||
if not slot:
|
if not slot:
|
||||||
@@ -2854,9 +2867,20 @@ def assign_rdp_slot(slot_id: int, payload: dict, request: Request, _: User = Dep
|
|||||||
if not service or not service.is_pilot:
|
if not service or not service.is_pilot:
|
||||||
raise HTTPException(status_code=400, detail="Слот принадлежит не пилотному сервису")
|
raise HTTPException(status_code=400, detail="Слот принадлежит не пилотному сервису")
|
||||||
|
|
||||||
|
prev_user_id = slot.assigned_user_id
|
||||||
raw_user_id = payload.get("user_id")
|
raw_user_id = payload.get("user_id")
|
||||||
|
|
||||||
if raw_user_id in (None, ""):
|
if raw_user_id in (None, ""):
|
||||||
slot.assigned_user_id = None
|
slot.assigned_user_id = None
|
||||||
|
if prev_user_id:
|
||||||
|
prev_access = db.scalar(
|
||||||
|
select(UserServiceAccess).where(
|
||||||
|
UserServiceAccess.user_id == prev_user_id,
|
||||||
|
UserServiceAccess.service_id == service.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if prev_access:
|
||||||
|
db.delete(prev_access)
|
||||||
db.commit()
|
db.commit()
|
||||||
audit(db, "RDP_SLOT_UNASSIGN", f"service={service.slug} slot={slot.id}", user_id=None)
|
audit(db, "RDP_SLOT_UNASSIGN", f"service={service.slug} slot={slot.id}", user_id=None)
|
||||||
return {"ok": True, "assigned_user_id": None}
|
return {"ok": True, "assigned_user_id": None}
|
||||||
@@ -2876,6 +2900,31 @@ def assign_rdp_slot(slot_id: int, payload: dict, request: Request, _: User = Dep
|
|||||||
status_code=409,
|
status_code=409,
|
||||||
detail=f"У пользователя уже есть слот №{other.id} на этом пилоте",
|
detail=f"У пользователя уже есть слот №{other.id} на этом пилоте",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
raw_expires = payload.get("expires_at")
|
||||||
|
expires_at = dt.datetime.fromisoformat(raw_expires) if raw_expires else None
|
||||||
|
|
||||||
|
if prev_user_id and prev_user_id != target_user.id:
|
||||||
|
prev_access = db.scalar(
|
||||||
|
select(UserServiceAccess).where(
|
||||||
|
UserServiceAccess.user_id == prev_user_id,
|
||||||
|
UserServiceAccess.service_id == service.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if prev_access:
|
||||||
|
db.delete(prev_access)
|
||||||
|
|
||||||
|
access = db.scalar(
|
||||||
|
select(UserServiceAccess).where(
|
||||||
|
UserServiceAccess.user_id == target_user.id,
|
||||||
|
UserServiceAccess.service_id == service.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if access:
|
||||||
|
access.expires_at = expires_at
|
||||||
|
else:
|
||||||
|
db.add(UserServiceAccess(user_id=target_user.id, service_id=service.id, expires_at=expires_at))
|
||||||
|
|
||||||
slot.assigned_user_id = target_user.id
|
slot.assigned_user_id = target_user.id
|
||||||
db.commit()
|
db.commit()
|
||||||
audit(db, "RDP_SLOT_ASSIGN", f"service={service.slug} slot={slot.id} user={target_user.username}", user_id=None)
|
audit(db, "RDP_SLOT_ASSIGN", f"service={service.slug} slot={slot.id} user={target_user.username}", user_id=None)
|
||||||
@@ -2971,49 +3020,33 @@ def delete_user(user_id: int, request: Request, admin: User = Depends(require_ad
|
|||||||
|
|
||||||
@app.put("/api/admin/users/{user_id}/acl")
|
@app.put("/api/admin/users/{user_id}/acl")
|
||||||
def set_acl(user_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
def set_acl(user_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||||||
|
"""Replace a user's non-pilot product grants with the posted set.
|
||||||
|
Pilots are deliberately out of scope here - they're granted/revoked
|
||||||
|
only via the per-slot assign endpoint on the Pilots tab, which is also
|
||||||
|
what points a specific container at the person. The Users tab ACL grid
|
||||||
|
doesn't render pilot checkboxes at all, so service_ids posted from
|
||||||
|
there never includes them; if this endpoint treated "pilot id missing
|
||||||
|
from service_ids" as "revoke it", saving any other product's ACL would
|
||||||
|
silently strip every pilot grant the user has."""
|
||||||
validate_csrf(request)
|
validate_csrf(request)
|
||||||
user = db.get(User, user_id)
|
user = db.get(User, user_id)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
service_ids = set(payload.get("service_ids", []))
|
service_ids = set(payload.get("service_ids", []))
|
||||||
# Optional per-service expiry override, e.g. {"12": "2026-11-01T00:00:00+00:00"}
|
pilot_ids = set(db.scalars(select(Service.id).where(Service.is_pilot == True)).all())
|
||||||
# or {"12": null} to clear it back to "follow the account expiry".
|
|
||||||
# Used for pilots, whose access window can be shorter than the account's.
|
|
||||||
expires_by_service = payload.get("expires_at_by_service") or {}
|
|
||||||
|
|
||||||
existing = db.scalars(select(UserServiceAccess).where(UserServiceAccess.user_id == user_id)).all()
|
existing = db.scalars(select(UserServiceAccess).where(UserServiceAccess.user_id == user_id)).all()
|
||||||
existing_map = {x.service_id: x for x in existing}
|
existing_map = {x.service_id: x for x in existing}
|
||||||
|
|
||||||
rows_by_service = dict(existing_map)
|
for sid in service_ids - pilot_ids:
|
||||||
for sid in service_ids:
|
|
||||||
if sid not in existing_map:
|
if sid not in existing_map:
|
||||||
new_row = UserServiceAccess(user_id=user_id, service_id=sid)
|
db.add(UserServiceAccess(user_id=user_id, service_id=sid))
|
||||||
db.add(new_row)
|
removed_ids = [
|
||||||
rows_by_service[sid] = new_row
|
sid for sid, row in existing_map.items()
|
||||||
removed_ids = [sid for sid, row in existing_map.items() if sid not in service_ids]
|
if sid not in service_ids and sid not in pilot_ids
|
||||||
|
]
|
||||||
for sid in removed_ids:
|
for sid in removed_ids:
|
||||||
db.delete(existing_map[sid])
|
db.delete(existing_map[sid])
|
||||||
|
|
||||||
for sid_str, iso_value in expires_by_service.items():
|
|
||||||
try:
|
|
||||||
sid = int(sid_str)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
row = rows_by_service.get(sid)
|
|
||||||
if row is None:
|
|
||||||
continue
|
|
||||||
row.expires_at = dt.datetime.fromisoformat(iso_value) if iso_value else None
|
|
||||||
|
|
||||||
if removed_ids:
|
|
||||||
# Revoking a pilot immediately frees any slot reserved for this user
|
|
||||||
# on it, instead of waiting for the next cleanup_loop sweep.
|
|
||||||
for slot in db.scalars(
|
|
||||||
select(RdpSlot).where(
|
|
||||||
RdpSlot.assigned_user_id == user_id,
|
|
||||||
RdpSlot.service_id.in_(removed_ids),
|
|
||||||
)
|
|
||||||
).all():
|
|
||||||
slot.assigned_user_id = None
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
+60
-85
@@ -32,9 +32,16 @@
|
|||||||
--av-good:#1f9d63; --av-warn:#b5680a; --av-bad:#d3453f;
|
--av-good:#1f9d63; --av-warn:#b5680a; --av-bad:#d3453f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pilot-badge{
|
body.admin-page-v2 .pilot-add-item{
|
||||||
|
width:100%;text-align:center;font-weight:700;color:var(--av-accent) !important;
|
||||||
|
border-style:dashed !important;
|
||||||
|
}
|
||||||
|
body.admin-page-v2 .pilot-toggle-btn{
|
||||||
|
flex:0 0 auto;width:30px;height:30px;padding:0;border-radius:7px;font-size:14px;line-height:1;
|
||||||
|
}
|
||||||
|
.pilot-inactive-tag{
|
||||||
display:inline-block;font:700 10px/1 "Ubuntu Mono",monospace;letter-spacing:.04em;
|
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;
|
color:var(--av-fg-faint);background:var(--av-line-soft);border-radius:4px;padding:2px 5px;vertical-align:middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.admin-page-v2{
|
body.admin-page-v2{
|
||||||
@@ -263,13 +270,12 @@
|
|||||||
|
|
||||||
<div style="margin-top:1rem;">
|
<div style="margin-top:1rem;">
|
||||||
<div class="list-title">ACL выбранного пользователя</div>
|
<div class="list-title">ACL выбранного пользователя</div>
|
||||||
|
<div class="field-help">Пилоты сюда не входят — доступ к ним назначается на вкладке «Pilots» (там же, где и слот).</div>
|
||||||
<div class="acl-grid">
|
<div class="acl-grid">
|
||||||
{% for s in services %}
|
{% for s in services if s.id not in pilot_service_ids %}
|
||||||
<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 %} />
|
<input type="checkbox" class="acl_service" value="{{s.id}}" data-stype="{{s.type.value}}" />
|
||||||
{{s.name}} ({{s.slug}}){% if s.id in pilot_service_ids %} <span class="pilot-badge">ПИЛОТ</span>
|
{{s.name}} ({{s.slug}})
|
||||||
<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>
|
<span class="acl-owner"></span>
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -620,22 +626,28 @@
|
|||||||
<div class="list-title">Список пилотов</div>
|
<div class="list-title">Список пилотов</div>
|
||||||
<input class="list-search" id="pilot_search" placeholder="Поиск пилота..." oninput="filterList('pilot_search', '#pilot_list .pilot-item')" />
|
<input class="list-search" id="pilot_search" placeholder="Поиск пилота..." oninput="filterList('pilot_search', '#pilot_list .pilot-item')" />
|
||||||
<div class="list-box" id="pilot_list">
|
<div class="list-box" id="pilot_list">
|
||||||
|
<button class="list-item pilot-add-item" onclick="startNewPilot()">+ Добавить пилота</button>
|
||||||
{% for s in pilot_services %}
|
{% for s in pilot_services %}
|
||||||
<button class="list-item service-row pilot-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectPilotService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}})'>
|
<div class="list-item service-row pilot-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" style="justify-content:space-between;cursor:pointer" onclick='selectPilotService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}})'>
|
||||||
|
<div class="service-row" style="min-width:0">
|
||||||
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
|
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
|
||||||
<div>
|
<div style="min-width:0">
|
||||||
<div>{{s.name}}</div>
|
<div>{{s.name}}{% if not s.active %} <span class="pilot-inactive-tag">выключен</span>{% endif %}</div>
|
||||||
<small>
|
<small>
|
||||||
<span class="status-dot status-{{ service_health[s.id].health }}"></span>
|
<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}}
|
{{service_health[s.id].health}} | {{service_health[s.id].running}} / {{service_health[s.id].desired}} | active: {{service_health[s.id].active_sessions}}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
|
<button type="button" class="pilot-toggle-btn" title="{{ 'Деактивировать' if s.active else 'Активировать' }}" onclick='event.stopPropagation(); togglePilotActive({{s.id}}, {{(not s.active)|tojson}})'>{{ "⏻" if s.active else "▶" }}</button>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="list-item" style="opacity:.6;cursor:default">Пилотов пока нет</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="list-title">Редактирование пилота</div>
|
<div class="list-title" id="pilot_form_title">Новый пилот</div>
|
||||||
<div class="field-help">Используйте поля host/port/user. Поле target собирается автоматически.</div>
|
<div class="field-help">Используйте поля host/port/user. Поле target собирается автоматически.</div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<input id="p_id" type="hidden" />
|
<input id="p_id" type="hidden" />
|
||||||
@@ -658,11 +670,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button onclick="savePilotService()">Save</button>
|
<button onclick="savePilotService()">Save</button>
|
||||||
<button onclick="deleteService('p_id')">Delete</button>
|
<button id="pilot_delete_btn" onclick="deleteService('p_id')" style="display:none;">Delete</button>
|
||||||
<button onclick="clearPilotForm()">Clear</button>
|
<button onclick="clearPilotForm()">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="icon-box" style="margin-top:1rem;">
|
<div class="icon-box" id="p_icon_box" style="display:none; margin-top:1rem;">
|
||||||
<div class="list-title">Иконка пилота</div>
|
<div class="list-title">Иконка пилота</div>
|
||||||
<div class="icon-row">
|
<div class="icon-row">
|
||||||
<img id="p_icon_preview" class="service-icon-preview" src="/static/service-placeholder.svg" alt="icon" />
|
<img id="p_icon_preview" class="service-icon-preview" src="/static/service-placeholder.svg" alt="icon" />
|
||||||
@@ -677,10 +689,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="pilot_slots_box" style="display:none; margin-top:1rem;">
|
<div id="pilot_slots_box" style="display:none; margin-top:1rem;">
|
||||||
<div class="list-title">Слоты пилота (закреплены за пользователем)</div>
|
<div class="list-title">Пользователи пилота (слоты)</div>
|
||||||
<div class="field-help">Каждый слот — отдельный контейнер. Назначьте его конкретному пользователю из списка.</div>
|
<div class="field-help">Назначение слота пользователю — это и есть выдача доступа: он сразу видит пилота на портале и получает именно этот контейнер. Снятие назначения отзывает доступ.</div>
|
||||||
<table class="admin-table" id="pilot_slots_table" style="margin-bottom:.7rem">
|
<table class="admin-table" id="pilot_slots_table" style="margin-bottom:.7rem">
|
||||||
<thead><tr><th>Логин RDP</th><th>Контейнер</th><th>Статус</th><th>Закреплён за</th><th></th></tr></thead>
|
<thead><tr><th>Логин RDP</th><th>Контейнер</th><th>Статус</th><th>Назначен</th><th>Доступ до</th><th></th></tr></thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
</table>
|
</table>
|
||||||
<div style="display:flex;gap:.5rem;align-items:flex-end;flex-wrap:wrap;">
|
<div style="display:flex;gap:.5rem;align-items:flex-end;flex-wrap:wrap;">
|
||||||
@@ -689,29 +701,6 @@
|
|||||||
<button onclick="addPilotSlot()">+ Добавить слот</button>
|
<button onclick="addPilotSlot()">+ Добавить слот</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr>
|
|
||||||
<div class="list-title">Добавить пилота</div>
|
|
||||||
<div class="field-help">Для большинства кейсов достаточно host + user + password.</div>
|
|
||||||
<div class="form-grid">
|
|
||||||
<input id="new_p_name" placeholder="Название пилота" oninput="autogenSlug('new_p_name','new_p_slug')" />
|
|
||||||
<input id="new_p_slug" placeholder="Системный slug" />
|
|
||||||
<input id="new_p_host" placeholder="RDP host" oninput="buildRdpTarget('new_p')" />
|
|
||||||
<input id="new_p_port" type="number" min="1" max="65535" placeholder="RDP порт (3389)" oninput="buildRdpTarget('new_p')" />
|
|
||||||
<input id="new_p_domain" placeholder="Домен (опционально)" oninput="buildRdpTarget('new_p')" />
|
|
||||||
<select id="new_p_sec" onchange="buildRdpTarget('new_p')">
|
|
||||||
<option value="">auto</option>
|
|
||||||
<option value="nla">nla</option>
|
|
||||||
<option value="tls">tls</option>
|
|
||||||
<option value="rdp">rdp</option>
|
|
||||||
</select>
|
|
||||||
<input id="new_p_target" placeholder="Собранный target (авто)" readonly style="background:rgba(255,255,255,.05);color:#888;cursor:default" />
|
|
||||||
<textarea id="new_p_comment" placeholder="Описание пилота для пользователя"></textarea>
|
|
||||||
<input id="new_p_svc_login" placeholder="Логин сервиса (необязательно)" />
|
|
||||||
<input id="new_p_svc_password" placeholder="Пароль сервиса (необязательно)" />
|
|
||||||
<select id="new_p_active"><option value="true">active</option><option value="false">inactive</option></select>
|
|
||||||
</div>
|
|
||||||
<button onclick="createPilotService()">Add Pilot</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -853,7 +842,6 @@
|
|||||||
<script>
|
<script>
|
||||||
const csrf = "{{ csrf_token }}";
|
const csrf = "{{ csrf_token }}";
|
||||||
const aclMap = {{ acl | tojson }};
|
const aclMap = {{ acl | tojson }};
|
||||||
const aclExpiresMap = {{ acl_expires | tojson }};
|
|
||||||
const serviceCategoryMap = {{ service_category_map | tojson }};
|
const serviceCategoryMap = {{ service_category_map | tojson }};
|
||||||
const rdpSlotsMap = {{ rdp_slots | tojson }};
|
const rdpSlotsMap = {{ rdp_slots | tojson }};
|
||||||
const usersList = {{ users_json | tojson }};
|
const usersList = {{ users_json | tojson }};
|
||||||
@@ -1029,27 +1017,14 @@
|
|||||||
document.querySelectorAll('.user-item').forEach((el) => el.classList.remove('selected-item'));
|
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() {
|
function syncAclForSelectedUser() {
|
||||||
const userId = parseInt(document.getElementById('u_id').value || '0', 10);
|
const userId = parseInt(document.getElementById('u_id').value || '0', 10);
|
||||||
const allowed = new Set((aclMap[userId] || []));
|
const allowed = new Set((aclMap[userId] || []));
|
||||||
const expiresMap = aclExpiresMap[userId] || {};
|
|
||||||
document.querySelectorAll('.acl_service').forEach((box) => {
|
document.querySelectorAll('.acl_service').forEach((box) => {
|
||||||
const sid = parseInt(box.value, 10);
|
const sid = parseInt(box.value, 10);
|
||||||
box.checked = allowed.has(sid);
|
box.checked = allowed.has(sid);
|
||||||
box.disabled = false;
|
box.disabled = false;
|
||||||
box.closest('label').style.opacity = '';
|
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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1057,14 +1032,7 @@
|
|||||||
const userId = document.getElementById('u_id').value;
|
const userId = document.getElementById('u_id').value;
|
||||||
if (!userId) return alert('Сначала выберите пользователя');
|
if (!userId) return alert('Сначала выберите пользователя');
|
||||||
const serviceIds = [...document.querySelectorAll('.acl_service:checked')].map(x => parseInt(x.value, 10));
|
const serviceIds = [...document.querySelectorAll('.acl_service:checked')].map(x => parseInt(x.value, 10));
|
||||||
const expiresAtByService = {};
|
await api(`/api/admin/users/${userId}/acl`, 'PUT', {service_ids: serviceIds});
|
||||||
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();
|
location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1292,7 +1260,7 @@
|
|||||||
box.style.display = 'block';
|
box.style.display = 'block';
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
if (!slots.length) {
|
if (!slots.length) {
|
||||||
tbody.innerHTML = '<tr><td colspan="5" style="color:#888">Нет слотов. Добавьте слот ниже и назначьте его пользователю.</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="6" style="color:#888">Нет слотов. Добавьте слот ниже и назначьте его пользователю.</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
slots.forEach(s => {
|
slots.forEach(s => {
|
||||||
@@ -1304,20 +1272,24 @@
|
|||||||
.join('');
|
.join('');
|
||||||
const tr = document.createElement('tr');
|
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>` +
|
tr.innerHTML = `<td>${s.rdp_username}</td><td style="font-size:.8em;color:#888">${s.container_name||'—'}</td><td>${statusBadge}</td>` +
|
||||||
`<td><select onchange="assignPilotSlot(${s.id}, this.value)">${options}</select></td>` +
|
`<td><select id="pilot_slot_user_${s.id}" onchange="assignPilotSlot(${s.id})">${options}</select></td>` +
|
||||||
|
`<td><input type="date" id="pilot_slot_exp_${s.id}" value="${s.assigned_expires_at ? s.assigned_expires_at.slice(0, 10) : ''}" ${s.assigned_user_id ? '' : 'disabled'} onchange="assignPilotSlot(${s.id})" style="width:140px" title="Необязательно — иначе по сроку аккаунта пользователя" /></td>` +
|
||||||
`<td><button onclick="deleteRdpSlot(${s.id})">✕</button></td>`;
|
`<td><button onclick="deleteRdpSlot(${s.id})">✕</button></td>`;
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assignPilotSlot(slotId, userId) {
|
async function assignPilotSlot(slotId) {
|
||||||
await api(`/api/admin/rdp-slots/${slotId}/assign`, 'PUT', {user_id: userId || null});
|
const userId = document.getElementById(`pilot_slot_user_${slotId}`).value;
|
||||||
|
const expInput = document.getElementById(`pilot_slot_exp_${slotId}`);
|
||||||
|
const expires_at = (userId && expInput.value) ? `${expInput.value}T23:59:59+00:00` : null;
|
||||||
|
await api(`/api/admin/rdp-slots/${slotId}/assign`, 'PUT', {user_id: userId || null, expires_at});
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addPilotSlot() {
|
async function addPilotSlot() {
|
||||||
const serviceId = document.getElementById('p_id').value;
|
const serviceId = document.getElementById('p_id').value;
|
||||||
if (!serviceId) return alert('Выберите пилота');
|
if (!serviceId) return alert('Сначала сохраните пилота');
|
||||||
const rdp_username = document.getElementById('new_pilot_slot_user').value.trim();
|
const rdp_username = document.getElementById('new_pilot_slot_user').value.trim();
|
||||||
const rdp_password = document.getElementById('new_pilot_slot_pass').value.trim();
|
const rdp_password = document.getElementById('new_pilot_slot_pass').value.trim();
|
||||||
if (!rdp_username) return alert('Введите логин RDP');
|
if (!rdp_username) return alert('Введите логин RDP');
|
||||||
@@ -1340,34 +1312,29 @@
|
|||||||
document.getElementById('p_svc_password').value = svcPassword || '';
|
document.getElementById('p_svc_password').value = svcPassword || '';
|
||||||
document.getElementById('p_active').value = String(active);
|
document.getElementById('p_active').value = String(active);
|
||||||
document.getElementById('p_icon_preview').src = iconPath || placeholderIcon;
|
document.getElementById('p_icon_preview').src = iconPath || placeholderIcon;
|
||||||
|
document.getElementById('pilot_form_title').textContent = `Пилот: ${name}`;
|
||||||
|
document.getElementById('pilot_delete_btn').style.display = '';
|
||||||
|
document.getElementById('p_icon_box').style.display = '';
|
||||||
markSelected('.pilot-item', 'data-service-id', id);
|
markSelected('.pilot-item', 'data-service-id', id);
|
||||||
renderPilotSlots(id);
|
renderPilotSlots(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createPilotService() {
|
function startNewPilot() {
|
||||||
const slug = document.getElementById('new_p_slug').value || slugifyRu(document.getElementById('new_p_name').value);
|
clearPilotForm();
|
||||||
const target = buildRdpTarget('new_p');
|
document.getElementById('p_name').focus();
|
||||||
await api('/api/admin/services', 'POST', {
|
}
|
||||||
name: document.getElementById('new_p_name').value,
|
|
||||||
slug,
|
async function togglePilotActive(id, nextActive) {
|
||||||
type: 'RDP',
|
await api(`/api/admin/services/${id}`, 'PUT', {active: nextActive});
|
||||||
target,
|
|
||||||
comment: document.getElementById('new_p_comment').value,
|
|
||||||
svc_login: document.getElementById('new_p_svc_login').value,
|
|
||||||
svc_password: document.getElementById('new_p_svc_password').value,
|
|
||||||
active: document.getElementById('new_p_active').value === 'true',
|
|
||||||
is_pilot: true,
|
|
||||||
});
|
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePilotService() {
|
async function savePilotService() {
|
||||||
const id = document.getElementById('p_id').value;
|
const id = document.getElementById('p_id').value;
|
||||||
if (!id) return alert('Выберите пилота');
|
|
||||||
const target = buildRdpTarget('p');
|
const target = buildRdpTarget('p');
|
||||||
await api(`/api/admin/services/${id}`, 'PUT', {
|
const payload = {
|
||||||
name: document.getElementById('p_name').value,
|
name: document.getElementById('p_name').value,
|
||||||
slug: document.getElementById('p_slug').value,
|
slug: document.getElementById('p_slug').value || slugifyRu(document.getElementById('p_name').value),
|
||||||
type: 'RDP',
|
type: 'RDP',
|
||||||
target,
|
target,
|
||||||
comment: document.getElementById('p_comment').value,
|
comment: document.getElementById('p_comment').value,
|
||||||
@@ -1375,7 +1342,12 @@
|
|||||||
svc_password: document.getElementById('p_svc_password').value,
|
svc_password: document.getElementById('p_svc_password').value,
|
||||||
active: document.getElementById('p_active').value === 'true',
|
active: document.getElementById('p_active').value === 'true',
|
||||||
is_pilot: true,
|
is_pilot: true,
|
||||||
});
|
};
|
||||||
|
if (id) {
|
||||||
|
await api(`/api/admin/services/${id}`, 'PUT', payload);
|
||||||
|
} else {
|
||||||
|
await api('/api/admin/services', 'POST', payload);
|
||||||
|
}
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1385,6 +1357,9 @@
|
|||||||
document.getElementById('p_sec').value = '';
|
document.getElementById('p_sec').value = '';
|
||||||
document.getElementById('p_active').value = 'true';
|
document.getElementById('p_active').value = 'true';
|
||||||
document.getElementById('p_icon_preview').src = placeholderIcon;
|
document.getElementById('p_icon_preview').src = placeholderIcon;
|
||||||
|
document.getElementById('p_icon_box').style.display = 'none';
|
||||||
|
document.getElementById('pilot_delete_btn').style.display = 'none';
|
||||||
|
document.getElementById('pilot_form_title').textContent = 'Новый пилот';
|
||||||
document.querySelectorAll('.pilot-item').forEach((el) => el.classList.remove('selected-item'));
|
document.querySelectorAll('.pilot-item').forEach((el) => el.classList.remove('selected-item'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user