Admin: moved pilot management out of the RDP tab into its own
"Pilots" tab - dedicated list, create/edit form and slot table with
a per-slot user-assign dropdown (always on, no more is_pilot
checkbox toggling the RDP tab's slot table between two modes). The
RDP tab goes back to exactly its pre-pilots shape.
Dashboard: a granted pilot no longer sits inside the "Ваши сервисы"
grid - it gets its own <details> section above it, open by default
whenever the user has at least one pilot (and simply absent when
they have none, rather than showing empty and collapsed).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Outlook desktop (Word rendering engine) does not support data: URI
images at all - the logo in approval/rejection emails was silently
dropped there. Fix:
- app/static/logo-email.png: logo asset, attached as an inline MIME
part with Content-ID instead of base64-embedded in <img src>
- app/main.py _send_email(): builds a multipart/related message and
attaches the logo as cid:mont_logo whenever html_body references it
- all 6 client-facing access emails (approve/reject x 3 call sites):
switched from dark gradient theme to a light theme (solid white
card, dark text) - Outlook does not support CSS gradients either,
so the old dark card + light text would have rendered as invisible
light text on a default white background on top of the missing logo
- footer contact address: mont@mont.ru -> RGalyaviev@mont.ru
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- app/main.py: raise products_raw[:20] cap to [:200] in /api/request-access
and /api/request-more-access - was silently truncating access requests
with more than 20 selected products
- accumulated bundles/product-page WIP (config, models, runtime, templates)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
After status/ready=true, do HEAD request to the iframe URL itself.
If it returns 5xx, keep polling. This prevents Bad Gateway appearing
in the iframe when container is technically registered but not yet serving.
Session view page was loading the iframe immediately on load, causing
Bad Gateway if the container was still starting. Now polls /status
until ready, then sets iframe src.
route_ready returned True on 502 Bad Gateway, causing premature redirect
before the container was actually serving. Now only accepts < 500 responses.
Spinner removed from wait page per user request.
Static SESSION_ID=rdpslot-N did not match the actual session UUID in DB,
so touch requests returned 404, last_access_at was never updated, and sessions
expired immediately after 5 minutes without user activity tracking.
terminate_session_record was explicitly skipping WEBPOOLIDX containers.
Now stops portal-webpool-N in a background thread on any session close,
so ensure_web_pool recreates it fresh for the next user.
- Pool containers got IDLE_TIMEOUT=86400 instead of SESSION_IDLE_SECONDS (300s)
to prevent them from cycling every 5 minutes while idle
- On session expiry, WEBPOOLIDX containers are now stopped so ensure_web_pool()
recreates them fresh for the next user (prevents dirty Chromium state)
- Fill username by specific attrs even without visible password field
(handles two-step forms: username first, password appears after)
- Broad input[type=text] fallback only fires when password field is present
(prevents filling R7-Office font selector and other app inputs)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Prevents autofill from filling app text inputs (e.g. R7 font selector)
after login. Now tryFill() bails out immediately if no input[type=password]
is visible — meaning we are no longer on a login form.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Credentials wrapped in svc-credentials-wrap with toggle button and chevron
- Credentials hidden by default, expand on click (CSS class toggle)
- Autofill: add autofocus selector to catch fields like Zabbix name=name
- Autofill: re-fill username after password fill if Pult/Zabbix cleared it
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add --disable-features=PasswordManagerEnabled to Chromium flags
- Add credentials_enable_service=False and profile.password_manager_enabled=False to Chrome Preferences
- Install chromium-l10n package in Dockerfile for Russian browser UI