111 Commits

Author SHA1 Message Date
ruslan 85bcb304f6 refactor: extract _init_schema and _startup_pools to remove duplication in maintenance.py 2026-07-23 11:16:06 +00:00
ruslan 53e18d92ac refactor: extract _dispatch_post to remove duplicate retry logic 2026-07-23 11:14:25 +00:00
ruslan 2e0d4c78ca refactor: merge duplicate pool status functions into _get_pool_status 2026-07-23 11:13:17 +00:00
ruslan 1e6eadeed2 refactor: remove dead code (safe cleanup)
- main.py: remove duplicate sqlalchemy select import
- main.py: remove unused json import in _telegram_poll_loop
- runtime.py: remove unreachable return after raise LockTimeoutError
- runtime.py: remove _restart_rdp_slot_bg (defined but never called)
2026-07-23 11:07:48 +00:00
ruslan 9839539206 fix: double-check iframe src with HEAD probe before loading
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.
2026-07-23 10:55:42 +00:00
ruslan a1479aa47a fix: view page polls readiness before loading iframe
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.
2026-07-23 10:50:41 +00:00
ruslan b2297c7773 fix: route_ready rejects 5xx responses; remove spinner from wait page
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.
2026-07-23 10:48:16 +00:00
ruslan b89f239d02 feat: add spinner and loading message to session wait page
Wait page was a blank dark screen - users saw nothing and hit refresh.
Now shows spinner + service name + status message from /status endpoint.
2026-07-23 10:43:57 +00:00
ruslan 5e884a39ad fix: rdp-proxy heartbeat now uses sid from URL instead of static SESSION_ID
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.
2026-07-22 08:16:52 +00:00
ruslan c58ed04821 fix: recycle webpool container on TERMINATED sessions too
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.
2026-07-22 08:10:20 +00:00
ruslan ca5d1af2b5 fix: webpool containers now stay alive 24h; recycle on session expiry
- 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)
2026-07-21 13:53:18 +00:00
ruslan 3eff94fbb2 ui: remove credentials badge from WEB sessions, remove Back button from RDP
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 11:07:21 +00:00
ruslan de56959ba2 fix autofill: handle two-step login forms (CommuniGate)
- 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>
2026-07-21 11:02:55 +00:00
ruslan d258f3e0fd fix autofill: stop filling when no visible password field on page
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>
2026-07-21 09:02:22 +00:00
ruslan 51590cea80 seo: add explicit shortcut icon link to all templates for Yandex favicon indexing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 08:38:24 +00:00
ruslan 65b098b702 fix: serve favicon.ico from /favicon.ico route with correct mime type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 08:32:04 +00:00
ruslan a9213eb507 update favicon.ico: multi-size (16/32/48/64/128/256px) from favicon.svg
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 08:27:16 +00:00
ruslan cc5d7b8476 revert: white cards, remove credentials from service tiles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 08:13:38 +00:00
ruslan cdc30f126c fix: add cache-busting v=2 to style.css to force browser reload
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 08:10:29 +00:00
ruslan ccc3a2fb51 fix: force original icon box size with explicit !important overrides
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 08:08:15 +00:00
ruslan 4c2fc455ff revert: restore original icon box size
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 08:05:12 +00:00
ruslan 1fe62af26b revert: remove search bar and credentials collapsible
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 08:04:04 +00:00
ruslan 4d5e01855f dashboard: collapsible credentials block; autofill: fix Pult username field
- 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>
2026-07-21 08:00:02 +00:00
ruslan cfadc72e6c dashboard: glassmorphism cards, compact accent icon box, live search bar
- Tile cards: backdrop-filter blur(22px), rgba white bg, inset highlight
- Hover: translateY(-5px) + brighter glass effect
- Icon box: 68px compact rounded square with blue gradient accent
- Added live search input with glass style (filters by service name)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 07:48:11 +00:00
ruslan 174efa7f57 form: add Select All checkbox to products list in access request form 2026-07-20 20:19:07 +00:00
ruslan b7a5d42644 fix: serve /favicon.ico so search engines find the site icon 2026-07-20 19:37:09 +00:00
ruslan 0ffab2e98f web overlay: add 2s progress bar before showing Enter button 2026-07-20 19:29:44 +00:00
ruslan f1449460d0 nav: hide title text when green Enter button appears on overlay 2026-07-20 19:27:09 +00:00
ruslan 706c647ba6 nav: replace hint text with green Enter button on overlay 2026-07-20 19:24:12 +00:00
ruslan b185049a60 nav: show click-to-continue hint on overlay, fullscreen on that click 2026-07-20 19:21:02 +00:00
ruslan f00d51cd76 nav: fullscreen on first user interaction with screen (browser policy fix) 2026-07-20 19:16:02 +00:00
ruslan e079723922 nav: add labels to buttons, auto-fullscreen on session open 2026-07-20 19:12:47 +00:00
ruslan 709de88c95 fix: encode From header with RFC 2047 to fix email delivery with Cyrillic sender name 2026-07-20 17:06:52 +00:00
ruslan 09833cd298 Replace spinner with mont-emoji(3).gif animation in RDP and WEB overlays 2026-07-20 16:41:58 +00:00
ruslan 81608f8741 webpool: disable password save dialog and add chromium-l10n for Russian UI
- 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
2026-07-20 16:15:44 +00:00
ruslan 806c5d8e1f session wait page: replace visible card with silent dark redirect 2026-07-20 16:04:21 +00:00
ruslan f028f91ce4 universal-runtime: fix JS SyntaxError — remove bash escapes from single-quoted heredoc 2026-07-20 15:57:43 +00:00
ruslan 36570631cf universal-runtime: fix overlay hiding error messages on reconnect 2026-07-20 15:50:54 +00:00
ruslan 19a9764d8e universal-runtime: replace status badge with centered overlay Подготовка приложения... 2026-07-20 15:45:50 +00:00
ruslan f61953f32a rdp-proxy: rename overlay text to Подготовка рабочего стола... 2026-07-20 15:31:42 +00:00
ruslan 8707c72d23 rdp-proxy: pre-connect xfreerdp at container startup for instant sessions 2026-07-20 15:19:51 +00:00
ruslan 283f4f93c9 rdp-proxy: show overlay only after connect, remove waiting phase 2026-07-20 15:16:43 +00:00
ruslan 6f40e9dc72 rdp-proxy: replace corner status with centered fullscreen overlay
- Remove Ожидайте corner badge
- Phase 1 (connecting): centered spinner + Подключение к рабочему столу... + elapsed timer
- Phase 2 (desktop ready): progress bar fills over 5.8s then fades out
- Errors (reconnect) shown in small top-left box only
2026-07-20 15:05:27 +00:00
ruslan 3433e32df3 Handle renewal: extend expiry, add new products, send renewal email 2026-07-08 08:56:53 +00:00
ruslan 4b4b07dd20 Fix Telegram sendMessage: switch to HTML parse mode, escape user data 2026-07-03 11:37:30 +00:00
ruslan 18bb4bfaf4 Update description text: Партнеры MONT и их заказчики 2026-06-15 13:27:03 +00:00
ruslan 2a494dfa5e Reduce left panel title font size to fit narrow column 2026-06-08 09:39:06 +00:00
ruslan 8bf6c44485 Fix left panel overflow: top-align content, allow vertical scroll 2026-06-08 09:37:18 +00:00
ruslan dff17ad56a Move logo inside left panel to fix overlap at any screen size 2026-06-08 09:33:20 +00:00
ruslan 0d22003716 Fix logo overlap on mobile, fix cookie banner persistence 2026-06-08 09:29:39 +00:00
ruslan 986dcf5c84 Update favicon: new SVG and PNG versions 2026-05-29 16:11:26 +00:00
ruslan efa1c26e5d Email improvements: domain-aware portal URL, embedded logo, fix product list color
- Store request origin domain in PendingAccessRequest.portal_url
- Use per-request portal URL in approval/rejection emails
- Embed logo as base64 so it displays without external image loading
- Fix 'Предоставлен доступ к продуктам' text color to match body color
- Switch Telegram polling to 30-second interval with single-worker flock fix
2026-05-29 16:10:40 +00:00
ruslan e5ea23487e Add Telegram approval flow: inline buttons, user creation, email notifications 2026-05-29 14:41:42 +00:00
ruslan ad1e781040 Add cookie consent banner with localStorage persistence 2026-05-28 11:42:24 +00:00
ruslan 4a16813942 Update logo link to www.mont.ru 2026-05-28 11:17:12 +00:00
ruslan 202b609b3e Update Made by Galyaviev email to ruslan@ipcom.su 2026-05-28 11:12:32 +00:00
ruslan 56cc2495a6 Logo links to mont.ru or 4mont.ru depending on domain 2026-05-28 11:10:35 +00:00
ruslan 59bfb66ae4 Open maps.mont.ru when accessed via stand.mont.ru 2026-05-28 10:44:33 +00:00
ruslan 7918c16a59 Replace contact modal with mailto link on Made by Galyaviev 2026-05-28 10:29:17 +00:00
ruslan de49bffc1b Update privacy redirect to mont.ru/ru-ru/privacy 2026-05-28 10:22:54 +00:00
ruslan 7765d666ef Replace privacy page with 301 redirect to mont.ru/ru-ru/agreement 2026-05-28 10:22:02 +00:00
ruslan 46cc29fd4a Remove hardcoded domains from privacy policy 2026-05-28 09:54:55 +00:00
ruslan 1c4f351f10 Replace mont.com with mont.ru 2026-05-28 09:53:53 +00:00
ruslan 9d2a25af10 Make canonical/OG URLs domain-aware via x-forwarded-proto 2026-05-28 09:53:21 +00:00
ruslan a10f2c240a Force white color on consent label span 2026-05-28 09:49:37 +00:00
ruslan 823b28983c Set consent label text color to white 2026-05-28 09:48:08 +00:00
ruslan 984f8c324f Improve consent checkbox text visibility 2026-05-28 09:47:14 +00:00
ruslan e88e33e7e8 Add privacy policy page and consent checkbox to both modals (152-FZ compliance) 2026-05-28 09:44:17 +00:00
ruslan 9de7538309 Remove redundant tls.domains labels, NPM handles TLS 2026-05-28 09:02:23 +00:00
ruslan df12c54c76 Add stand.mont.ru as second domain with TLS cert 2026-05-28 09:00:34 +00:00
ruslan 8ab7df12a1 Replace logo.png with new version, rename МОНТ→MONT everywhere 2026-05-27 17:39:16 +00:00
ruslan dd7288beaf Add SVG favicon 120x120 with MONT branding, add SVG link to all templates 2026-05-18 07:19:50 +00:00
ruslan 5c06440e4d Add Yandex Webmaster verification file 2026-05-15 13:02:42 +00:00
ruslan 3d531238d7 SEO: meta tags, OG, JSON-LD, robots.txt, sitemap, keywords in content 2026-05-15 12:50:55 +00:00
ruslan 4b2618191d Add Telegram config vars to config.py 2026-05-14 08:19:56 +00:00
ruslan a4b69b0018 Fix real IP: trust upstream forwardedHeaders in Traefik, use X-Forwarded-For[0] 2026-05-14 07:41:51 +00:00
ruslan 73c7d006c7 Fix _get_real_ip: use X-Real-IP from NPM instead of X-Forwarded-For 2026-05-14 07:33:49 +00:00
ruslan 1aa9db8e2a Add real IP + geo location to Telegram notifications 2026-05-14 07:27:23 +00:00
ruslan 4b5b9906a8 Remove access modal subtitle 2026-05-14 07:00:42 +00:00
ruslan d65b7a0d35 Fix submit forms: use getElementById instead of stale closures, fix texts 2026-05-14 06:52:20 +00:00
ruslan a60279ae3e Fix JS syntax errors in modal success buttons (broken single quotes) 2026-05-14 06:45:16 +00:00
ruslan b36b3f6325 Add contact modal, success messages, form reset on open 2026-05-14 06:42:09 +00:00
ruslan ba8f3cf753 Validate all modal fields at once with per-field highlighting 2026-05-14 06:34:29 +00:00
ruslan eb05bcac53 Add email and phone validation to request-access modal 2026-05-14 06:29:37 +00:00
ruslan beb2781123 Fix request-access: add Telegram env to compose, fix log_event calls 2026-05-14 06:28:58 +00:00
ruslan a0b1754ddb Rename modal title to Запрос на доступ 2026-05-14 06:25:04 +00:00
ruslan ce39573618 Fix login-request-btn width after a→button change 2026-05-14 06:24:11 +00:00
ruslan f740420a77 Add request access modal on login page with Telegram notification
- Modal form: name, company, email, phone (required), manager (optional), product checkboxes
- Products loaded from DB via GET /api/public/services-by-category (public route)
- POST /api/request-access sends styled Telegram message with divider and emojis
- Dark-themed modal matching login page design
- CSS: overlay, card, fields, checkbox list, error, footer buttons
2026-05-14 06:22:39 +00:00
ruslan 9530f3e957 fix: autofill dispatches focus/blur/keyup/InputEvent for SPA frameworks 2026-05-13 12:14:44 +00:00
ruslan 3e640fbe15 revert: restore CSS to working state before logo column experiments 2026-05-12 13:29:08 +00:00
ruslan eda342cf43 fix: logo in own grid column, content never overlaps 2026-05-12 13:27:09 +00:00
ruslan e8d1515f89 fix: reserve space for fixed page-logo, prevent content overlap 2026-05-12 13:24:05 +00:00
ruslan 4f52ae8566 style: add gap between avatar and username in header 2026-05-12 13:20:47 +00:00
ruslan 30ce37b906 fix: remove first_name/last_name from all models except User 2026-05-12 13:01:29 +00:00
ruslan 4268b19a37 fix: remove first_name/last_name from Service model (was added by mistake) 2026-05-12 12:59:38 +00:00
ruslan 6aa40eb5c2 feat: add first_name/last_name to users, avatar in header, neutral dashboard bg 2026-05-12 12:51:47 +00:00
ruslan dedf4aea77 dashboard: replace informal welcome text with product name 2026-05-12 12:44:44 +00:00
ruslan fff7ecdce2 login: left panel 1/4, distrib button, text tweaks, dashboard light theme polish 2026-05-12 12:42:12 +00:00
ruslan 666093f1c6 login: logo only in top-left corner, left panel 1/3 right panel 2/3 2026-05-11 08:54:25 +00:00
ruslan 020793a3e2 redesign: stylish two-column login page (dark navy split layout) 2026-05-11 08:50:02 +00:00
ruslan 55da535f44 feat: project description block on login page 2026-05-11 08:43:50 +00:00
ruslan d7716fa569 design: stylish request-access button on login page 2026-05-08 13:05:02 +00:00
ruslan 116ffba42d feat: add Yandex Metrika counter (id=109119977) to all pages 2026-05-08 13:03:46 +00:00
ruslan b9f1e375d3 feat: request access button on login page (mailto rgalyaviev) 2026-05-08 12:59:15 +00:00
ruslan e516cc4aeb feat: Russian locale (ru_RU.UTF-8) in universal-runtime for Chromium UI language 2026-05-08 12:54:16 +00:00
ruslan 52cb1fd3d6 feat: fullscreen button in nav panel for web and rdp services 2026-05-08 12:00:39 +00:00
ruslan 1dc5a0eb34 fix: replace favicon with correct local file 2026-05-07 07:26:15 +00:00
ruslan 983065ac9f fix: use favicon.png instead of svg 2026-05-07 07:23:56 +00:00
ruslan 7e94ddaf8d fix: rdp target field readonly, host/port/domain/sec oninput rebuilds target 2026-05-06 11:43:26 +00:00
ruslan 2edb804660 fix: autofill login first then password, continuous re-fill for SPA re-renders 2026-05-05 11:05:09 +00:00
ruslan f994674327 merge: refactor/split-main-py into main 2026-05-04 14:46:05 +00:00
26 changed files with 3005 additions and 518 deletions
+12
View File
@@ -33,3 +33,15 @@ ICON_UPLOAD_TYPES = {
"image/webp": "webp", "image/webp": "webp",
} }
SERVICE_ICONS_DIR = Path("static/service-icons") SERVICE_ICONS_DIR = Path("static/service-icons")
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
TELEGRAM_API_URL = os.getenv("TELEGRAM_API_URL", "https://api.telegram.org/bot")
SMTP_HOST = os.getenv("SMTP_HOST", "mail.hosting.reg.ru")
SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM_EMAIL = os.getenv("SMTP_FROM_EMAIL", "stand@4mont.ru")
SMTP_FROM_NAME = os.getenv("SMTP_FROM_NAME", "\u0418\u043d\u0444\u0440\u0430\u0441\u0442\u0443\u043a\u0442\u0443\u0440\u043d\u044b\u0439 \u043f\u043e\u043b\u0438\u0433\u043e\u043d MONT")
PORTAL_URL = os.getenv("PORTAL_URL", "https://stend.4mont.ru")
+101
View File
@@ -0,0 +1,101 @@
import os
import sys
# SQLite in-memory for tests — no PostgreSQL needed
os.environ.setdefault("DATABASE_URL", "sqlite:///./test.db")
os.environ.setdefault("SIGNING_KEY", "test-signing-key-32chars-padding!!")
os.environ.setdefault("ADMIN_USERNAME", "admin")
os.environ.setdefault("ADMIN_PASSWORD", "testpass123")
os.environ.setdefault("PUBLIC_HOST", "http://localhost")
os.environ.setdefault("ENABLE_STARTUP_MAINTENANCE", "0")
os.environ.setdefault("LOG_LEVEL", "ERROR")
import pytest
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
# Patch docker before importing app modules
_docker_mock = MagicMock()
_docker_mock.containers.get.side_effect = Exception("no docker in tests")
_docker_mock.containers.list.return_value = []
_docker_mock.containers.run.return_value = MagicMock(id="test-container-id", status="running", name="test")
sys.modules.setdefault("docker", MagicMock())
with patch("docker.from_env", return_value=_docker_mock):
with patch("runtime.ensure_schema_compatibility", lambda: None):
from database import Base, get_db
import main as app_module
engine = create_engine(
"sqlite:///./test.db",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(bind=engine)
Base.metadata.create_all(bind=engine)
# Create admin user
from auth import hash_password
from models import User
with TestingSessionLocal() as db:
if not db.query(User).filter(User.username == "admin").first():
import datetime as _dt
db.add(User(
username="admin",
password_hash=hash_password("testpass123"),
is_admin=True,
active=True,
expires_at=_dt.datetime(2099, 1, 1, tzinfo=_dt.timezone.utc),
))
db.commit()
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app_module.app.dependency_overrides[get_db] = override_get_db
@pytest.fixture(scope="session")
def client():
with patch("runtime.docker_client", return_value=_docker_mock), \
patch("runtime.ensure_schema_compatibility", lambda: None):
with TestClient(app_module.app, raise_server_exceptions=False, base_url="https://testserver") as c:
yield c
def _extract_csrf(client) -> str:
"""GET / → берём CSRF из HTML и ставим куку вручную."""
import re
r = client.get("/", follow_redirects=True)
assert r.status_code == 200
m = re.search(r'name=["\']csrf_token["\'][^>]*value=["\']([^"\']+)["\']'
r'|value=["\']([^"\']+)["\'][^>]*name=["\']csrf_token["\']', r.text)
if not m:
m = re.search(r'csrf_token["\']?\s*[=:]\s*["\']([^"\']{10,})["\']', r.text)
assert m, f"csrf_token not found in HTML: {r.text[:500]}"
csrf = m.group(1) or m.group(2)
client.cookies.set("portal_csrf", csrf, domain="testserver")
return csrf
@pytest.fixture(scope="session")
def auth_client(client):
"""Client with admin session cookie."""
csrf = _extract_csrf(client)
r = client.post("/login", data={
"username": "admin",
"password": "testpass123",
"csrf_token": csrf,
}, follow_redirects=True)
assert r.status_code == 200, f"login failed: {r.status_code} {r.text[:300]}"
return client
+798 -70
View File
File diff suppressed because one or more lines are too long
+46 -63
View File
@@ -23,6 +23,7 @@ from runtime import (
ensure_web_pool, ensure_web_pool,
start_rdp_slot_container, start_rdp_slot_container,
stop_runtime_container, stop_runtime_container,
web_pool_container_name,
) )
logger = logging.getLogger("portal") logger = logging.getLogger("portal")
@@ -58,10 +59,16 @@ def cleanup_loop():
rdp_slots_to_restart.append(int(cid.split(":", 1)[1])) rdp_slots_to_restart.append(int(cid.split(":", 1)[1]))
except Exception: except Exception:
pass pass
elif cid.startswith("WEBPOOLIDX:"):
try:
slot = int(cid.split(":", 1)[1])
stop_runtime_container(web_pool_container_name(slot))
except Exception:
logger.exception("webpool_slot_recycle_failed cid=%s", cid)
elif cid and not ( elif cid and not (
cid.startswith("POOL:") cid.startswith("POOL:")
or cid.startswith("POOLIDX:") or cid.startswith("POOLIDX:")
or cid.startswith("WEBPOOLIDX:") or cid.startswith("RDPSLOT:")
): ):
stop_runtime_container(cid) stop_runtime_container(cid)
sess.status = SessionStatus.EXPIRED sess.status = SessionStatus.EXPIRED
@@ -113,49 +120,56 @@ def try_acquire_maintenance_leader() -> bool:
return True return True
def run_maintenance_service() -> None: def _init_schema() -> None:
logger.info("maintenance_service_bootstrap_started")
with open("/tmp/portal-schema.lock", "w") as lock_file: with open("/tmp/portal-schema.lock", "w") as lock_file:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
ensure_schema_compatibility() ensure_schema_compatibility()
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
ensure_icons_dir() ensure_icons_dir()
bootstrap_admin() bootstrap_admin()
def _startup_pools(db) -> None:
ensure_universal_pool()
ensure_web_pool()
for svc in db.scalars(
select(Service).where(
Service.active == True,
Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
)
).all():
if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
ensure_warm_pool(svc)
elif svc.type == ServiceType.RDP:
slots = db.scalars(select(RdpSlot).where(RdpSlot.service_id == svc.id)).all()
for slot in slots:
try:
cname = _rdp_slot_container_name(svc.slug, slot.id)
try:
c = docker_client().containers.get(cname)
if c.status != "running":
c.start()
except docker.errors.NotFound:
start_rdp_slot_container(slot, svc)
slot.container_name = cname
except Exception:
logger.exception("startup_rdp_slot_start_failed slot_id=%s", slot.id)
if slots:
db.commit()
def run_maintenance_service() -> None:
logger.info("maintenance_service_bootstrap_started")
_init_schema()
maintenance_lock = open("/tmp/portal-maintenance.lock", "w") maintenance_lock = open("/tmp/portal-maintenance.lock", "w")
fcntl.flock(maintenance_lock.fileno(), fcntl.LOCK_EX) fcntl.flock(maintenance_lock.fileno(), fcntl.LOCK_EX)
logger.info("maintenance_service_leader_acquired") logger.info("maintenance_service_leader_acquired")
db = SessionLocal() db = SessionLocal()
try: try:
ensure_universal_pool() _startup_pools(db)
ensure_web_pool()
for svc in db.scalars(
select(Service).where(
Service.active == True,
Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
)
).all():
if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
ensure_warm_pool(svc)
elif svc.type == ServiceType.RDP:
slots = db.scalars(select(RdpSlot).where(RdpSlot.service_id == svc.id)).all()
for slot in slots:
try:
cname = _rdp_slot_container_name(svc.slug, slot.id)
try:
c = docker_client().containers.get(cname)
if c.status != "running":
c.start()
except docker.errors.NotFound:
start_rdp_slot_container(slot, svc)
slot.container_name = cname
except Exception:
logger.exception("startup_rdp_slot_start_failed slot_id=%s", slot.id)
if slots:
db.commit()
finally: finally:
db.close() db.close()
@@ -164,13 +178,7 @@ def run_maintenance_service() -> None:
def on_startup() -> None: def on_startup() -> None:
with open("/tmp/portal-schema.lock", "w") as lock_file: _init_schema()
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
Base.metadata.create_all(bind=engine)
ensure_schema_compatibility()
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
ensure_icons_dir()
bootstrap_admin()
if not try_acquire_maintenance_leader(): if not try_acquire_maintenance_leader():
logger.info("maintenance_leader_skipped") logger.info("maintenance_leader_skipped")
return return
@@ -178,32 +186,7 @@ def on_startup() -> None:
if ENABLE_STARTUP_MAINTENANCE: if ENABLE_STARTUP_MAINTENANCE:
db = SessionLocal() db = SessionLocal()
try: try:
ensure_universal_pool() _startup_pools(db)
ensure_web_pool()
for svc in db.scalars(
select(Service).where(
Service.active == True,
Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
)
).all():
if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
ensure_warm_pool(svc)
elif svc.type == ServiceType.RDP:
slots = db.scalars(select(RdpSlot).where(RdpSlot.service_id == svc.id)).all()
for slot in slots:
try:
cname = _rdp_slot_container_name(svc.slug, slot.id)
try:
c = docker_client().containers.get(cname)
if c.status != "running":
c.start()
except docker.errors.NotFound:
start_rdp_slot_container(slot, svc)
slot.container_name = cname
except Exception:
logger.exception("startup_rdp_slot_start_failed slot_id=%s", slot.id)
if slots:
db.commit()
finally: finally:
db.close() db.close()
+17
View File
@@ -32,6 +32,8 @@ class User(Base):
expires_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), index=True) expires_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), index=True)
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False) is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
first_name: Mapped[str] = mapped_column(String(64), default="")
last_name: Mapped[str] = mapped_column(String(64), default="")
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc)) created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
@@ -113,3 +115,18 @@ class AuditLog(Base):
action: Mapped[str] = mapped_column(String(128), index=True) action: Mapped[str] = mapped_column(String(128), index=True)
details: Mapped[str] = mapped_column(Text) details: Mapped[str] = mapped_column(Text)
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc), index=True) created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc), index=True)
class PendingAccessRequest(Base):
__tablename__ = "pending_access_requests"
id: Mapped[str] = mapped_column(String(12), primary_key=True)
name: Mapped[str] = mapped_column(String(256))
company: Mapped[str] = mapped_column(String(256))
email: Mapped[str] = mapped_column(String(256))
phone: Mapped[str] = mapped_column(String(64))
manager: Mapped[str] = mapped_column(String(256), default="")
products_json: Mapped[str] = mapped_column(Text, default="[]")
portal_url: Mapped[str] = mapped_column(String(256), default="")
status: Mapped[str] = mapped_column(String(16), default="pending")
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
+38 -75
View File
@@ -145,7 +145,7 @@ def ensure_web_pool(target_size: Optional[int] = None) -> None:
"portal.pool.slot": str(i), "portal.pool.slot": str(i),
} }
env = { env = {
"IDLE_TIMEOUT": str(SESSION_IDLE_SECONDS), "IDLE_TIMEOUT": "86400",
"ENABLE_HEARTBEAT": "0", "ENABLE_HEARTBEAT": "0",
"SESSION_ID": f"webpool-{i}", "SESSION_ID": f"webpool-{i}",
"X11VNC_FLAGS": X11VNC_FLAGS, "X11VNC_FLAGS": X11VNC_FLAGS,
@@ -194,16 +194,15 @@ def ensure_web_pool(target_size: Optional[int] = None) -> None:
break break
def get_universal_pool_status() -> dict: def _get_pool_status(desired_size: int, name_fn) -> dict:
desired = max(0, UNIVERSAL_POOL_SIZE) desired = max(0, desired_size)
if desired <= 0: if desired <= 0:
return {"desired": 0, "running": 0, "total": 0, "health": "down", "names": []} return {"desired": 0, "running": 0, "total": 0, "health": "down", "names": []}
d = docker_client() d = docker_client()
names = [universal_container_name(i) for i in range(desired)]
containers = [] containers = []
for name in names: for i in range(desired):
try: try:
containers.append(d.containers.get(name)) containers.append(d.containers.get(name_fn(i)))
except Exception: except Exception:
continue continue
running = sum(1 for c in containers if c.status == "running") running = sum(1 for c in containers if c.status == "running")
@@ -217,27 +216,12 @@ def get_universal_pool_status() -> dict:
} }
def get_universal_pool_status() -> dict:
return _get_pool_status(UNIVERSAL_POOL_SIZE, universal_container_name)
def get_web_pool_status() -> dict: def get_web_pool_status() -> dict:
desired = max(0, WEB_POOL_SIZE) return _get_pool_status(WEB_POOL_SIZE, web_pool_container_name)
if desired <= 0:
return {"desired": 0, "running": 0, "total": 0, "health": "down", "names": []}
d = docker_client()
names = [web_pool_container_name(i) for i in range(desired)]
containers = []
for name in names:
try:
containers.append(d.containers.get(name))
except Exception:
continue
running = sum(1 for c in containers if c.status == "running")
health = "ok" if running >= min(desired, 1) else "down"
return {
"desired": desired,
"running": running,
"total": len(containers),
"names": sorted(c.name for c in containers),
"health": health,
}
def acquire_universal_slot(db: Session) -> int: def acquire_universal_slot(db: Session) -> int:
@@ -303,6 +287,20 @@ def sanitize_client_resolution(width: Optional[int], height: Optional[int]) -> t
return clamped_width, clamped_height return clamped_width, clamped_height
def _dispatch_post(url: str, payload: dict) -> None:
last_exc = None
for _ in range(max(1, POOL_DISPATCH_RETRIES)):
try:
resp = requests.post(url, json=payload, timeout=POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS)
resp.raise_for_status()
return
except Exception as exc:
last_exc = exc
time.sleep(max(0.0, POOL_DISPATCH_SLEEP_SECONDS))
if last_exc:
raise last_exc
def dispatch_universal_target(slot: int, service: Service, width: Optional[int] = None, height: Optional[int] = None) -> None: def dispatch_universal_target(slot: int, service: Service, width: Optional[int] = None, height: Optional[int] = None) -> None:
name = universal_container_name(slot) name = universal_container_name(slot)
url = "" url = ""
@@ -328,17 +326,7 @@ def dispatch_universal_target(slot: int, service: Service, width: Optional[int]
else: else:
raise HTTPException(status_code=400, detail="Universal pool supports WEB/RDP only") raise HTTPException(status_code=400, detail="Universal pool supports WEB/RDP only")
last_exc = None _dispatch_post(url, payload)
for _ in range(max(1, POOL_DISPATCH_RETRIES)):
try:
resp = requests.post(url, json=payload, timeout=POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS)
resp.raise_for_status()
return
except Exception as exc:
last_exc = exc
time.sleep(max(0.0, POOL_DISPATCH_SLEEP_SECONDS))
if last_exc:
raise last_exc
def dispatch_web_pool_target(slot: int, service: Service, width: Optional[int] = None, height: Optional[int] = None) -> None: def dispatch_web_pool_target(slot: int, service: Service, width: Optional[int] = None, height: Optional[int] = None) -> None:
@@ -350,17 +338,7 @@ def dispatch_web_pool_target(slot: int, service: Service, width: Optional[int] =
if width and height: if width and height:
payload["width"] = width payload["width"] = width
payload["height"] = height payload["height"] = height
last_exc = None _dispatch_post(url, payload)
for _ in range(max(1, POOL_DISPATCH_RETRIES)):
try:
resp = requests.post(url, json=payload, timeout=POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS)
resp.raise_for_status()
return
except Exception as exc:
last_exc = exc
time.sleep(max(0.0, POOL_DISPATCH_SLEEP_SECONDS))
if last_exc:
raise last_exc
def create_runtime_container(service: Service, session_id: str): def create_runtime_container(service: Service, session_id: str):
@@ -532,7 +510,7 @@ def wait_for_session_route(session_id: str, timeout_seconds: int = 6) -> bool:
allow_redirects=False, allow_redirects=False,
timeout=1.5, timeout=1.5,
) )
if resp.status_code != 404: if resp.status_code < 500:
return True return True
except Exception: except Exception:
pass pass
@@ -554,7 +532,7 @@ def route_ready(path: str) -> bool:
timeout=1.5, timeout=1.5,
verify=verify, verify=verify,
) )
if resp.status_code != 404: if resp.status_code < 500:
return True return True
except Exception: except Exception:
continue continue
@@ -701,27 +679,6 @@ def disconnect_rdp_slot(slot_id: int) -> None:
db.close() db.close()
def _restart_rdp_slot_bg(slot_id: int) -> None:
db = SessionLocal()
try:
slot = db.get(RdpSlot, slot_id)
if not slot or not slot.container_name:
return
service = db.get(Service, slot.service_id)
if not service:
return
try:
d = docker_client()
c = d.containers.get(slot.container_name)
c.restart(timeout=10)
logger.info("rdp_slot_container_restarted slot_id=%s", slot_id)
except docker.errors.NotFound:
start_rdp_slot_container(slot, service)
except Exception:
logger.exception("rdp_slot_container_restart_failed slot_id=%s", slot_id)
finally:
db.close()
def stop_runtime_container(container_id: Optional[str]) -> None: def stop_runtime_container(container_id: Optional[str]) -> None:
if not container_id: if not container_id:
@@ -745,8 +702,15 @@ def terminate_session_record(
return return
old_status = sess.status old_status = sess.status
cid = sess.container_id or "" cid = sess.container_id or ""
if stop_container and cid and not cid.startswith(("POOL:", "POOLIDX:", "WEBPOOLIDX:", "RDPSLOT:")): if stop_container and cid:
stop_runtime_container(cid) if cid.startswith("WEBPOOLIDX:"):
try:
slot = int(cid.split(":", 1)[1])
threading.Thread(target=stop_runtime_container, args=(web_pool_container_name(slot),), daemon=True).start()
except Exception:
logger.exception("webpool_slot_recycle_failed cid=%s", cid)
elif not cid.startswith(("POOL:", "POOLIDX:", "RDPSLOT:")):
stop_runtime_container(cid)
if cid.startswith("RDPSLOT:"): if cid.startswith("RDPSLOT:"):
try: try:
slot_id = int(cid.split(":", 1)[1]) slot_id = int(cid.split(":", 1)[1])
@@ -808,6 +772,7 @@ def ensure_schema_compatibility() -> None:
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_password VARCHAR(256) NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_password VARCHAR(256) NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_cred_hint TEXT NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_cred_hint TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS icon_path TEXT NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS icon_path TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE pending_access_requests ADD COLUMN IF NOT EXISTS portal_url VARCHAR(256) NOT NULL DEFAULT ''"))
conn.execute( conn.execute(
text( text(
""" """
@@ -1085,8 +1050,6 @@ def allocator_lock(db: Session, lock_id: int, timeout_seconds: Optional[float] =
time.sleep(max(0.01, poll_seconds)) time.sleep(max(0.01, poll_seconds))
raise LockTimeoutError(f"advisory lock timeout lock_id={lock_id} timeout={timeout_seconds}") raise LockTimeoutError(f"advisory lock timeout lock_id={lock_id} timeout={timeout_seconds}")
return self_nonlocal
def __exit__(self_nonlocal, exc_type, exc, tb): def __exit__(self_nonlocal, exc_type, exc, tb):
return False return False
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 B

After

Width:  |  Height:  |  Size: 13 KiB

+21 -7
View File
@@ -1,11 +1,25 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title desc">
<title id="title">4MONT favicon</title>
<desc id="desc">A compact favicon inspired by the 4MONT logo: a blue geometric 4 and bold black M on a clean rounded square.</desc>
<defs> <defs>
<linearGradient id='g' x1='0' y1='0' x2='1' y2='1'> <linearGradient id="blue" x1="5" y1="8" x2="38" y2="58" gradientUnits="userSpaceOnUse">
<stop offset='0%' stop-color='#1e6aa8'/> <stop offset="0" stop-color="#0C5CAD"/>
<stop offset='100%' stop-color='#2f8ec8'/> <stop offset="0.45" stop-color="#004C92"/>
<stop offset="1" stop-color="#002F62"/>
</linearGradient> </linearGradient>
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#001A33" flood-opacity="0.16"/>
</filter>
</defs> </defs>
<rect width='64' height='64' rx='14' fill='#eaf3fb'/>
<rect x='14' y='14' width='36' height='36' transform='rotate(45 32 32)' fill='url(#g)'/> <rect x="3" y="3" width="58" height="58" rx="14" fill="#FFFFFF"/>
<rect x='34' y='9' width='14' height='14' transform='rotate(45 41 16)' fill='#b7c0c9'/> <rect x="3.5" y="3.5" width="57" height="57" rx="13.5" fill="none" stroke="#E6EAF0"/>
<g filter="url(#softShadow)">
<!-- Stylized 4 -->
<path fill="url(#blue)" d="M7 38.7 27.4 10.2h10.4v28.5h6.3v8.9h-6.3v7.3H27.9v-7.3H7v-8.9Zm20.9 0V25.2L18 38.7h9.9Z"/>
<!-- Compact M -->
<path fill="#050505" d="M39.2 54.9V10.2h9.4l5.7 16.1 5.7-16.1h9.1v44.7h-8.7V30.2l-4.7 13.3h-3.1l-4.8-13.3v24.7h-8.6Z" transform="translate(-5.4 0)"/>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 498 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 32 KiB

+11
View File
@@ -0,0 +1,11 @@
User-agent: *
Allow: /
Disallow: /admin
Disallow: /api/
Disallow: /go/
Disallow: /s/
Disallow: /w/
Disallow: /u/
Disallow: /rdp/
Sitemap: https://stend.4mont.ru/sitemap.xml
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://stend.4mont.ru/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
</urlset>
+816 -121
View File
@@ -105,8 +105,24 @@ button {
top: 0; top: 0;
z-index: 100; z-index: 100;
} }
.header-left { display: flex; align-items: center; } .header-left { display: flex; align-items: center; gap: 0.65rem; }
.header-right { display: flex; align-items: center; gap: 0.75rem; } .header-right { display: flex; align-items: center; gap: 0.75rem; }
.user-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: linear-gradient(135deg, #1e7dc8 0%, #1360a0 100%);
color: #fff;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.02em;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(14,80,160,0.4);
}
.header-username { .header-username {
color: #ffffff; color: #ffffff;
font-size: 1rem; font-size: 1rem;
@@ -742,7 +758,7 @@ button {
100% { transform: translate3d(-6%, 0, 0); } 100% { transform: translate3d(-6%, 0, 0); }
} }
/* Effects disabled per request: no parallax, no animated clouds, no hover motion */ /* ========== Dashboard refined light theme ========== */
.parallax-scene, .parallax-scene,
.parallax-layer, .parallax-layer,
.cloud-layer, .cloud-layer,
@@ -753,161 +769,486 @@ button {
animation: none !important; animation: none !important;
transform: none !important; transform: none !important;
} }
/* Richer background */
.dashboard-page { .dashboard-page {
background: linear-gradient(180deg, #a8d2ee 0%, #d8ecf9 100%) !important; background:
} radial-gradient(ellipse 80% 40% at 15% 0%, rgba(120,185,240,0.35) 0%, transparent 60%),
.dashboard-page .panel, radial-gradient(ellipse 60% 50% at 90% 100%, rgba(90,160,230,0.22) 0%, transparent 60%),
.dashboard-page .tile { linear-gradient(170deg, #deeffe 0%, #c8e4f8 45%, #b0d5f0 100%) !important;
backdrop-filter: none !important;
background: rgba(255, 255, 255, 0.9) !important;
border: 1px solid rgba(198, 218, 235, 0.9) !important;
} }
/* Header — keep dark, refine */
.dashboard-page .header { .dashboard-page .header {
background: rgba(10, 25, 41, 0.92) !important; background: rgba(8, 20, 38, 0.94) !important;
backdrop-filter: blur(8px) !important; backdrop-filter: blur(12px) !important;
border-bottom: 1px solid rgba(255,255,255,0.08) !important; border-bottom: 1px solid rgba(255,255,255,0.09) !important;
} box-shadow: 0 1px 0 rgba(0,0,0,0.18) !important;
.dashboard-page .svc-credentials {
background: rgba(220, 238, 252, 0.9) !important;
border-color: rgba(180, 210, 235, 0.85) !important;
} }
/* Top info panel */
.dashboard-page .panel { .dashboard-page .panel {
backdrop-filter: none !important;
background: rgba(255, 255, 255, 0.82) !important;
border: 1px solid rgba(185, 215, 238, 0.75) !important;
box-shadow: 0 4px 24px rgba(15, 64, 103, 0.1) !important;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
box-sizing: border-box; box-sizing: border-box;
} }
.tile,
.tile:hover, /* Admin intro banner */
.made-by, .dashboard-page .admin-intro {
.made-by:hover, background: linear-gradient(135deg, #e8f5ff 0%, #d8ecfa 100%);
border: 1px solid rgba(160, 210, 240, 0.7);
color: #0e3d5f;
border-radius: 10px;
}
/* Rules banner */
.dashboard-page .rules-banner {
background: linear-gradient(135deg, #f0f8ff 0%, #e4f2fd 100%);
border: 1px solid rgba(160, 210, 240, 0.6);
}
.dashboard-page .rules-pill {
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(180, 215, 238, 0.7);
color: #1e4a6a;
}
/* Category chips */
.dashboard-page .category-chip {
background: rgba(255, 255, 255, 0.75) !important;
border: 1px solid rgba(160, 205, 235, 0.75) !important;
color: #1a4a6e !important;
font-weight: 600;
}
.dashboard-page .category-chip.active {
background: linear-gradient(135deg, #1675b4 0%, #0f5b94 100%) !important;
border-color: transparent !important;
color: #fff !important;
box-shadow: 0 2px 8px rgba(15, 91, 148, 0.35);
}
/* Session notice */
.dashboard-page .session-notice {
background: #e4f3ff;
border-color: #aad4f0;
color: #174768;
}
/* Service tiles — clean white cards with depth */
.dashboard-page .tile {
backdrop-filter: none !important;
background: #ffffff !important;
border: 1px solid rgba(185, 215, 238, 0.7) !important;
box-shadow:
0 1px 3px rgba(15, 64, 103, 0.06),
0 6px 20px rgba(15, 64, 103, 0.09) !important;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease !important;
}
.dashboard-page .tile-wrap:hover .tile {
transform: translateY(-3px) !important;
border-color: rgba(42, 130, 210, 0.45) !important;
box-shadow:
0 2px 6px rgba(15, 64, 103, 0.08),
0 12px 32px rgba(15, 64, 103, 0.16) !important;
}
/* Icon box */
.dashboard-page .tile-icon-box {
width: min(100%, 336px) !important;
aspect-ratio: 1 / 1 !important;
max-height: 180px !important;
border-radius: 16px !important;
background: #f2f8fd !important;
border-color: rgba(185, 215, 238, 0.65) !important;
}
.dashboard-page .tile-icon {
width: 88% !important;
height: 88% !important;
}
/* Credentials block */
.dashboard-page .svc-credentials {
background: linear-gradient(135deg, rgba(220,238,255,0.55) 0%, rgba(200,225,248,0.5) 100%) !important;
border-color: rgba(180, 215, 240, 0.6) !important;
}
/* Category badges on tile */
.dashboard-page .service-cat-badge {
background: #edf5fc;
border-color: rgba(175, 210, 235, 0.8);
color: #1e4a6e;
}
/* Made by footer */
.dashboard-page .made-by {
color: rgba(20, 60, 95, 0.38) !important;
text-shadow: 0 1px 4px rgba(255,255,255,0.5) !important;
font-family: "Segoe UI", sans-serif !important;
font-size: 0.82rem !important;
font-weight: 500 !important;
letter-spacing: 0.04em;
}
.dashboard-page .made-by:hover {
color: rgba(15, 91, 148, 0.65) !important;
}
/* Service name in glass card */
.dashboard-page .tile h3 {
font-size: 1rem !important;
font-weight: 700 !important;
color: #0a1d2e !important;
text-shadow: 0 1px 2px rgba(255,255,255,0.5) !important;
letter-spacing: 0.01em !important;
}
/* Suppress transitions on non-tile elements */
.category-chip, .category-chip,
.category-chip.active, .category-chip.active,
.btn-link, .btn-link,
.btn-link.secondary, .btn-link.secondary,
button { button {
transition: none !important; transition: none !important;
transform: none !important;
}
.tile:hover {
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.06) !important;
border-color: transparent !important;
} }
/* 4-up desktop grid with adaptive breakpoints */ /* 4-up desktop grid */
.service-grid { .service-grid {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }
@media (max-width: 1400px) { @media (max-width: 1400px) {
.service-grid { .service-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
grid-template-columns: repeat(3, minmax(0, 1fr));
}
} }
@media (max-width: 1050px) { @media (max-width: 1050px) {
.service-grid { .service-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.service-grid { .service-grid { grid-template-columns: 1fr; }
grid-template-columns: 1fr;
}
} }
/* Stylish login page */ /* ========== Login page redesign ========== */
.login-page { .login-wrap {
display: flex;
height: 100vh;
}
.login-corner-logo {
display: block;
height: 36px;
width: auto;
margin-bottom: 1.5rem;
filter: brightness(0) invert(1);
opacity: 0.85;
}
/* LEFT PANEL */
.login-left {
flex: 0 0 25%;
background: linear-gradient(150deg, #050d1a 0%, #091829 55%, #0e2344 100%);
position: relative; position: relative;
background: display: flex;
radial-gradient(circle at 12% 15%, rgba(255, 255, 255, 0.55) 0, rgba(255, 255, 255, 0) 34%), align-items: flex-start;
radial-gradient(circle at 88% 82%, rgba(255, 255, 255, 0.45) 0, rgba(255, 255, 255, 0) 32%), justify-content: center;
linear-gradient(145deg, #0f4c7c 0%, #1a77b8 48%, #5db2de 100%); overflow-x: hidden;
} overflow-y: auto;
.login-shell { padding: 3rem;
width: min(560px, 94vw); box-sizing: border-box;
margin: 0 auto;
display: grid;
justify-items: center;
border-radius: 18px;
padding: clamp(1.1rem, 2.4vw, 2rem);
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(255, 255, 255, 0.7);
box-shadow: 0 18px 46px rgba(9, 44, 72, 0.28);
backdrop-filter: blur(6px);
}
.login-title {
color: #0f3553;
font-weight: 700;
letter-spacing: 0.01em;
}
.login-subtitle {
margin: -0.35rem 0 0.85rem;
text-align: center;
color: #355a77;
font-size: 0.96rem;
}
.login-panel {
width: 100% !important;
justify-self: center;
min-width: 0;
background: #ffffff;
border: 1px solid #d3e4f2;
box-shadow: 0 10px 26px rgba(20, 66, 101, 0.12);
}
.login-panel label {
font-size: 0.88rem;
color: #234a68;
font-weight: 600;
}
.login-panel input {
background: #f8fbfe;
border: 1px solid #bfd5e8;
}
.login-panel input:focus {
outline: none;
border-color: #2a82c0;
box-shadow: 0 0 0 3px rgba(42, 130, 192, 0.16);
}
.login-panel button {
margin-top: 0.3rem;
font-weight: 700;
background: linear-gradient(180deg, #1675b4 0%, #0f5b94 100%);
}
.login-page .auth-error {
margin-bottom: 0.8rem;
}
@media (max-width: 700px) {
.login-shell {
border-radius: 14px;
padding: 1rem;
backdrop-filter: none;
}
.login-subtitle {
font-size: 0.9rem;
}
} }
.login-corner-brand { .login-left-glow {
position: fixed; position: absolute;
top: 14px; border-radius: 50%;
left: 16px; pointer-events: none;
z-index: 20;
color: #e8f4ff;
font-size: 1.5rem;
font-weight: 700;
letter-spacing: 0.01em;
text-shadow: 0 2px 8px rgba(9, 44, 72, 0.35);
} }
.login-made-by-wrap { .login-left-glow-top {
position: fixed; width: 480px;
height: 480px;
background: radial-gradient(circle, rgba(30,120,210,0.16) 0%, transparent 70%);
top: -100px;
left: -80px;
}
.login-left-glow-bottom {
width: 350px;
height: 350px;
background: radial-gradient(circle, rgba(22,90,170,0.2) 0%, transparent 70%);
bottom: -80px;
right: -50px;
}
.login-left-inner {
position: relative;
z-index: 2;
max-width: 400px;
width: 100%;
}
.login-left-title {
font-size: clamp(1.2rem, 1.8vw, 2.0rem);
font-weight: 800;
color: #ffffff;
margin: 0 0 1.1rem;
line-height: 1.18;
letter-spacing: -0.025em;
}
.login-left-desc {
font-size: 0.88rem;
color: rgba(168,205,238,0.7);
line-height: 1.75;
margin: 0 0 2rem;
}
.login-features {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.login-feature {
display: flex;
align-items: center;
gap: 0.85rem;
color: rgba(196,226,255,0.82);
font-size: 0.875rem;
font-weight: 500;
}
.login-feature-icon {
width: 34px;
height: 34px;
flex-shrink: 0;
background: rgba(42,130,210,0.15);
border: 1px solid rgba(42,130,210,0.28);
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
}
.login-distrib-btn {
margin-top: 2.5rem;
padding: 0.78rem 1.5rem;
border: 1px solid rgba(255,255,255,0.28);
border-radius: 10px;
background: rgba(255,255,255,0.07);
color: rgba(220,238,255,0.9);
font-size: 0.88rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
width: 100%;
letter-spacing: 0.03em;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.login-distrib-btn:hover {
background: rgba(255,255,255,0.13);
border-color: rgba(255,255,255,0.45);
color: #fff;
}
/* RIGHT PANEL */
.login-right {
flex: 1;
background: #09172a;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
padding: 2.5rem 2rem;
box-sizing: border-box;
}
.login-right::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 100%;
background: linear-gradient(180deg, transparent 0%, rgba(42,130,210,0.25) 30%, rgba(42,130,210,0.25) 70%, transparent 100%);
}
.login-right-inner {
width: 100%;
max-width: 340px;
}
.login-form-title {
font-size: 1.55rem;
font-weight: 700;
color: #e6f2ff;
margin-bottom: 0.3rem;
letter-spacing: -0.02em;
}
.login-form-subtitle {
font-size: 0.82rem;
color: rgba(130,175,215,0.6);
margin-bottom: 2rem;
letter-spacing: 0.02em;
}
.lp-session-notice {
margin-bottom: 1rem;
font-size: 0.85rem;
}
.lp-auth-error {
margin-bottom: 1rem;
}
.login-form {
display: flex;
flex-direction: column;
gap: 1.15rem;
}
.login-field {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.login-field label {
font-size: 0.75rem;
font-weight: 700;
color: rgba(150,190,225,0.75);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.login-field input {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.09);
border-radius: 10px;
padding: 0.78rem 1rem;
color: #daeeff;
font-size: 0.93rem;
font-family: inherit;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
outline: none;
width: 100%;
box-sizing: border-box;
}
.login-field input::placeholder {
color: rgba(130,175,210,0.35);
}
.login-field input:focus {
border-color: rgba(42,130,210,0.6);
box-shadow: 0 0 0 3px rgba(42,130,210,0.12);
background: rgba(255,255,255,0.07);
}
.login-submit {
margin-top: 0.5rem;
padding: 0.82rem 1.5rem;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #1e7dc8 0%, #1360a0 100%);
color: #fff;
font-size: 0.93rem;
font-weight: 700;
font-family: inherit;
cursor: pointer;
transition: filter 0.18s, transform 0.1s;
box-shadow: 0 4px 22px rgba(22,104,180,0.38), inset 0 1px 0 rgba(255,255,255,0.14);
width: 100%;
letter-spacing: 0.02em;
}
.login-submit:hover {
filter: brightness(1.1);
}
.login-submit:active {
transform: translateY(1px);
}
.login-request-btn {
display: block;
width: 100%;
box-sizing: border-box;
text-align: center;
margin-top: 1.1rem;
padding: 0.68rem 1.2rem;
cursor: pointer;
border-radius: 10px;
border: 1px solid rgba(42,130,210,0.22);
background: transparent;
color: rgba(100,175,230,0.7);
font-size: 0.85rem;
font-weight: 600;
letter-spacing: 0.03em;
text-decoration: none;
transition: border-color 0.2s, color 0.2s, background 0.2s;
}
.login-request-btn:hover {
border-color: rgba(42,130,210,0.5);
background: rgba(42,130,210,0.07);
color: rgba(140,200,250,0.9);
}
.login-footer {
position: absolute;
bottom: 1.25rem;
left: 0; left: 0;
right: 0; right: 0;
bottom: 10px;
z-index: 20;
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
.login-made-by {
color: rgba(240, 248, 255, 0.95); .login-footer-link {
text-shadow: 0 2px 10px rgba(9, 44, 72, 0.45); color: rgba(100,145,185,0.45);
font-size: 0.75rem;
text-decoration: none;
transition: color 0.2s;
}
.login-footer-link:hover {
color: rgba(100,175,230,0.7);
}
/* Mobile: stack vertically */
@media (max-width: 820px) {
.login-wrap {
flex-direction: column;
height: auto;
min-height: 100vh;
}
.login-left {
flex: none;
padding: 2.5rem 1.5rem 2rem;
}
.login-left-title {
font-size: 1.5rem;
}
.login-left-desc {
font-size: 0.84rem;
}
.login-right {
padding: 2rem 1.5rem 4rem;
}
.login-right::before { display: none; }
.login-footer { bottom: 0.75rem; }
}
@media (max-width: 480px) {
.login-left { padding: 2rem 1.25rem 1.75rem; }
.login-right { padding: 1.75rem 1.25rem 4rem; }
} }
/* Markdown inside service card comments */ /* Markdown inside service card comments */
@@ -950,3 +1291,357 @@ button {
color: #4a7090; color: #4a7090;
line-height: 1.35; line-height: 1.35;
} }
.request-access-btn {
display: block;
text-align: center;
margin-top: 1.1rem;
padding: 0.72rem 1.2rem;
border-radius: 10px;
border: 1px solid rgba(42,140,214,.45);
background: linear-gradient(135deg, rgba(22,117,180,.18) 0%, rgba(15,91,148,.12) 100%);
color: #6bbfff;
font-size: .9rem;
font-weight: 600;
letter-spacing: .03em;
text-decoration: none;
box-shadow: 0 2px 12px rgba(42,140,214,.15), inset 0 1px 0 rgba(255,255,255,.08);
transition: background .2s, color .2s, border-color .2s, box-shadow .2s;
}
.request-access-btn:hover {
background: linear-gradient(135deg, rgba(42,140,214,.32) 0%, rgba(22,117,180,.22) 100%);
border-color: rgba(42,140,214,.75);
color: #c0dff8;
box-shadow: 0 4px 18px rgba(42,140,214,.28), inset 0 1px 0 rgba(255,255,255,.12);
}
.login-about {
max-width: 420px;
text-align: center;
padding: 0 1rem 2.5rem;
}
.login-about-title {
font-size: 1.25rem;
font-weight: 700;
color: #dce8f5;
margin-bottom: 0.75rem;
letter-spacing: .01em;
}
.login-about-desc {
font-size: .9rem;
color: #7ea8c4;
line-height: 1.65;
margin-bottom: 1.1rem;
}
.login-about-chips {
display: flex;
flex-wrap: wrap;
gap: .5rem;
justify-content: center;
}
.login-chip {
background: rgba(42,140,214,.13);
border: 1px solid rgba(42,140,214,.3);
border-radius: 999px;
padding: .35rem .85rem;
font-size: .8rem;
color: #6bbfff;
font-weight: 600;
}
/* ========== Request Access Modal ========== */
.access-modal-overlay {
position: fixed;
inset: 0;
background: rgba(3, 8, 18, 0.82);
backdrop-filter: blur(6px);
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.access-modal {
background: linear-gradient(150deg, #0b1a2e 0%, #0d2040 100%);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px;
box-shadow: 0 24px 80px rgba(0,0,0,0.65), 0 0 0 1px rgba(42,130,210,0.15);
width: 100%;
max-width: 520px;
max-height: 90vh;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(42,130,210,0.3) transparent;
}
.access-modal-header {
padding: 1.75rem 1.75rem 0;
}
.access-modal-title {
font-size: 1.25rem;
font-weight: 700;
color: #e0f0ff;
margin-bottom: 0.3rem;
}
.access-modal-sub {
font-size: 0.85rem;
color: rgba(160,205,238,0.65);
}
.access-modal-body {
padding: 1.25rem 1.75rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.access-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.access-field label {
font-size: 0.82rem;
font-weight: 600;
color: rgba(160,205,238,0.8);
letter-spacing: 0.02em;
}
.access-field .req {
color: #5aadee;
}
.access-field input[type=text],
.access-field input[type=email],
.access-field input[type=tel] {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
padding: 0.6rem 0.85rem;
color: #daeeff;
font-size: 0.92rem;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.access-field input[type=text]::placeholder,
.access-field input[type=email]::placeholder,
.access-field input[type=tel]::placeholder {
color: rgba(120,170,210,0.35);
}
.access-field input[type=text]:focus,
.access-field input[type=email]:focus,
.access-field input[type=tel]:focus {
border-color: rgba(42,130,210,0.55);
box-shadow: 0 0 0 3px rgba(42,130,210,0.12);
}
.access-products-wrap {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
padding: 0.75rem;
max-height: 200px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(42,130,210,0.3) transparent;
}
.access-products-loading {
color: rgba(140,190,228,0.55);
font-size: 0.85rem;
}
.access-products-group {
margin-bottom: 0.6rem;
}
.access-products-group:last-child {
margin-bottom: 0;
}
.access-products-cat {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(100,165,215,0.6);
margin-bottom: 0.35rem;
}
.access-product-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.2rem 0;
cursor: pointer;
font-size: 0.875rem;
color: rgba(200,228,255,0.8);
}
.access-product-item input[type=checkbox] {
accent-color: #1e7dc8;
width: 14px;
height: 14px;
flex-shrink: 0;
}
.access-product-item:hover span {
color: #daeeff;
}
.access-modal-error {
background: rgba(220,60,60,0.12);
border: 1px solid rgba(220,60,60,0.3);
border-radius: 6px;
padding: 0.55rem 0.85rem;
color: #f08080;
font-size: 0.85rem;
}
.access-modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 0 1.75rem 1.5rem;
}
.access-btn-cancel {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
padding: 0.6rem 1.25rem;
color: rgba(180,215,240,0.75);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.access-btn-cancel:hover {
background: rgba(255,255,255,0.09);
border-color: rgba(255,255,255,0.2);
}
.access-btn-submit {
background: linear-gradient(135deg, #1e7dc8 0%, #1360a0 100%);
border: none;
border-radius: 8px;
padding: 0.6rem 1.5rem;
color: #fff;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s, box-shadow 0.15s;
box-shadow: 0 2px 12px rgba(20,96,160,0.4);
}
.access-btn-submit:hover:not(:disabled) {
opacity: 0.9;
box-shadow: 0 4px 18px rgba(20,96,160,0.55);
}
.access-btn-submit:disabled {
opacity: 0.55;
cursor: default;
}
/* Invalid field highlight in access modal */
.access-field input.am-invalid {
border-color: rgba(220, 70, 70, 0.7) !important;
box-shadow: 0 0 0 3px rgba(220, 70, 70, 0.15) !important;
background: rgba(220, 70, 70, 0.05) !important;
}
/* Access modal success state */
.am-success-msg {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 1.5rem 0.5rem 0.5rem;
gap: 0.6rem;
}
.am-success-icon {
width: 52px;
height: 52px;
border-radius: 50%;
background: linear-gradient(135deg, #1e7dc8, #1360a0);
color: #fff;
font-size: 1.6rem;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 18px rgba(20, 96, 160, 0.4);
}
.am-success-title {
font-size: 1.1rem;
font-weight: 700;
color: #e0f0ff;
}
.am-success-sub {
font-size: 0.88rem;
color: rgba(160, 205, 238, 0.75);
line-height: 1.5;
}
.am-success-sub strong {
color: rgba(200, 230, 255, 0.9);
}
/* Textarea in access modal */
/* Consent checkbox */
.access-consent-field {
margin-top: 12px;
}
.access-consent-label {
display: flex;
align-items: flex-start;
gap: 10px;
cursor: pointer;
font-size: 0.88rem;
color: #ffffff !important;
line-height: 1.45;
}
.access-consent-label span {
color: #ffffff !important;
}
.access-consent-label input[type=checkbox] {
margin-top: 2px;
flex-shrink: 0;
width: 15px;
height: 15px;
accent-color: #2d8cf0;
cursor: pointer;
}
.access-consent-link {
color: #82baee;
text-decoration: underline;
}
.access-consent-link:hover { color: #82baee; }
.am-invalid-consent .access-consent-label {
color: rgba(220, 70, 70, 0.85);
}
.am-invalid-consent .access-consent-link {
color: rgba(220, 100, 100, 0.9);
}
.access-textarea {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
padding: 0.6rem 0.85rem;
color: #daeeff;
font-size: 0.92rem;
font-family: inherit;
outline: none;
resize: vertical;
min-height: 90px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.access-textarea::placeholder {
color: rgba(120,170,210,0.35);
}
.access-textarea:focus {
border-color: rgba(42,130,210,0.55);
box-shadow: 0 0 0 3px rgba(42,130,210,0.12);
}
.access-textarea.am-invalid {
border-color: rgba(220, 70, 70, 0.7) !important;
box-shadow: 0 0 0 3px rgba(220, 70, 70, 0.15) !important;
background: rgba(220, 70, 70, 0.05) !important;
}
/* Footer link as button */
.login-footer-link {
background: none;
border: none;
cursor: pointer;
}
+40 -17
View File
@@ -4,15 +4,28 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Администрирование</title> <title>Администрирование</title>
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css?v=2" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="alternate icon" type="image/png" href="/static/favicon.png" /> <link rel="icon" type="image/png" href="/static/favicon.png" />
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,"script","https://mc.yandex.ru/metrika/tag.js?id=109119977", "ym");
ym(109119977, "init", {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/109119977" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
</head> </head>
<body> <body>
<header class="header"> <header class="header">
<div style="display:flex; align-items:center; gap:0.6rem;"> <div style="display:flex; align-items:center; gap:0.6rem;">
<img src="/static/logo.png" alt="MONT" class="header-logo" /> <a href="https://4mont.ru"><img src="/static/logo.png?v=2" alt="MONT" class="header-logo" /></a>
<div>МОНТ - инфрастуктурный полигон | Админ: {{ admin.username }}</div> <div>MONT - инфрастуктурный полигон | Админ: {{ admin.username }}</div>
</div> </div>
<a href="/" class="btn-link secondary">Главная панель</a> <a href="/" class="btn-link secondary">Главная панель</a>
</header> </header>
@@ -42,8 +55,8 @@
<input class="list-search" id="users_search" placeholder="Поиск пользователя..." oninput="filterList('users_search', '#users_list .user-item')" /> <input class="list-search" id="users_search" placeholder="Поиск пользователя..." oninput="filterList('users_search', '#users_list .user-item')" />
<div class="list-box" id="users_list"> <div class="list-box" id="users_list">
{% for u in users %} {% for u in users %}
<button class="list-item user-item" data-user-id="{{u.id}}" data-filter="{{u.username|lower}}" onclick='selectUser({{u.id}}, {{u.username|tojson}}, {{u.active|tojson}}, {{u.is_admin|tojson}}, {{u.expires_at.isoformat()|tojson}})'> <button class="list-item user-item" data-user-id="{{u.id}}" data-filter="{{u.username|lower}}" onclick='selectUser({{u.id}}, {{u.username|tojson}}, {{u.active|tojson}}, {{u.is_admin|tojson}}, {{u.expires_at.isoformat()|tojson}}, {{u.first_name|tojson}}, {{u.last_name|tojson}})'>
<div>{{u.username}}</div> <div>{{u.username}}{% if u.first_name or u.last_name %} <small style="opacity:.6">— {{ (u.first_name + ' ' + u.last_name)|trim }}</small>{% endif %}</div>
<small class="user-days" data-exp="{{u.expires_at.isoformat()}}"></small> <small class="user-days" data-exp="{{u.expires_at.isoformat()}}"></small>
</button> </button>
{% endfor %} {% endfor %}
@@ -55,6 +68,8 @@
<div class="form-grid"> <div class="form-grid">
<input id="u_id" type="hidden" /> <input id="u_id" type="hidden" />
<input id="u_name" placeholder="username" /> <input id="u_name" placeholder="username" />
<input id="u_first_name" placeholder="Имя" />
<input id="u_last_name" placeholder="Фамилия" />
<input id="u_exp" type="date" required /> <input id="u_exp" type="date" required />
<input id="u_pwd" placeholder="new password (optional)" type="password" /> <input id="u_pwd" placeholder="new password (optional)" type="password" />
<select id="u_active"><option value="true">active</option><option value="false">inactive</option></select> <select id="u_active"><option value="true">active</option><option value="false">inactive</option></select>
@@ -80,6 +95,8 @@
<div class="list-title">Добавить пользователя</div> <div class="list-title">Добавить пользователя</div>
<div class="form-grid"> <div class="form-grid">
<input id="new_u_name" placeholder="username" /> <input id="new_u_name" placeholder="username" />
<input id="new_u_first_name" placeholder="Имя" />
<input id="new_u_last_name" placeholder="Фамилия" />
<input id="new_u_pwd" placeholder="password" type="password" /> <input id="new_u_pwd" placeholder="password" type="password" />
<input id="new_u_exp" type="date" required /> <input id="new_u_exp" type="date" required />
<select id="new_u_active"><option value="true">active</option><option value="false">inactive</option></select> <select id="new_u_active"><option value="true">active</option><option value="false">inactive</option></select>
@@ -295,16 +312,16 @@
<input id="r_id" type="hidden" /> <input id="r_id" type="hidden" />
<input id="r_name" placeholder="Название сервиса (что увидит user)" oninput="autogenSlug('r_name','r_slug')" /> <input id="r_name" placeholder="Название сервиса (что увидит user)" oninput="autogenSlug('r_name','r_slug')" />
<input id="r_slug" placeholder="Системный slug" /> <input id="r_slug" placeholder="Системный slug" />
<input id="r_host" placeholder="RDP host (например 192.168.1.60)" /> <input id="r_host" placeholder="RDP host (например 192.168.1.60)" oninput="buildRdpTarget('r')" />
<input id="r_port" type="number" min="1" max="65535" placeholder="RDP порт, обычно 3389" /> <input id="r_port" type="number" min="1" max="65535" placeholder="RDP порт, обычно 3389" oninput="buildRdpTarget('r')" />
<input id="r_domain" placeholder="Домен (опционально)" /> <input id="r_domain" placeholder="Домен (опционально)" oninput="buildRdpTarget('r')" />
<select id="r_sec"> <select id="r_sec" onchange="buildRdpTarget('r')">
<option value="">auto</option> <option value="">auto</option>
<option value="nla">nla</option> <option value="nla">nla</option>
<option value="tls">tls</option> <option value="tls">tls</option>
<option value="rdp">rdp</option> <option value="rdp">rdp</option>
</select> </select>
<input id="r_target" placeholder="Собранный target (авто)" /> <input id="r_target" placeholder="Собранный target (авто)" readonly style="background:rgba(255,255,255,.05);color:#888;cursor:default" />
<textarea id="r_comment" placeholder="Описание для пользователя"></textarea> <textarea id="r_comment" placeholder="Описание для пользователя"></textarea>
<input id="r_svc_login" placeholder="Логин сервиса (показывается на карточке)" /> <input id="r_svc_login" placeholder="Логин сервиса (показывается на карточке)" />
<input id="r_svc_password" placeholder="Пароль сервиса (показывается на карточке)" /> <input id="r_svc_password" placeholder="Пароль сервиса (показывается на карточке)" />
@@ -381,16 +398,16 @@
<div class="form-grid"> <div class="form-grid">
<input id="new_r_name" placeholder="Название сервиса" oninput="autogenSlug('new_r_name','new_r_slug')" /> <input id="new_r_name" placeholder="Название сервиса" oninput="autogenSlug('new_r_name','new_r_slug')" />
<input id="new_r_slug" placeholder="Системный slug" /> <input id="new_r_slug" placeholder="Системный slug" />
<input id="new_r_host" placeholder="RDP host" /> <input id="new_r_host" placeholder="RDP host" oninput="buildRdpTarget('new_r')" />
<input id="new_r_port" type="number" min="1" max="65535" placeholder="RDP порт (3389)" /> <input id="new_r_port" type="number" min="1" max="65535" placeholder="RDP порт (3389)" oninput="buildRdpTarget('new_r')" />
<input id="new_r_domain" placeholder="Домен (опционально)" /> <input id="new_r_domain" placeholder="Домен (опционально)" oninput="buildRdpTarget('new_r')" />
<select id="new_r_sec"> <select id="new_r_sec" onchange="buildRdpTarget('new_r')">
<option value="">auto</option> <option value="">auto</option>
<option value="nla">nla</option> <option value="nla">nla</option>
<option value="tls">tls</option> <option value="tls">tls</option>
<option value="rdp">rdp</option> <option value="rdp">rdp</option>
</select> </select>
<input id="new_r_target" placeholder="Собранный target (авто)" /> <input id="new_r_target" placeholder="Собранный target (авто)" readonly style="background:rgba(255,255,255,.05);color:#888;cursor:default" />
<textarea id="new_r_comment" placeholder="Описание для пользователя"></textarea> <textarea id="new_r_comment" placeholder="Описание для пользователя"></textarea>
<input id="new_r_svc_login" placeholder="Логин сервиса (необязательно)" /> <input id="new_r_svc_login" placeholder="Логин сервиса (необязательно)" />
<input id="new_r_svc_password" placeholder="Пароль сервиса (необязательно)" /> <input id="new_r_svc_password" placeholder="Пароль сервиса (необязательно)" />
@@ -657,9 +674,11 @@
return r.json(); return r.json();
} }
function selectUser(id, username, active, isAdmin, expiresIso) { function selectUser(id, username, active, isAdmin, expiresIso, firstName, lastName) {
document.getElementById('u_id').value = id; document.getElementById('u_id').value = id;
document.getElementById('u_name').value = username; document.getElementById('u_name').value = username;
document.getElementById('u_first_name').value = firstName || '';
document.getElementById('u_last_name').value = lastName || '';
document.getElementById('u_exp').value = dateFromIso(expiresIso); document.getElementById('u_exp').value = dateFromIso(expiresIso);
document.getElementById('u_pwd').value = ''; document.getElementById('u_pwd').value = '';
document.getElementById('u_active').value = String(active); document.getElementById('u_active').value = String(active);
@@ -673,6 +692,8 @@
if (!expDate) return alert('Выберите дату деактивации'); if (!expDate) return alert('Выберите дату деактивации');
await api('/api/admin/users', 'POST', { await api('/api/admin/users', 'POST', {
username: document.getElementById('new_u_name').value, username: document.getElementById('new_u_name').value,
first_name: document.getElementById('new_u_first_name').value,
last_name: document.getElementById('new_u_last_name').value,
password: document.getElementById('new_u_pwd').value, password: document.getElementById('new_u_pwd').value,
expires_at: expiryToApi(expDate), expires_at: expiryToApi(expDate),
active: document.getElementById('new_u_active').value === 'true', active: document.getElementById('new_u_active').value === 'true',
@@ -688,6 +709,8 @@
if (!expDate) return alert('Выберите дату деактивации'); if (!expDate) return alert('Выберите дату деактивации');
const payload = { const payload = {
username: document.getElementById('u_name').value, username: document.getElementById('u_name').value,
first_name: document.getElementById('u_first_name').value,
last_name: document.getElementById('u_last_name').value,
expires_at: expiryToApi(expDate), expires_at: expiryToApi(expDate),
active: document.getElementById('u_active').value === 'true', active: document.getElementById('u_active').value === 'true',
is_admin: document.getElementById('u_admin').value === 'true', is_admin: document.getElementById('u_admin').value === 'true',
+25 -29
View File
@@ -3,10 +3,23 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>МОНТ - инфрастуктурный полигон</title> <title>MONT - инфрастуктурный полигон</title>
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css?v=2" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="alternate icon" type="image/png" href="/static/favicon.png" /> <link rel="icon" type="image/png" href="/static/favicon.png" />
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,"script","https://mc.yandex.ru/metrika/tag.js?id=109119977", "ym");
ym(109119977, "init", {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/109119977" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
</head> </head>
<body class="dashboard-page"> <body class="dashboard-page">
{% raw %}<style> {% raw %}<style>
@@ -20,20 +33,21 @@
.mw-footer{position:absolute;bottom:1.2rem;left:0;width:100%;text-align:center;font-size:clamp(.65rem,2.8vw,.78rem);color:rgba(160,184,204,.45);font-family:sans-serif} .mw-footer{position:absolute;bottom:1.2rem;left:0;width:100%;text-align:center;font-size:clamp(.65rem,2.8vw,.78rem);color:rgba(160,184,204,.45);font-family:sans-serif}
</style>{% endraw %} </style>{% endraw %}
<div id="mobile-wall"> <div id="mobile-wall">
<img src="/static/logo.png" alt="MONT" style="position:absolute;top:1.2rem;left:50%;transform:translateX(-50%);height:clamp(4rem,16vw,6rem);opacity:.9"> <a href="https://4mont.ru"><img src="/static/logo.png?v=2" alt="MONT" style="position:absolute;top:1.2rem;left:50%;transform:translateX(-50%);height:clamp(4rem,16vw,6rem);opacity:.9"></a>
<div class="mw-icon">🖥️</div> <div class="mw-icon">🖥️</div>
<div class="mw-title">Только для компьютера</div> <div class="mw-title">Только для компьютера</div>
<div class="mw-sub">Инфраструктурный полигон МОНТ оптимизирован для работы на ПК.<br>Пожалуйста, откройте портал с настольного компьютера или ноутбука.</div> <div class="mw-sub">Инфраструктурный полигон MONT оптимизирован для работы на ПК.<br>Пожалуйста, откройте портал с настольного компьютера или ноутбука.</div>
<div class="mw-badge"> <div class="mw-badge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
Минимальная ширина экрана: 1024 px Минимальная ширина экрана: 1024 px
</div> </div>
<div class="mw-footer"><a href="mailto:rgalyaviev@mont.com" style="color:inherit;text-decoration:none">Made by Galyaviev</a></div> <div class="mw-footer"><a href="mailto:ruslan@ipcom.su" style="color:inherit;text-decoration:none">Made by Galyaviev</a></div>
</div> </div>
<header class="header"> <header class="header">
<div class="header-left"> <div class="header-left">
<span class="header-username">{{ user.username }}</span> <div class="user-avatar">{{ ((user.first_name[0] if user.first_name else user.username[0]) + (user.last_name[0] if user.last_name else ''))|upper }}</div>
<span class="header-username">{{ (user.first_name + ' ' + user.last_name)|trim or user.username }}</span>
</div> </div>
<div class="header-right"> <div class="header-right">
{% if user.is_admin %} {% if user.is_admin %}
@@ -45,11 +59,11 @@
</div> </div>
</header> </header>
<div class="page-logo-wrap"> <div class="page-logo-wrap">
<img src="/static/logo.png" alt="MONT" class="page-logo" /> <a href="https://4mont.ru"><img src="/static/logo.png?v=2" alt="MONT" class="page-logo" /></a>
</div> </div>
<main class="admin-layout"> <main class="admin-layout">
<section class="panel"> <section class="panel">
<div class="admin-intro">Добро пожаловать в инфраструктурную песочницу</div> <div class="admin-intro">Инфраструктурный полигон MONT</div>
{% if session_notice %} {% if session_notice %}
<div class="session-notice">{{ session_notice }}</div> <div class="session-notice">{{ session_notice }}</div>
{% endif %} {% endif %}
@@ -87,25 +101,6 @@
</div> </div>
<h3>{{ service.name }}</h3> <h3>{{ service.name }}</h3>
<div class="tile-info-area"> <div class="tile-info-area">
{% if service.svc_login or service.svc_password %}
<div class="svc-credentials">
{% if service.svc_login %}
<div class="svc-cred-row">
<span class="svc-cred-label">Логин</span>
<span class="svc-cred-value">{{ service.svc_login }}</span>
</div>
{% endif %}
{% if service.svc_password %}
<div class="svc-cred-row">
<span class="svc-cred-label">Пароль</span>
<span class="svc-cred-value svc-cred-masked">{{ service.svc_password }}</span>
</div>
{% endif %}
{% if service.svc_cred_hint %}
<p class="svc-cred-hint">{{ service.svc_cred_hint }}</p>
{% endif %}
</div>
{% endif %}
{% if service.comment %} {% if service.comment %}
<div class="tile-comment">{{ service_comment_html.get(service.id, '') }}</div> <div class="tile-comment">{{ service_comment_html.get(service.id, '') }}</div>
{% endif %} {% endif %}
@@ -129,7 +124,7 @@
</div> </div>
{% endfor %} {% endfor %}
</section> </section>
<footer class="made-by-wrap"><a class="made-by" href="mailto:rgalyaviev@mont.com">Made by Galyaviev</a></footer> <footer class="made-by-wrap"><a class="made-by" href="mailto:ruslan@ipcom.su">Made by Galyaviev</a></footer>
</main> </main>
<style> <style>
#loading-overlay{display:none;position:fixed;inset:0;z-index:8888;background:rgba(10,18,28,.88); #loading-overlay{display:none;position:fixed;inset:0;z-index:8888;background:rgba(10,18,28,.88);
@@ -218,6 +213,7 @@
if (loadingOverlay) loadingOverlay.style.display = 'none'; if (loadingOverlay) loadingOverlay.style.display = 'none';
}); });
})(); })();
</script> </script>
</body> </body>
</html> </html>
+525 -19
View File
@@ -1,31 +1,537 @@
{% set _scheme = request.headers.get('x-forwarded-proto', request.url.scheme) %}
{% set base_url = _scheme + '://' + request.url.netloc + '/' %}
<!doctype html> <!doctype html>
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>МОНТ - инфрастуктурный полигон</title> <title>Инфраструктурный полигон MONT — демо и пилоты российского ПО</title>
<link rel="stylesheet" href="/static/style.css" /> <meta name="description" content="Инфраструктурный полигон MONT: демонстрация и пилотное тестирование российского ПО для партнёров и заказчиков. Браузерный доступ к рабочим стендам — без установки и настройки." />
<meta name="keywords" content="инфраструктурный полигон MONT, пилоты MONT, демо MONT, партнёры MONT, демонстрация MONT, российское ПО демо, отечественное ПО тестирование, демостенд ПО" />
<meta name="robots" content="index, follow" />
<link rel="canonical" href="{{ base_url }}" />
<meta property="og:type" content="website" />
<meta property="og:url" content="{{ base_url }}" />
<meta property="og:title" content="Инфраструктурный полигон MONT — демо и пилоты российского ПО" />
<meta property="og:description" content="Демонстрация и тестирование российского ПО для партнёров и заказчиков MONT. Доступ к рабочим стендам прямо в браузере." />
<meta property="og:image" content="{{ base_url }}static/logo.png?v=2" />
<meta property="og:locale" content="ru_RU" />
<meta property="og:site_name" content="Полигон MONT" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Инфраструктурный полигон MONT" />
<meta name="twitter:description" content="Демо и пилоты российского ПО для партнёров и заказчиков MONT." />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Инфраструктурный полигон MONT",
"url": "{{ base_url }}",
"description": "Платформа для демонстрации и пилотного тестирования российского программного обеспечения. Партнеры MONT и их заказчики получают браузерный доступ к рабочим стендам.",
"publisher": {
"@type": "Organization",
"name": "MONT",
"url": "https://www.mont.ru/"
}
}
</script>
<link rel="stylesheet" href="/static/style.css?v=2" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="alternate icon" type="image/png" href="/static/favicon.png" /> <link rel="icon" type="image/png" href="/static/favicon.png" />
<style>
body { background: #070f1c; overflow: hidden; height: 100vh; }
@media (max-width: 820px) { body { overflow: auto; height: auto; } }
</style>
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,"script","https://mc.yandex.ru/metrika/tag.js?id=109119977", "ym");
ym(109119977, "init", {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/109119977" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
</head> </head>
<body> <body>
<div class="login-corner-brand">МОНТ - инфрастуктурный полигон</div> <div class="login-wrap">
<main class="center-box login-page"> <aside class="login-left">
<section class="login-shell"> <div class="login-left-glow login-left-glow-top"></div>
<img src="/static/logo.png" alt="MONT" class="brand-logo brand-logo-fullscreen" /> <div class="login-left-glow login-left-glow-bottom"></div>
<div style="height:3.5rem"></div> <div class="login-left-inner">
{% if session_notice %}<div class="session-notice">{{ session_notice }}</div>{% endif %} <a href="#" onclick="window.open(location.hostname==='stand.mont.ru'?'https://www.mont.ru':'https://4mont.ru','_blank');return false;"><img src="/static/logo.png?v=2" alt="MONT" class="login-corner-logo" /></a>
{% if login_error %}<div class="auth-error">{{ login_error }}</div>{% endif %} <h1 class="login-left-title">Инфраструктурный<br>полигон MONT</h1>
<form method="post" action="/login" class="panel login-panel"> <p class="login-left-desc">Платформа для демонстрации и пилотного тестирования российского ПО. Партнеры MONT и их заказчики получают браузерный доступ к рабочим стендам с отечественными ОС, платформами виртуализации, СРК и другими решениями — без установки и настройки.</p>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" /> <ul class="login-features">
<label>Логин</label> <li class="login-feature">
<input type="text" name="username" placeholder="Введите логин" required /> <span class="login-feature-icon">🖥</span>
<label>Пароль</label> <span>Доступ к рабочим столам ОС</span>
<input type="password" name="password" placeholder="Введите пароль" required /> </li>
<button type="submit">Войти</button> <li class="login-feature">
<span class="login-feature-icon">🌐</span>
<span>Веб-интерфейсы сервисов</span>
</li>
<li class="login-feature">
<span class="login-feature-icon"></span>
<span>Доступ в один клик</span>
</li>
<li class="login-feature">
<span class="login-feature-icon">🔒</span>
<span>Защищённый контур</span>
</li>
</ul>
<button type="button" class="login-distrib-btn" onclick="window.open(location.hostname==='stand.mont.ru'?'https://maps.mont.ru':'https://maps.4mont.ru','_blank')">Продукты нашей дистрибуции</button>
</div>
</aside>
<main class="login-right">
<div class="login-right-inner">
<div class="login-form-title">Вход в систему</div>
<div class="login-form-subtitle">Инфраструктурный полигон</div>
{% if session_notice %}<div class="session-notice lp-session-notice">{{ session_notice }}</div>{% endif %}
{% if login_error %}<div class="auth-error lp-auth-error">{{ login_error }}</div>{% endif %}
<form method="post" action="/login" class="login-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="login-field">
<label>Логин</label>
<input type="text" name="username" placeholder="Введите логин" required autocomplete="username" />
</div>
<div class="login-field">
<label>Пароль</label>
<input type="password" name="password" placeholder="Введите пароль" required autocomplete="current-password" />
</div>
<button type="submit" class="login-submit">Войти</button>
</form> </form>
</section> <button type="button" class="login-request-btn" id="btn-request-access" data-open-access-modal="1">Запросить доступ</button>
</div>
<footer class="login-footer">
<a href="mailto:ruslan@ipcom.su" class="login-footer-link">Made by Galyaviev</a>
</footer>
</main> </main>
<footer class="login-made-by-wrap"><a class="made-by login-made-by" href="mailto:rgalyaviev@mont.com">Made by Galyaviev</a></footer> </div>
<!-- Request Access Modal -->
<div id="access-modal" class="access-modal-overlay" style="display:none" aria-modal="true" role="dialog">
<div class="access-modal">
<div class="access-modal-header">
<div class="access-modal-title">Запрос на доступ</div>
</div>
<div class="access-modal-body">
<div class="access-field">
<label>Имя и фамилия <span class="req">*</span></label>
<input id="am-name" type="text" placeholder="Иван Иванов" />
</div>
<div class="access-field">
<label>Название компании <span class="req">*</span></label>
<input id="am-company" type="text" placeholder="ООО Компания" />
</div>
<div class="access-field">
<label>Email <span class="req">*</span></label>
<input id="am-email" type="email" placeholder="ivan@company.ru" />
</div>
<div class="access-field">
<label>Телефон <span class="req">*</span></label>
<input id="am-phone" type="tel" placeholder="+7 (999) 000-00-00" />
</div>
<div class="access-field">
<label>Ваш менеджер в MONT</label>
<input id="am-manager" type="text" placeholder="Если известно — укажите имя" />
</div>
<div class="access-field">
<label style="display:flex;align-items:center;justify-content:space-between">
Интересующие продукты
<label id="am-select-all-wrap" style="display:none;font-weight:400;font-size:13px;cursor:pointer;gap:6px;align-items:center">
<input type="checkbox" id="am-select-all" style="cursor:pointer" />
<span>Выбрать все</span>
</label>
</label>
<div id="am-products" class="access-products-wrap">
<div class="access-products-loading">Загрузка...</div>
</div>
</div>
<div class="access-consent-field">
<label class="access-consent-label">
<input type="checkbox" id="am-consent" />
<span>Согласен на <a href="/privacy" target="_blank" class="access-consent-link">обработку персональных данных</a></span>
</label>
</div>
<div id="am-error" class="access-modal-error" style="display:none"></div>
</div>
<div class="access-modal-footer">
<button type="button" class="access-btn-cancel" id="am-cancel">Отмена</button>
<button type="button" class="access-btn-submit" id="am-submit">Запросить доступ</button>
</div>
</div>
</div>
<script>
(function() {
const overlay = document.getElementById('access-modal');
const btnCancel = document.getElementById('am-cancel');
const btnSubmit = document.getElementById('am-submit');
const errEl = document.getElementById('am-error');
let productsLoaded = false;
function resetAccessForm() {
if (!document.getElementById('am-name')) {
document.querySelector('.access-modal-body').innerHTML = `
<div class="access-field"><label>Имя и фамилия <span class="req">*</span></label><input id="am-name" type="text" placeholder="Иван Иванов" /></div>
<div class="access-field"><label>Название компании <span class="req">*</span></label><input id="am-company" type="text" placeholder="ООО Компания" /></div>
<div class="access-field"><label>Email <span class="req">*</span></label><input id="am-email" type="email" placeholder="ivan@company.ru" /></div>
<div class="access-field"><label>Телефон <span class="req">*</span></label><input id="am-phone" type="tel" placeholder="+7 (999) 000-00-00" /></div>
<div class="access-field"><label>Ваш менеджер в MONT</label><input id="am-manager" type="text" placeholder="Если известно — укажите имя" /></div>
<div class="access-field"><label>Интересующие продукты</label><div id="am-products" class="access-products-wrap"><div class="access-products-loading">Загрузка...</div></div></div>
<div class="access-consent-field"><label class="access-consent-label"><input type="checkbox" id="am-consent" /><span>Согласен на <a href="/privacy" target="_blank" class="access-consent-link">обработку персональных данных</a></span></label></div>
<div id="am-error" class="access-modal-error" style="display:none"></div>`;
document.querySelector('.access-modal-footer').innerHTML = `<button type="button" class="access-btn-cancel" id="am-cancel">Отмена</button><button type="button" class="access-btn-submit" id="am-submit">Запросить доступ</button>`;
document.getElementById('am-cancel').addEventListener('click', closeModal);
document.getElementById('am-submit').addEventListener('click', submitForm);
document.querySelectorAll('#am-name,#am-company,#am-email,#am-phone').forEach(function(el){ el.addEventListener('input', function(){ el.classList.remove('am-invalid'); }); });
productsLoaded = false;
} else {
['am-name','am-company','am-email','am-phone','am-manager'].forEach(function(id){ var el=document.getElementById(id); if(el){el.value='';el.classList.remove('am-invalid');} });
document.querySelectorAll('#am-products input[type=checkbox]').forEach(function(cb){ cb.checked=false; });
var saBox = document.getElementById('am-select-all'); if (saBox) saBox.checked = false;
var err=document.getElementById('am-error'); if(err) err.style.display='none';
var btn=document.getElementById('am-submit'); if(btn){btn.disabled=false;btn.textContent='Запросить доступ';}
}
}
function openModal() {
resetAccessForm();
overlay.style.display = 'flex';
document.body.style.overflow = 'hidden';
if (!productsLoaded) loadProducts();
}
function closeModal() {
overlay.style.display = 'none';
document.body.style.overflow = '';
errEl.style.display = 'none';
document.querySelectorAll('.am-invalid').forEach(el => el.classList.remove('am-invalid'));
}
window._closeAccessModal = function() { closeModal(); productsLoaded = false; };
async function loadProducts() {
const wrap = document.getElementById('am-products');
try {
const res = await fetch('/api/public/services-by-category');
const data = await res.json();
wrap.innerHTML = '';
for (const [cat, svcs] of Object.entries(data)) {
const group = document.createElement('div');
group.className = 'access-products-group';
group.innerHTML = '<div class="access-products-cat">' + cat + '</div>';
for (const svc of svcs) {
const lbl = document.createElement('label');
lbl.className = 'access-product-item';
lbl.innerHTML = '<input type="checkbox" value="' + svc.name.replace(/"/g, '&quot;') + '" /><span>' + svc.name + '</span>';
group.appendChild(lbl);
}
wrap.appendChild(group);
}
productsLoaded = true;
// Show "select all" checkbox
const saWrap = document.getElementById('am-select-all-wrap');
if (saWrap) saWrap.style.display = 'flex';
const saBox = document.getElementById('am-select-all');
if (saBox) {
saBox.addEventListener('change', function() {
document.querySelectorAll('#am-products input[type=checkbox]').forEach(function(cb){ cb.checked = saBox.checked; });
});
// Keep "select all" in sync when individual boxes change
wrap.addEventListener('change', function(e) {
if (e.target === saBox) return;
const all = [...document.querySelectorAll('#am-products input[type=checkbox]')];
saBox.checked = all.length > 0 && all.every(function(cb){ return cb.checked; });
});
}
} catch(e) {
wrap.innerHTML = '<div class="access-products-loading">Не удалось загрузить список</div>';
}
}
async function submitForm() {
const nameEl = document.getElementById('am-name');
const companyEl = document.getElementById('am-company');
const emailEl = document.getElementById('am-email');
const phoneEl = document.getElementById('am-phone');
const managerEl = document.getElementById('am-manager');
const submitBtn = document.getElementById('am-submit');
const errorEl = document.getElementById('am-error');
const name = nameEl ? nameEl.value.trim() : '';
const company = companyEl ? companyEl.value.trim() : '';
const email = emailEl ? emailEl.value.trim() : '';
const phone = phoneEl ? phoneEl.value.trim() : '';
const manager = managerEl ? managerEl.value.trim() : '';
const checked = [...document.querySelectorAll('#am-products input[type=checkbox]:checked')];
const products = checked.map(c => c.value);
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phoneRe = /^[\+\d][\d\s\-\(\)]{6,18}$/;
const consentEl = document.getElementById('am-consent');
const fields = [
{ el: nameEl, check: () => !!name, msg: 'Введите имя и фамилию' },
{ el: companyEl, check: () => !!company, msg: 'Введите название компании' },
{ el: emailEl, check: () => emailRe.test(email), msg: 'Введите корректный email' },
{ el: phoneEl, check: () => phoneRe.test(phone), msg: 'Введите корректный номер телефона' },
];
const errors = [];
fields.forEach(f => {
if (f.el && !f.check()) { f.el.classList.add('am-invalid'); errors.push(f.msg); }
else if (f.el) f.el.classList.remove('am-invalid');
});
if (!consentEl || !consentEl.checked) {
errors.push('Необходимо согласие на обработку персональных данных');
const cf = document.querySelector('.access-consent-field');
if (cf) cf.classList.add('am-invalid-consent');
} else {
const cf = document.querySelector('.access-consent-field');
if (cf) cf.classList.remove('am-invalid-consent');
}
if (errors.length) {
if (errorEl) { errorEl.textContent = errors.join(' • '); errorEl.style.display = 'block'; }
return;
}
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Отправка...'; }
if (errorEl) errorEl.style.display = 'none';
try {
const res = await fetch('/api/request-access', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, company, email, phone, manager, products}),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d.detail || 'Ошибка отправки');
}
const body = document.querySelector('#access-modal .access-modal-body');
const footer = document.querySelector('#access-modal .access-modal-footer');
body.innerHTML = '<div class="am-success-msg">' +
'<div class="am-success-icon">✓</div>' +
'<div class="am-success-title">Запрос отправлен</div>' +
'<div class="am-success-sub">После утверждения доступы придут на электронную почту <strong>' + email + '</strong></div>' +
'</div>';
footer.innerHTML = '<button type="button" class="access-btn-cancel" onclick="window._closeAccessModal()">Закрыть</button>';
} catch(e) {
const eb = document.getElementById('am-error');
if (eb) { eb.textContent = e.message || 'Ошибка отправки, попробуйте позже'; eb.style.display = 'block'; }
const sb = document.getElementById('am-submit');
if (sb) { sb.disabled = false; sb.textContent = 'Запросить доступ'; }
}
}
// Clear invalid highlight on input
document.querySelectorAll('#am-name,#am-company,#am-email,#am-phone').forEach(el => {
el.addEventListener('input', () => el.classList.remove('am-invalid'));
});
// Wire up request-access button
document.querySelectorAll('.login-request-btn, [data-open-access-modal]').forEach(el => {
el.addEventListener('click', function(e) {
e.preventDefault();
openModal();
});
});
btnCancel.addEventListener('click', closeModal);
btnSubmit.addEventListener('click', submitForm);
overlay.addEventListener('click', function(e) {
if (e.target === overlay) closeModal();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeModal();
});
})();
</script>
<!-- Contact Ruslan Modal -->
<div id="contact-modal" class="access-modal-overlay" style="display:none" aria-modal="true" role="dialog">
<div class="access-modal">
<div class="access-modal-header">
<div class="access-modal-title">Связаться с Русланом</div>
</div>
<div class="access-modal-body" id="cm-body">
<div class="access-field">
<label>Ваше имя <span class="req">*</span></label>
<input id="cm-name" type="text" placeholder="Иван Иванов" />
</div>
<div class="access-field">
<label>Email <span class="req">*</span></label>
<input id="cm-email" type="email" placeholder="ivan@company.ru" />
</div>
<div class="access-field">
<label>Телефон <span class="req">*</span></label>
<input id="cm-phone" type="tel" placeholder="+7 (999) 000-00-00" />
</div>
<div class="access-field">
<label>Сообщение <span class="req">*</span></label>
<textarea id="cm-text" class="access-textarea" placeholder="Ваш вопрос или предложение..." rows="4"></textarea>
</div>
<div class="access-consent-field">
<label class="access-consent-label">
<input type="checkbox" id="cm-consent" />
<span>Согласен на <a href="/privacy" target="_blank" class="access-consent-link">обработку персональных данных</a></span>
</label>
</div>
<div id="cm-error" class="access-modal-error" style="display:none"></div>
</div>
<div class="access-modal-footer" id="cm-footer">
<button type="button" class="access-btn-cancel" id="cm-cancel">Отмена</button>
<button type="button" class="access-btn-submit" id="cm-submit">Отправить</button>
</div>
</div>
</div>
<script>
(function() {
const overlay = document.getElementById('contact-modal');
const btnCancel = document.getElementById('cm-cancel');
const btnSubmit = document.getElementById('cm-submit');
const errEl = document.getElementById('cm-error');
function resetContactForm() {
if (!document.getElementById('cm-name')) {
document.getElementById('cm-body').innerHTML = `
<div class="access-field"><label>Ваше имя <span class="req">*</span></label><input id="cm-name" type="text" placeholder="Иван Иванов" /></div>
<div class="access-field"><label>Email <span class="req">*</span></label><input id="cm-email" type="email" placeholder="ivan@company.ru" /></div>
<div class="access-field"><label>Телефон <span class="req">*</span></label><input id="cm-phone" type="tel" placeholder="+7 (999) 000-00-00" /></div>
<div class="access-field"><label>Сообщение <span class="req">*</span></label><textarea id="cm-text" class="access-textarea" placeholder="Ваш вопрос или предложение..." rows="4"></textarea></div>
<div class="access-consent-field"><label class="access-consent-label"><input type="checkbox" id="cm-consent" /><span>Согласен на <a href="/privacy" target="_blank" class="access-consent-link">обработку персональных данных</a></span></label></div>
<div id="cm-error" class="access-modal-error" style="display:none"></div>`;
document.getElementById('cm-footer').innerHTML = `<button type="button" class="access-btn-cancel" id="cm-cancel">Отмена</button><button type="button" class="access-btn-submit" id="cm-submit">Отправить</button>`;
document.getElementById('cm-cancel').addEventListener('click', closeContact);
document.getElementById('cm-submit').addEventListener('click', submitContact);
document.querySelectorAll('#cm-name,#cm-email,#cm-phone,#cm-text').forEach(function(el){ el.addEventListener('input', function(){ el.classList.remove('am-invalid'); }); });
} else {
['cm-name','cm-email','cm-phone','cm-text'].forEach(function(id){ var el=document.getElementById(id); if(el){el.value='';el.classList.remove('am-invalid');} });
var err=document.getElementById('cm-error'); if(err) err.style.display='none';
var btn=document.getElementById('cm-submit'); if(btn){btn.disabled=false;btn.textContent='Отправить';}
}
}
function openContact() {
resetContactForm();
overlay.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function closeContact() {
overlay.style.display = 'none';
document.body.style.overflow = '';
errEl.style.display = 'none';
document.querySelectorAll('#contact-modal .am-invalid').forEach(el => el.classList.remove('am-invalid'));
}
window._closeContactModal = closeContact;
async function submitContact() {
const nameEl = document.getElementById('cm-name');
const emailEl = document.getElementById('cm-email');
const phoneEl = document.getElementById('cm-phone');
const textEl = document.getElementById('cm-text');
const submitBtn = document.getElementById('cm-submit');
const errorEl = document.getElementById('cm-error');
const name = nameEl ? nameEl.value.trim() : '';
const email = emailEl ? emailEl.value.trim() : '';
const phone = phoneEl ? phoneEl.value.trim() : '';
const text = textEl ? textEl.value.trim() : '';
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phoneRe = /^[\+\d][\d\s\-\(\)]{6,18}$/;
const consentEl = document.getElementById('cm-consent');
const fields = [
{ el: nameEl, check: () => !!name, msg: 'Введите имя' },
{ el: emailEl, check: () => emailRe.test(email), msg: 'Введите корректный email' },
{ el: phoneEl, check: () => phoneRe.test(phone), msg: 'Введите корректный номер телефона' },
{ el: textEl, check: () => !!text, msg: 'Введите сообщение' },
];
const errors = [];
fields.forEach(f => {
if (f.el && !f.check()) { f.el.classList.add('am-invalid'); errors.push(f.msg); }
else if (f.el) f.el.classList.remove('am-invalid');
});
if (!consentEl || !consentEl.checked) {
errors.push('Необходимо согласие на обработку персональных данных');
const cf = document.querySelector('#contact-modal .access-consent-field');
if (cf) cf.classList.add('am-invalid-consent');
} else {
const cf = document.querySelector('#contact-modal .access-consent-field');
if (cf) cf.classList.remove('am-invalid-consent');
}
if (errors.length) {
if (errorEl) { errorEl.textContent = errors.join(' • '); errorEl.style.display = 'block'; }
return;
}
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Отправка...'; }
if (errorEl) errorEl.style.display = 'none';
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, email, phone, text}),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d.detail || 'Ошибка отправки');
}
document.getElementById('cm-body').innerHTML =
'<div class="am-success-msg">' +
'<div class="am-success-icon">✓</div>' +
'<div class="am-success-title">Отправлено</div>' +
'<div class="am-success-sub">Постараюсь ответить в ближайшее время</div>' +
'</div>';
document.getElementById('cm-footer').innerHTML =
'<button type="button" class="access-btn-cancel" onclick="window._closeContactModal()">Закрыть</button>';
} catch(e) {
const eb = document.getElementById('cm-error');
if (eb) { eb.textContent = e.message || 'Ошибка отправки, попробуйте позже'; eb.style.display = 'block'; }
const sb = document.getElementById('cm-submit');
if (sb) { sb.disabled = false; sb.textContent = 'Отправить'; }
}
}
document.querySelectorAll('#cm-name,#cm-email,#cm-phone,#cm-text').forEach(el => {
el.addEventListener('input', () => el.classList.remove('am-invalid'));
});
document.getElementById('btn-contact-ruslan').addEventListener('click', function(e) {
e.preventDefault();
openContact();
});
btnCancel.addEventListener('click', closeContact);
btnSubmit.addEventListener('click', submitContact);
overlay.addEventListener('click', function(e) { if (e.target === overlay) closeContact(); });
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && overlay.style.display !== 'none') closeContact(); });
})();
</script>
<!-- Cookie Banner -->
<div id="cookie-banner" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:99999;background:rgba(7,15,28,0.97);border-top:1px solid rgba(255,255,255,0.1);backdrop-filter:blur(8px);padding:14px 24px;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;">
<p style="margin:0;font-size:0.85rem;color:#c8d8ea;line-height:1.5;flex:1;min-width:200px;">
Мы используем файлы cookie, чтобы сделать работу с сайтом удобнее. Нажмите «Принять», чтобы согласиться с использованием файлов cookie в соответствии с <a href="https://www.mont.ru/ru-ru/confidential" target="_blank" style="color:#5b9bd5;text-decoration:underline;">Политикой конфиденциальности</a>.
</p>
<button onclick="document.getElementById('cookie-banner').style.display='none';localStorage.setItem('cookie_accepted','1');" style="flex-shrink:0;padding:8px 22px;background:linear-gradient(135deg,#1a5db5,#2d8cf0);color:#fff;border:none;border-radius:8px;font-size:0.88rem;font-weight:600;cursor:pointer;white-space:nowrap;">Принять</button>
</div>
<script>
if (!localStorage.getItem('cookie_accepted')) {
document.getElementById('cookie-banner').style.display = 'flex';
}
</script>
</body> </body>
</html> </html>
+137
View File
@@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Политика конфиденциальности — Полигон MONT</title>
<meta name="robots" content="noindex"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
<link rel="icon" type="image/png" href="/static/favicon.png"/>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: linear-gradient(160deg, #070f1c 0%, #0a1f3a 100%);
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: #c8d8ea;
padding: 40px 20px 60px;
}
.wrap {
max-width: 780px;
margin: 0 auto;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: #5b9bd5;
text-decoration: none;
font-size: 0.9rem;
margin-bottom: 32px;
opacity: 0.85;
transition: opacity 0.2s;
}
.back-link:hover { opacity: 1; }
h1 {
font-size: 1.7rem;
font-weight: 700;
color: #e8f1fb;
margin-bottom: 6px;
}
.subtitle {
font-size: 0.9rem;
color: #6a8aaa;
margin-bottom: 36px;
}
h2 {
font-size: 1.05rem;
font-weight: 600;
color: #d0e4f7;
margin: 28px 0 10px;
}
p, li {
font-size: 0.95rem;
line-height: 1.7;
color: #a8bdd4;
}
ul {
padding-left: 20px;
margin-top: 6px;
}
li { margin-bottom: 4px; }
a { color: #5b9bd5; }
.divider {
border: none;
border-top: 1px solid rgba(255,255,255,0.07);
margin: 32px 0;
}
.contact-box {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px;
padding: 20px 24px;
margin-top: 28px;
}
.contact-box p { color: #a8bdd4; }
.contact-box strong { color: #d0e4f7; }
</style>
</head>
<body>
<div class="wrap">
<a class="back-link" href="/">&#8592; Вернуться на главную</a>
<h1>Политика конфиденциальности</h1>
<p class="subtitle">Последнее обновление: 28 мая 2026 г.</p>
<p>Настоящая Политика конфиденциальности описывает, как ООО «МОНТ» (далее — «Оператор») осуществляет сбор, использование и хранение персональных данных пользователей, оставивших заявку на получение доступа к Инфраструктурному полигону MONT.</p>
<h2>1. Оператор персональных данных</h2>
<p>ООО «МОНТ»<br>
Юридический адрес: г. Москва<br>
Сайт: <a href="https://www.mont.ru/" target="_blank">www.mont.ru</a><br>
Email для обращений по вопросам ПД: <a href="mailto:privacy@mont.ru">privacy@mont.ru</a></p>
<h2>2. Какие данные мы собираем</h2>
<p>При заполнении формы запроса доступа мы получаем:</p>
<ul>
<li>Имя и фамилия</li>
<li>Название компании</li>
<li>Адрес электронной почты</li>
<li>Номер телефона</li>
<li>Имя вашего менеджера в MONT (необязательно)</li>
<li>Список интересующих продуктов</li>
</ul>
<h2>3. Цели обработки</h2>
<p>Персональные данные обрабатываются исключительно для рассмотрения заявки на доступ к демостендам и последующей связи с вами по вопросам предоставления доступа.</p>
<h2>4. Правовое основание</h2>
<p>Обработка персональных данных осуществляется на основании вашего явного согласия в соответствии со ст. 6, ч. 1, п. 1 и ст. 9 Федерального закона № 152-ФЗ «О персональных данных».</p>
<h2>5. Передача данных третьим лицам</h2>
<p>Персональные данные не хранятся в базе данных портала. После отправки формы данные передаются ответственным сотрудникам MONT по защищённому каналу для обработки заявки. Данные не продаются и не передаются третьим лицам в коммерческих целях.</p>
<h2>6. Срок хранения</h2>
<p>Данные хранятся в течение срока, необходимого для обработки заявки и предоставления доступа, но не более 1 года с момента подачи заявки, если иное не требуется законодательством РФ.</p>
<h2>7. Ваши права</h2>
<p>В соответствии с 152-ФЗ вы вправе:</p>
<ul>
<li>получить информацию об обработке ваших персональных данных;</li>
<li>потребовать уточнения, блокирования или уничтожения ваших данных;</li>
<li>отозвать согласие на обработку персональных данных в любой момент.</li>
</ul>
<p>Для реализации любого из перечисленных прав направьте запрос на <a href="mailto:privacy@mont.ru">privacy@mont.ru</a>.</p>
<h2>8. Защита данных</h2>
<p>Передача данных осуществляется по зашифрованному соединению (HTTPS). Доступ к данным имеют только уполномоченные сотрудники MONT.</p>
<hr class="divider"/>
<div class="contact-box">
<p><strong>Вопросы по обработке персональных данных:</strong><br>
<a href="mailto:privacy@mont.ru">privacy@mont.ru</a></p>
</div>
</div>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
pytest==8.3.5
httpx==0.28.1
pytest-asyncio==0.24.0
+103
View File
@@ -0,0 +1,103 @@
"""
Smoke-tests: проверяем что все ключевые роуты не падают с NameError/ImportError.
Не проверяем бизнес-логику — только что страницы отдают ответ.
"""
import pytest
# ── Публичные страницы ──────────────────────────────────────────────────────
def test_index_anonymous(client):
"""Главная без авторизации — либо страница сервисов, либо логин."""
r = client.get("/", follow_redirects=True)
assert r.status_code == 200
def test_login_form_on_index(client):
"""Форма логина рендерится на /."""
r = client.get("/", follow_redirects=True)
assert r.status_code == 200
assert "csrf" in r.text.lower() or "login" in r.text.lower() or "пароль" in r.text.lower()
def _get_csrf(client):
from conftest import _extract_csrf
return _extract_csrf(client)
def test_login_wrong_password(client):
csrf = _get_csrf(client)
r = client.post("/login", data={
"username": "admin",
"password": "wrongpass",
"csrf_token": csrf,
})
assert r.status_code in (200, 401)
def test_login_csrf_fail(client):
r = client.post("/login", data={
"username": "admin",
"password": "testpass123",
"csrf_token": "bad-token",
})
assert r.status_code == 403
def test_login_no_such_method(client):
r = client.get("/login")
assert r.status_code in (200, 405) # только документируем поведение
# ── Авторизованные страницы ─────────────────────────────────────────────────
def test_login_success(auth_client):
r = auth_client.get("/", follow_redirects=True)
assert r.status_code == 200
def test_admin_page(auth_client):
r = auth_client.get("/admin")
assert r.status_code == 200
def test_admin_requires_auth(client):
from fastapi.testclient import TestClient
import main as app_module
fresh = TestClient(app_module.app, raise_server_exceptions=False)
r = fresh.get("/admin", follow_redirects=False)
assert r.status_code in (302, 303, 401, 403)
# ── API роуты ───────────────────────────────────────────────────────────────
def test_api_services_list(auth_client):
r = auth_client.get("/api/admin/services")
assert r.status_code in (200, 404, 405)
def test_api_users_list(auth_client):
r = auth_client.get("/api/admin/users")
assert r.status_code in (200, 404, 405)
def test_api_categories(auth_client):
r = auth_client.get("/api/admin/categories")
assert r.status_code in (200, 404, 405)
# ── Несуществующие роуты ────────────────────────────────────────────────────
def test_404(client):
r = client.get("/this-does-not-exist-xyz")
assert r.status_code == 404
def test_session_unknown(auth_client):
r = auth_client.get("/s/00000000-0000-0000-0000-000000000000/")
assert r.status_code in (200, 302, 303, 404)
def test_go_unknown_slug(auth_client):
r = auth_client.get("/go/nonexistent-service-slug", follow_redirects=False)
assert r.status_code in (302, 303, 404)
+21 -1
View File
@@ -57,6 +57,16 @@ services:
WEB_RESOLUTION_MAX_WIDTH: ${WEB_RESOLUTION_MAX_WIDTH:-3840} WEB_RESOLUTION_MAX_WIDTH: ${WEB_RESOLUTION_MAX_WIDTH:-3840}
WEB_RESOLUTION_MAX_HEIGHT: ${WEB_RESOLUTION_MAX_HEIGHT:-2160} WEB_RESOLUTION_MAX_HEIGHT: ${WEB_RESOLUTION_MAX_HEIGHT:-2160}
ENABLE_STARTUP_MAINTENANCE: ${ENABLE_STARTUP_MAINTENANCE:-0} ENABLE_STARTUP_MAINTENANCE: ${ENABLE_STARTUP_MAINTENANCE:-0}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
TELEGRAM_API_URL: ${TELEGRAM_API_URL:-https://api.telegram.org/bot}
SMTP_HOST: ${SMTP_HOST:-mail.hosting.reg.ru}
SMTP_PORT: ${SMTP_PORT:-465}
SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-}
PORTAL_URL: ${PORTAL_URL:-https://stend.4mont.ru}
depends_on: depends_on:
- db - db
volumes: volumes:
@@ -65,7 +75,7 @@ services:
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.docker.network=portal_net - traefik.docker.network=portal_net
- traefik.http.routers.portal.rule=Host(`${PUBLIC_HOST}`) - traefik.http.routers.portal.rule=Host(`${PUBLIC_HOST}`) || Host(`stand.mont.ru`)
- traefik.http.routers.portal.entrypoints=websecure - traefik.http.routers.portal.entrypoints=websecure
- traefik.http.routers.portal.tls=true - traefik.http.routers.portal.tls=true
- traefik.http.routers.portal.tls.certresolver=letsencrypt - traefik.http.routers.portal.tls.certresolver=letsencrypt
@@ -107,6 +117,16 @@ services:
WEB_RESOLUTION_MAX_WIDTH: ${WEB_RESOLUTION_MAX_WIDTH:-3840} WEB_RESOLUTION_MAX_WIDTH: ${WEB_RESOLUTION_MAX_WIDTH:-3840}
WEB_RESOLUTION_MAX_HEIGHT: ${WEB_RESOLUTION_MAX_HEIGHT:-2160} WEB_RESOLUTION_MAX_HEIGHT: ${WEB_RESOLUTION_MAX_HEIGHT:-2160}
ENABLE_STARTUP_MAINTENANCE: ${ENABLE_STARTUP_MAINTENANCE:-0} ENABLE_STARTUP_MAINTENANCE: ${ENABLE_STARTUP_MAINTENANCE:-0}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
TELEGRAM_API_URL: ${TELEGRAM_API_URL:-https://api.telegram.org/bot}
SMTP_HOST: ${SMTP_HOST:-mail.hosting.reg.ru}
SMTP_PORT: ${SMTP_PORT:-465}
SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-}
PORTAL_URL: ${PORTAL_URL:-https://stend.4mont.ru}
depends_on: depends_on:
- db - db
volumes: volumes:
+98 -28
View File
File diff suppressed because one or more lines are too long
+5 -4
View File
@@ -182,11 +182,12 @@ class Handler(BaseHTTPRequestHandler):
if __name__ == "__main__": if __name__ == "__main__":
if not RDP_HOST: if RDP_HOST:
log.warning("RDP_HOST not set — connect calls will fail") log.info("pre-connecting xfreerdp at startup")
if _load_state():
log.info("restoring state: reconnecting xfreerdp")
_should_be_connected = True _should_be_connected = True
_save_state()
_launch() _launch()
else:
log.warning("RDP_HOST not set — skipping pre-connect")
log.info("manager started on :7001") log.info("manager started on :7001")
HTTPServer(("0.0.0.0", 7001), Handler).serve_forever() HTTPServer(("0.0.0.0", 7001), Handler).serve_forever()
+10
View File
@@ -1,8 +1,18 @@
entryPoints: entryPoints:
web: web:
address: ":80" address: ":80"
forwardedHeaders:
trustedIPs:
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"
websecure: websecure:
address: ":443" address: ":443"
forwardedHeaders:
trustedIPs:
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"
providers: providers:
docker: docker:
+8 -1
View File
@@ -1,10 +1,14 @@
FROM debian:bookworm-slim FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive \
LANG=ru_RU.UTF-8 \
LC_ALL=ru_RU.UTF-8 \
LANGUAGE=ru_RU:ru
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
tini \ tini \
chromium \ chromium \
chromium-l10n \
xvfb \ xvfb \
x11vnc \ x11vnc \
fluxbox \ fluxbox \
@@ -17,6 +21,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
x11-utils \ x11-utils \
fonts-dejavu-core \ fonts-dejavu-core \
python3-cryptography \ python3-cryptography \
locales \
&& sed -i 's/# ru_RU.UTF-8/ru_RU.UTF-8/' /etc/locale.gen \
&& locale-gen \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
File diff suppressed because one or more lines are too long
+58 -22
View File
@@ -30,8 +30,6 @@ _lock = threading.Lock()
_AUTOFILL_CONTENT_JS = r""" _AUTOFILL_CONTENT_JS = r"""
(function() { (function() {
const CREDS = __CREDS__; const CREDS = __CREDS__;
let userFilled = false;
let passFilled = false;
console.log('[PortalAutofill] loaded for', location.href); console.log('[PortalAutofill] loaded for', location.href);
function isVisible(el) { function isVisible(el) {
@@ -51,7 +49,8 @@ _AUTOFILL_CONTENT_JS = r"""
'input[name*="email" i]:not([type="password"]):not([type="hidden"]), ' + 'input[name*="email" i]:not([type="password"]):not([type="hidden"]), ' +
'input[id*="user" i]:not([type="password"]):not([type="hidden"]), ' + 'input[id*="user" i]:not([type="password"]):not([type="hidden"]), ' +
'input[id*="login" i]:not([type="password"]):not([type="hidden"]), ' + 'input[id*="login" i]:not([type="password"]):not([type="hidden"]), ' +
'input[id*="email" i]:not([type="password"]):not([type="hidden"])' 'input[id*="email" i]:not([type="password"]):not([type="hidden"]), ' +
'input[autofocus]:not([type="password"]):not([type="hidden"]):not([type="checkbox"]):not([type="radio"])'
); );
for (const el of candidates) { for (const el of candidates) {
if (isVisible(el)) return el; if (isVisible(el)) return el;
@@ -96,39 +95,68 @@ _AUTOFILL_CONTENT_JS = r"""
for (const el of list) { for (const el of list) {
if (isVisible(el)) return el; if (isVisible(el)) return el;
} }
return list[0] || null; return null;
} }
function setNativeValue(el, v) { function setNativeValue(el, v) {
if (!el) return false; if (!el) return false;
if (el.value === v) return true; if (el.value === v) return true;
el.dispatchEvent(new FocusEvent('focus', { bubbles: true }));
const proto = Object.getPrototypeOf(el); const proto = Object.getPrototypeOf(el);
const desc = Object.getOwnPropertyDescriptor(proto, 'value') || const desc = Object.getOwnPropertyDescriptor(proto, 'value') ||
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value'); Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value');
if (desc && desc.set) desc.set.call(el, v); else el.value = v; if (desc && desc.set) desc.set.call(el, v); else el.value = v;
el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new InputEvent('input', { bubbles: true, data: v, inputType: 'insertText' }));
el.dispatchEvent(new Event('change', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: v.slice(-1) }));
el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
return true; return true;
} }
function tryFill() { function tryFill() {
if (userFilled && passFilled) return;
const p = findPassField(); const p = findPassField();
const u = findUserField(p);
if (CREDS.password && p && !passFilled) { // Fill by specific attrs even on two-step forms (where password isn't visible yet)
const uSpec = findUserFieldByAttrs();
if (CREDS.login && uSpec) {
if (setNativeValue(uSpec, CREDS.login)) {
console.log('[PortalAutofill] user filled (specific)');
}
}
// Without a visible password field don't use broad fallback —
// prevents filling generic app inputs (e.g. R7-Office font selector)
if (!p) return;
// With password field: also try proximity / broad fallback for username
const u = uSpec || findUserFieldNearPassword(p) ||
Array.from(document.querySelectorAll('input[type="text"], input:not([type])'))
.find(isVisible) || null;
if (CREDS.login && u && u !== uSpec) {
if (setNativeValue(u, CREDS.login)) {
console.log('[PortalAutofill] user filled (fallback)');
}
}
if (CREDS.password) {
if (setNativeValue(p, CREDS.password)) { if (setNativeValue(p, CREDS.password)) {
passFilled = true;
console.log('[PortalAutofill] password filled'); console.log('[PortalAutofill] password filled');
} }
} }
if (CREDS.login && u && !userFilled) { // Re-fill guard: some apps clear username after password events
if (setNativeValue(u, CREDS.login)) { const uFinal = u || uSpec;
userFilled = true; if (CREDS.login && uFinal && uFinal.value !== CREDS.login) {
console.log('[PortalAutofill] user filled'); setNativeValue(uFinal, CREDS.login);
} console.log('[PortalAutofill] user re-filled');
} }
if (!CREDS.login) userFilled = true; }
if (!CREDS.password) passFilled = true;
// Watch for SPA re-renders clearing the fields and re-fill continuously
let _filling = false;
function scheduleFill() {
if (_filling) return;
_filling = true;
requestAnimationFrame(() => { tryFill(); _filling = false; });
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
@@ -137,17 +165,22 @@ _AUTOFILL_CONTENT_JS = r"""
tryFill(); tryFill();
} }
const obs = new MutationObserver(() => { // Periodic check for first 30s in case of async SPA resets
if (!(userFilled && passFilled)) tryFill(); let _checks = 0;
}); const _interval = setInterval(() => {
tryFill();
if (++_checks >= 30) clearInterval(_interval);
}, 1000);
const obs = new MutationObserver(scheduleFill);
if (document.documentElement) { if (document.documentElement) {
obs.observe(document.documentElement, { childList: true, subtree: true }); obs.observe(document.documentElement, { childList: true, subtree: true });
} }
const resetAndRefill = () => { const resetAndRefill = () => {
userFilled = !CREDS.login; _checks = 0;
passFilled = !CREDS.password;
setTimeout(tryFill, 150); setTimeout(tryFill, 150);
setTimeout(tryFill, 600);
}; };
['pushState', 'replaceState'].forEach(fn => { ['pushState', 'replaceState'].forEach(fn => {
const orig = history[fn]; const orig = history[fn];
@@ -213,6 +246,9 @@ def _create_chrome_profile() -> str:
"intl": {"accept_languages": "ru-RU,ru,en", "selected_languages": "ru-RU,ru"}, "intl": {"accept_languages": "ru-RU,ru,en", "selected_languages": "ru-RU,ru"},
"translate": {"enabled": False}, "translate": {"enabled": False},
"translate_blocked_languages": ["ru"], "translate_blocked_languages": ["ru"],
"credentials_enable_service": False,
"credentials_enable_autosignin": False,
"profile": {"password_manager_enabled": False},
} }
with open(os.path.join(default_dir, "Preferences"), "w") as f: with open(os.path.join(default_dir, "Preferences"), "w") as f:
json.dump(prefs, f) json.dump(prefs, f)
@@ -367,7 +403,7 @@ def open_web(
"--use-gl=swiftshader", "--use-gl=swiftshader",
"--kiosk", "--kiosk",
"--disable-translate", "--disable-translate",
"--disable-features=TranslateUI,ExtensionsToolbarMenu", "--disable-features=TranslateUI,ExtensionsToolbarMenu,PasswordManagerEnabled",
"--disable-pinch", "--disable-pinch",
"--overscroll-history-navigation=0", "--overscroll-history-navigation=0",
"--ignore-certificate-errors", "--ignore-certificate-errors",