diff --git a/app/main.py b/app/main.py index e06c556..a460cce 100644 --- a/app/main.py +++ b/app/main.py @@ -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) acl_rows = db.scalars(select(UserServiceAccess)).all() acl = {} - acl_expires = {} for row in acl_rows: 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: acl[user_id] = sorted(acl[user_id]) 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) occupied_username = u.username if u else f"id={active_sess.user_id}" assigned_username = None + assigned_expires_at = None if 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}" + 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({ "id": slot.id, "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, "assigned_user_id": slot.assigned_user_id, "assigned_username": assigned_username, + "assigned_expires_at": assigned_expires_at, }) rdp_slots[svc.id] = slot_list return templates.TemplateResponse( @@ -1289,7 +1296,6 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi "categories": categories, "service_category_map": service_category_map, "acl": acl, - "acl_expires": acl_expires, "pool_status": pool_status, "service_health": service_health, "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") 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 - specific person. 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.""" + specific person. This is the single action that gives someone a pilot: + it both points the slot at them AND grants/revokes their + 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) slot = db.get(RdpSlot, slot_id) 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: raise HTTPException(status_code=400, detail="Слот принадлежит не пилотному сервису") + prev_user_id = slot.assigned_user_id raw_user_id = payload.get("user_id") + if raw_user_id in (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() audit(db, "RDP_SLOT_UNASSIGN", f"service={service.slug} slot={slot.id}", 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, 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 db.commit() 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") 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) user = db.get(User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") service_ids = set(payload.get("service_ids", [])) - # Optional per-service expiry override, e.g. {"12": "2026-11-01T00:00:00+00:00"} - # 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 {} + pilot_ids = set(db.scalars(select(Service.id).where(Service.is_pilot == True)).all()) existing = db.scalars(select(UserServiceAccess).where(UserServiceAccess.user_id == user_id)).all() existing_map = {x.service_id: x for x in existing} - rows_by_service = dict(existing_map) - for sid in service_ids: + for sid in service_ids - pilot_ids: if sid not in existing_map: - new_row = UserServiceAccess(user_id=user_id, service_id=sid) - db.add(new_row) - rows_by_service[sid] = new_row - removed_ids = [sid for sid, row in existing_map.items() if sid not in service_ids] + db.add(UserServiceAccess(user_id=user_id, service_id=sid)) + removed_ids = [ + sid for sid, row in existing_map.items() + if sid not in service_ids and sid not in pilot_ids + ] for sid in removed_ids: 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() return {"ok": True} diff --git a/app/templates/admin.html b/app/templates/admin.html index 91d33ca..949f81e 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -32,9 +32,16 @@ --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; - 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{ @@ -263,13 +270,12 @@
ACL выбранного пользователя
+
Пилоты сюда не входят — доступ к ним назначается на вкладке «Pilots» (там же, где и слот).
- {% for s in services %} + {% for s in services if s.id not in pilot_service_ids %} {% endfor %} @@ -620,22 +626,28 @@
Список пилотов
+ {% for s in pilot_services %} - + +
+ {% else %} +
Пилотов пока нет
{% endfor %}
-
Редактирование пилота
+
Новый пилот
Используйте поля host/port/user. Поле target собирается автоматически.
@@ -658,11 +670,11 @@
- +
-
+
@@ -853,7 +842,6 @@