66 Commits

Author SHA1 Message Date
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
23 changed files with 2578 additions and 198 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
+719 -5
View File
File diff suppressed because one or more lines are too long
+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))
+1
View File
@@ -808,6 +808,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(
""" """
Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 B

After

Width:  |  Height:  |  Size: 13 KiB

+22 -8
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"/>
</svg>
<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>

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>
+798 -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,468 @@ 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 {
background: #f2f8fd !important;
border-color: rgba(185, 215, 238, 0.65) !important;
}
/* Credentials block */
.dashboard-page .svc-credentials {
background: linear-gradient(135deg, #edf6ff 0%, #e2f0fb 100%) !important;
border-color: rgba(180, 215, 240, 0.8) !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;
}
/* 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 +1273,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;
}
+38 -16
View File
@@ -6,13 +6,25 @@
<title>Администрирование</title> <title>Администрирование</title>
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css" />
<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 +54,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 +67,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 +94,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 +311,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 +397,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 +673,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 +691,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 +708,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',
+22 -9
View File
@@ -3,10 +3,22 @@
<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" />
<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 +32,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 +58,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 %}
@@ -129,7 +142,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);
+501 -18
View File
@@ -1,31 +1,514 @@
{% 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>
<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" /> <link rel="stylesheet" href="/static/style.css" />
<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>Интересующие продукты</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 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;
} 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:
+9
View File
@@ -59,6 +59,7 @@ cat > /opt/portal/index.html <<HTML
<div class="nav-panel"> <div class="nav-panel">
<button class="nav-btn" id="btn-back" type="button" title="Назад">←</button> <button class="nav-btn" id="btn-back" type="button" title="Назад">←</button>
<button class="nav-btn" id="btn-home" type="button" title="Главная">⌂</button> <button class="nav-btn" id="btn-home" type="button" title="Главная">⌂</button>
<button class="nav-btn" id="btn-fs" type="button" title="На весь экран">⛶</button>
</div> </div>
<script type="module"> <script type="module">
import RFB from './core/rfb.js'; import RFB from './core/rfb.js';
@@ -176,6 +177,14 @@ cat > /opt/portal/index.html <<HTML
document.getElementById('btn-back').addEventListener('click', () => chord(XK_ALT_L, XK_LEFT, 'AltLeft', 'ArrowLeft')); document.getElementById('btn-back').addEventListener('click', () => chord(XK_ALT_L, XK_LEFT, 'AltLeft', 'ArrowLeft'));
document.getElementById('btn-home').addEventListener('click', goHome); document.getElementById('btn-home').addEventListener('click', goHome);
document.getElementById('btn-fs').addEventListener('click', () => {
if (!document.fullscreenElement) document.documentElement.requestFullscreen();
else document.exitFullscreen();
});
document.addEventListener('fullscreenchange', () => {
document.getElementById('btn-fs').textContent = document.fullscreenElement ? '✕' : '⛶';
document.getElementById('btn-fs').title = document.fullscreenElement ? 'Выйти из полного экрана' : 'На весь экран';
});
document.addEventListener('contextmenu', (e) => e.preventDefault()); document.addEventListener('contextmenu', (e) => e.preventDefault());
(function(){ (function(){
+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:
+7 -1
View File
@@ -1,6 +1,9 @@
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 \
@@ -17,6 +20,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
+9
View File
@@ -60,6 +60,7 @@ cat > /opt/portal/index.html <<'HTML'
<div class="nav-panel"> <div class="nav-panel">
<button class="nav-btn" id="btn-back" type="button" title="Назад">←</button> <button class="nav-btn" id="btn-back" type="button" title="Назад">←</button>
<button class="nav-btn" id="btn-home" type="button" title="Главная">⌂</button> <button class="nav-btn" id="btn-home" type="button" title="Главная">⌂</button>
<button class="nav-btn" id="btn-fs" type="button" title="На весь экран">⛶</button>
</div> </div>
<script type="module"> <script type="module">
import RFB from './core/rfb.js'; import RFB from './core/rfb.js';
@@ -253,6 +254,14 @@ cat > /opt/portal/index.html <<'HTML'
document.getElementById('btn-back').addEventListener('click', () => chord(XK_ALT_L, XK_LEFT, 'AltLeft', 'ArrowLeft')); document.getElementById('btn-back').addEventListener('click', () => chord(XK_ALT_L, XK_LEFT, 'AltLeft', 'ArrowLeft'));
document.getElementById('btn-home').addEventListener('click', goHome); document.getElementById('btn-home').addEventListener('click', goHome);
document.getElementById('btn-fs').addEventListener('click', () => {
if (!document.fullscreenElement) document.documentElement.requestFullscreen();
else document.exitFullscreen();
});
document.addEventListener('fullscreenchange', () => {
document.getElementById('btn-fs').textContent = document.fullscreenElement ? '✕' : '⛶';
document.getElementById('btn-fs').title = document.fullscreenElement ? 'Выйти из полного экрана' : 'На весь экран';
});
document.addEventListener('contextmenu', (e) => e.preventDefault()); document.addEventListener('contextmenu', (e) => e.preventDefault());
(function(){ (function(){
+29 -19
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) {
@@ -102,33 +100,40 @@ _AUTOFILL_CONTENT_JS = r"""
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); const u = findUserField(p);
if (CREDS.password && p && !passFilled) { // Fill login FIRST so password-triggered re-render doesn't clear it
if (setNativeValue(p, CREDS.password)) { if (CREDS.login && u) {
passFilled = true;
console.log('[PortalAutofill] password filled');
}
}
if (CREDS.login && u && !userFilled) {
if (setNativeValue(u, CREDS.login)) { if (setNativeValue(u, CREDS.login)) {
userFilled = true;
console.log('[PortalAutofill] user filled'); console.log('[PortalAutofill] user filled');
} }
} }
if (!CREDS.login) userFilled = true; if (CREDS.password && p) {
if (!CREDS.password) passFilled = true; if (setNativeValue(p, CREDS.password)) {
console.log('[PortalAutofill] password filled');
}
}
}
// 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 +142,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];