Compare commits
65 Commits
f994674327
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 18bb4bfaf4 | |||
| 2a494dfa5e | |||
| 8bf6c44485 | |||
| dff17ad56a | |||
| 0d22003716 | |||
| 986dcf5c84 | |||
| efa1c26e5d | |||
| e5ea23487e | |||
| ad1e781040 | |||
| 4a16813942 | |||
| 202b609b3e | |||
| 56cc2495a6 | |||
| 59bfb66ae4 | |||
| 7918c16a59 | |||
| de49bffc1b | |||
| 7765d666ef | |||
| 46cc29fd4a | |||
| 1c4f351f10 | |||
| 9d2a25af10 | |||
| a10f2c240a | |||
| 823b28983c | |||
| 984f8c324f | |||
| e88e33e7e8 | |||
| 9de7538309 | |||
| df12c54c76 | |||
| 8ab7df12a1 | |||
| dd7288beaf | |||
| 5c06440e4d | |||
| 3d531238d7 | |||
| 4b2618191d | |||
| a4b69b0018 | |||
| 73c7d006c7 | |||
| 1aa9db8e2a | |||
| 4b5b9906a8 | |||
| d65b7a0d35 | |||
| a60279ae3e | |||
| b36b3f6325 | |||
| ba8f3cf753 | |||
| eb05bcac53 | |||
| beb2781123 | |||
| a0b1754ddb | |||
| ce39573618 | |||
| f740420a77 | |||
| 9530f3e957 | |||
| 3e640fbe15 | |||
| eda342cf43 | |||
| e8d1515f89 | |||
| 4f52ae8566 | |||
| 30ce37b906 | |||
| 4268b19a37 | |||
| 6aa40eb5c2 | |||
| dedf4aea77 | |||
| fff7ecdce2 | |||
| 666093f1c6 | |||
| 020793a3e2 | |||
| 55da535f44 | |||
| d7716fa569 | |||
| 116ffba42d | |||
| b9f1e375d3 | |||
| e516cc4aeb | |||
| 52cb1fd3d6 | |||
| 1dc5a0eb34 | |||
| 983065ac9f | |||
| 7e94ddaf8d | |||
| 2edb804660 |
@@ -33,3 +33,15 @@ ICON_UPLOAD_TYPES = {
|
||||
"image/webp": "webp",
|
||||
}
|
||||
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
@@ -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
File diff suppressed because one or more lines are too long
@@ -32,6 +32,8 @@ class User(Base):
|
||||
expires_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
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))
|
||||
|
||||
|
||||
@@ -113,3 +115,18 @@ class AuditLog(Base):
|
||||
action: Mapped[str] = mapped_column(String(128), index=True)
|
||||
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)
|
||||
|
||||
|
||||
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))
|
||||
|
||||
@@ -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_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 pending_access_requests ADD COLUMN IF NOT EXISTS portal_url VARCHAR(256) NOT NULL DEFAULT ''"))
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 610 B After Width: | Height: | Size: 13 KiB |
+21
-7
@@ -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>
|
||||
<linearGradient id='g' x1='0' y1='0' x2='1' y2='1'>
|
||||
<stop offset='0%' stop-color='#1e6aa8'/>
|
||||
<stop offset='100%' stop-color='#2f8ec8'/>
|
||||
<linearGradient id="blue" x1="5" y1="8" x2="38" y2="58" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#0C5CAD"/>
|
||||
<stop offset="0.45" stop-color="#004C92"/>
|
||||
<stop offset="1" stop-color="#002F62"/>
|
||||
</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>
|
||||
<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='34' y='9' width='14' height='14' transform='rotate(45 41 16)' fill='#b7c0c9'/>
|
||||
|
||||
<rect x="3" y="3" width="58" height="58" rx="14" fill="#FFFFFF"/>
|
||||
<rect x="3.5" y="3.5" width="57" height="57" rx="13.5" fill="none" stroke="#E6EAF0"/>
|
||||
|
||||
<g filter="url(#softShadow)">
|
||||
<!-- Stylized 4 -->
|
||||
<path fill="url(#blue)" d="M7 38.7 27.4 10.2h10.4v28.5h6.3v8.9h-6.3v7.3H27.9v-7.3H7v-8.9Zm20.9 0V25.2L18 38.7h9.9Z"/>
|
||||
|
||||
<!-- Compact M -->
|
||||
<path fill="#050505" d="M39.2 54.9V10.2h9.4l5.7 16.1 5.7-16.1h9.1v44.7h-8.7V30.2l-4.7 13.3h-3.1l-4.8-13.3v24.7h-8.6Z" transform="translate(-5.4 0)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
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 |
@@ -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
|
||||
@@ -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
@@ -105,8 +105,24 @@ button {
|
||||
top: 0;
|
||||
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; }
|
||||
|
||||
.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 {
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
@@ -742,7 +758,7 @@ button {
|
||||
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-layer,
|
||||
.cloud-layer,
|
||||
@@ -753,161 +769,468 @@ button {
|
||||
animation: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* Richer background */
|
||||
.dashboard-page {
|
||||
background: linear-gradient(180deg, #a8d2ee 0%, #d8ecf9 100%) !important;
|
||||
}
|
||||
.dashboard-page .panel,
|
||||
.dashboard-page .tile {
|
||||
backdrop-filter: none !important;
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
border: 1px solid rgba(198, 218, 235, 0.9) !important;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 40% at 15% 0%, rgba(120,185,240,0.35) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 60% 50% at 90% 100%, rgba(90,160,230,0.22) 0%, transparent 60%),
|
||||
linear-gradient(170deg, #deeffe 0%, #c8e4f8 45%, #b0d5f0 100%) !important;
|
||||
}
|
||||
|
||||
/* Header — keep dark, refine */
|
||||
.dashboard-page .header {
|
||||
background: rgba(10, 25, 41, 0.92) !important;
|
||||
backdrop-filter: blur(8px) !important;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08) !important;
|
||||
}
|
||||
.dashboard-page .svc-credentials {
|
||||
background: rgba(220, 238, 252, 0.9) !important;
|
||||
border-color: rgba(180, 210, 235, 0.85) !important;
|
||||
background: rgba(8, 20, 38, 0.94) !important;
|
||||
backdrop-filter: blur(12px) !important;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.09) !important;
|
||||
box-shadow: 0 1px 0 rgba(0,0,0,0.18) !important;
|
||||
}
|
||||
|
||||
/* Top info 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%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.tile,
|
||||
.tile:hover,
|
||||
.made-by,
|
||||
.made-by:hover,
|
||||
|
||||
/* Admin intro banner */
|
||||
.dashboard-page .admin-intro {
|
||||
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.active,
|
||||
.btn-link,
|
||||
.btn-link.secondary,
|
||||
button {
|
||||
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 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.service-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.service-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 1050px) {
|
||||
.service-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.service-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.service-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.service-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Stylish login page */
|
||||
.login-page {
|
||||
/* ========== Login page redesign ========== */
|
||||
.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;
|
||||
background:
|
||||
radial-gradient(circle at 12% 15%, rgba(255, 255, 255, 0.55) 0, rgba(255, 255, 255, 0) 34%),
|
||||
radial-gradient(circle at 88% 82%, rgba(255, 255, 255, 0.45) 0, rgba(255, 255, 255, 0) 32%),
|
||||
linear-gradient(145deg, #0f4c7c 0%, #1a77b8 48%, #5db2de 100%);
|
||||
}
|
||||
.login-shell {
|
||||
width: min(560px, 94vw);
|
||||
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;
|
||||
}
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 3rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login-corner-brand {
|
||||
position: fixed;
|
||||
top: 14px;
|
||||
left: 16px;
|
||||
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-left-glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.login-made-by-wrap {
|
||||
position: fixed;
|
||||
.login-left-glow-top {
|
||||
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;
|
||||
right: 0;
|
||||
bottom: 10px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.login-made-by {
|
||||
color: rgba(240, 248, 255, 0.95);
|
||||
text-shadow: 0 2px 10px rgba(9, 44, 72, 0.45);
|
||||
|
||||
.login-footer-link {
|
||||
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 */
|
||||
@@ -950,3 +1273,357 @@ button {
|
||||
color: #4a7090;
|
||||
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
@@ -6,13 +6,25 @@
|
||||
<title>Администрирование</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<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>
|
||||
<body>
|
||||
<header class="header">
|
||||
<div style="display:flex; align-items:center; gap:0.6rem;">
|
||||
<img src="/static/logo.png" alt="MONT" class="header-logo" />
|
||||
<div>МОНТ - инфрастуктурный полигон | Админ: {{ admin.username }}</div>
|
||||
<a href="https://4mont.ru"><img src="/static/logo.png?v=2" alt="MONT" class="header-logo" /></a>
|
||||
<div>MONT - инфрастуктурный полигон | Админ: {{ admin.username }}</div>
|
||||
</div>
|
||||
<a href="/" class="btn-link secondary">Главная панель</a>
|
||||
</header>
|
||||
@@ -42,8 +54,8 @@
|
||||
<input class="list-search" id="users_search" placeholder="Поиск пользователя..." oninput="filterList('users_search', '#users_list .user-item')" />
|
||||
<div class="list-box" id="users_list">
|
||||
{% 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}})'>
|
||||
<div>{{u.username}}</div>
|
||||
<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}}{% 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>
|
||||
</button>
|
||||
{% endfor %}
|
||||
@@ -55,6 +67,8 @@
|
||||
<div class="form-grid">
|
||||
<input id="u_id" type="hidden" />
|
||||
<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_pwd" placeholder="new password (optional)" type="password" />
|
||||
<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="form-grid">
|
||||
<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_exp" type="date" required />
|
||||
<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_name" placeholder="Название сервиса (что увидит user)" oninput="autogenSlug('r_name','r_slug')" />
|
||||
<input id="r_slug" placeholder="Системный slug" />
|
||||
<input id="r_host" placeholder="RDP host (например 192.168.1.60)" />
|
||||
<input id="r_port" type="number" min="1" max="65535" placeholder="RDP порт, обычно 3389" />
|
||||
<input id="r_domain" placeholder="Домен (опционально)" />
|
||||
<select id="r_sec">
|
||||
<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" oninput="buildRdpTarget('r')" />
|
||||
<input id="r_domain" placeholder="Домен (опционально)" oninput="buildRdpTarget('r')" />
|
||||
<select id="r_sec" onchange="buildRdpTarget('r')">
|
||||
<option value="">auto</option>
|
||||
<option value="nla">nla</option>
|
||||
<option value="tls">tls</option>
|
||||
<option value="rdp">rdp</option>
|
||||
</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>
|
||||
<input id="r_svc_login" placeholder="Логин сервиса (показывается на карточке)" />
|
||||
<input id="r_svc_password" placeholder="Пароль сервиса (показывается на карточке)" />
|
||||
@@ -381,16 +397,16 @@
|
||||
<div class="form-grid">
|
||||
<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_host" placeholder="RDP host" />
|
||||
<input id="new_r_port" type="number" min="1" max="65535" placeholder="RDP порт (3389)" />
|
||||
<input id="new_r_domain" placeholder="Домен (опционально)" />
|
||||
<select id="new_r_sec">
|
||||
<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)" oninput="buildRdpTarget('new_r')" />
|
||||
<input id="new_r_domain" placeholder="Домен (опционально)" oninput="buildRdpTarget('new_r')" />
|
||||
<select id="new_r_sec" onchange="buildRdpTarget('new_r')">
|
||||
<option value="">auto</option>
|
||||
<option value="nla">nla</option>
|
||||
<option value="tls">tls</option>
|
||||
<option value="rdp">rdp</option>
|
||||
</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>
|
||||
<input id="new_r_svc_login" placeholder="Логин сервиса (необязательно)" />
|
||||
<input id="new_r_svc_password" placeholder="Пароль сервиса (необязательно)" />
|
||||
@@ -657,9 +673,11 @@
|
||||
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_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_pwd').value = '';
|
||||
document.getElementById('u_active').value = String(active);
|
||||
@@ -673,6 +691,8 @@
|
||||
if (!expDate) return alert('Выберите дату деактивации');
|
||||
await api('/api/admin/users', 'POST', {
|
||||
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,
|
||||
expires_at: expiryToApi(expDate),
|
||||
active: document.getElementById('new_u_active').value === 'true',
|
||||
@@ -688,6 +708,8 @@
|
||||
if (!expDate) return alert('Выберите дату деактивации');
|
||||
const payload = {
|
||||
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),
|
||||
active: document.getElementById('u_active').value === 'true',
|
||||
is_admin: document.getElementById('u_admin').value === 'true',
|
||||
|
||||
@@ -3,10 +3,22 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>МОНТ - инфрастуктурный полигон</title>
|
||||
<title>MONT - инфрастуктурный полигон</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<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>
|
||||
<body class="dashboard-page">
|
||||
{% 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}
|
||||
</style>{% endraw %}
|
||||
<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-title">Только для компьютера</div>
|
||||
<div class="mw-sub">Инфраструктурный полигон МОНТ оптимизирован для работы на ПК.<br>Пожалуйста, откройте портал с настольного компьютера или ноутбука.</div>
|
||||
<div class="mw-sub">Инфраструктурный полигон MONT оптимизирован для работы на ПК.<br>Пожалуйста, откройте портал с настольного компьютера или ноутбука.</div>
|
||||
<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>
|
||||
Минимальная ширина экрана: 1024 px
|
||||
</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>
|
||||
|
||||
<header class="header">
|
||||
<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 class="header-right">
|
||||
{% if user.is_admin %}
|
||||
@@ -45,11 +58,11 @@
|
||||
</div>
|
||||
</header>
|
||||
<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>
|
||||
<main class="admin-layout">
|
||||
<section class="panel">
|
||||
<div class="admin-intro">Добро пожаловать в инфраструктурную песочницу</div>
|
||||
<div class="admin-intro">Инфраструктурный полигон MONT</div>
|
||||
{% if session_notice %}
|
||||
<div class="session-notice">{{ session_notice }}</div>
|
||||
{% endif %}
|
||||
@@ -129,7 +142,7 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</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>
|
||||
<style>
|
||||
#loading-overlay{display:none;position:fixed;inset:0;z-index:8888;background:rgba(10,18,28,.88);
|
||||
|
||||
+501
-18
@@ -1,31 +1,514 @@
|
||||
{% set _scheme = request.headers.get('x-forwarded-proto', request.url.scheme) %}
|
||||
{% set base_url = _scheme + '://' + request.url.netloc + '/' %}
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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="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>
|
||||
<body>
|
||||
<div class="login-corner-brand">МОНТ - инфрастуктурный полигон</div>
|
||||
<main class="center-box login-page">
|
||||
<section class="login-shell">
|
||||
<img src="/static/logo.png" alt="MONT" class="brand-logo brand-logo-fullscreen" />
|
||||
<div style="height:3.5rem"></div>
|
||||
{% if session_notice %}<div class="session-notice">{{ session_notice }}</div>{% endif %}
|
||||
{% if login_error %}<div class="auth-error">{{ login_error }}</div>{% endif %}
|
||||
<form method="post" action="/login" class="panel login-panel">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<label>Логин</label>
|
||||
<input type="text" name="username" placeholder="Введите логин" required />
|
||||
<label>Пароль</label>
|
||||
<input type="password" name="password" placeholder="Введите пароль" required />
|
||||
<button type="submit">Войти</button>
|
||||
<div class="login-wrap">
|
||||
<aside class="login-left">
|
||||
<div class="login-left-glow login-left-glow-top"></div>
|
||||
<div class="login-left-glow login-left-glow-bottom"></div>
|
||||
<div class="login-left-inner">
|
||||
<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>
|
||||
<h1 class="login-left-title">Инфраструктурный<br>полигон MONT</h1>
|
||||
<p class="login-left-desc">Платформа для демонстрации и пилотного тестирования российского ПО. Партнеры MONT и их заказчики получают браузерный доступ к рабочим стендам с отечественными ОС, платформами виртуализации, СРК и другими решениями — без установки и настройки.</p>
|
||||
<ul class="login-features">
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
<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, '"') + '" /><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>
|
||||
</html>
|
||||
|
||||
@@ -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="/">← Вернуться на главную</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>
|
||||
@@ -0,0 +1,3 @@
|
||||
pytest==8.3.5
|
||||
httpx==0.28.1
|
||||
pytest-asyncio==0.24.0
|
||||
@@ -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
@@ -57,6 +57,16 @@ services:
|
||||
WEB_RESOLUTION_MAX_WIDTH: ${WEB_RESOLUTION_MAX_WIDTH:-3840}
|
||||
WEB_RESOLUTION_MAX_HEIGHT: ${WEB_RESOLUTION_MAX_HEIGHT:-2160}
|
||||
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:
|
||||
- db
|
||||
volumes:
|
||||
@@ -65,7 +75,7 @@ services:
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- 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.tls=true
|
||||
- traefik.http.routers.portal.tls.certresolver=letsencrypt
|
||||
@@ -107,6 +117,16 @@ services:
|
||||
WEB_RESOLUTION_MAX_WIDTH: ${WEB_RESOLUTION_MAX_WIDTH:-3840}
|
||||
WEB_RESOLUTION_MAX_HEIGHT: ${WEB_RESOLUTION_MAX_HEIGHT:-2160}
|
||||
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:
|
||||
- db
|
||||
volumes:
|
||||
|
||||
@@ -59,6 +59,7 @@ cat > /opt/portal/index.html <<HTML
|
||||
<div class="nav-panel">
|
||||
<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-fs" type="button" title="На весь экран">⛶</button>
|
||||
</div>
|
||||
<script type="module">
|
||||
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-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());
|
||||
|
||||
(function(){
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
forwardedHeaders:
|
||||
trustedIPs:
|
||||
- "10.0.0.0/8"
|
||||
- "172.16.0.0/12"
|
||||
- "192.168.0.0/16"
|
||||
websecure:
|
||||
address: ":443"
|
||||
forwardedHeaders:
|
||||
trustedIPs:
|
||||
- "10.0.0.0/8"
|
||||
- "172.16.0.0/12"
|
||||
- "192.168.0.0/16"
|
||||
|
||||
providers:
|
||||
docker:
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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 \
|
||||
tini \
|
||||
@@ -17,6 +20,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
x11-utils \
|
||||
fonts-dejavu-core \
|
||||
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/*
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
@@ -60,6 +60,7 @@ cat > /opt/portal/index.html <<'HTML'
|
||||
<div class="nav-panel">
|
||||
<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-fs" type="button" title="На весь экран">⛶</button>
|
||||
</div>
|
||||
<script type="module">
|
||||
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-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());
|
||||
|
||||
(function(){
|
||||
|
||||
@@ -30,8 +30,6 @@ _lock = threading.Lock()
|
||||
_AUTOFILL_CONTENT_JS = r"""
|
||||
(function() {
|
||||
const CREDS = __CREDS__;
|
||||
let userFilled = false;
|
||||
let passFilled = false;
|
||||
console.log('[PortalAutofill] loaded for', location.href);
|
||||
|
||||
function isVisible(el) {
|
||||
@@ -102,33 +100,40 @@ _AUTOFILL_CONTENT_JS = r"""
|
||||
function setNativeValue(el, v) {
|
||||
if (!el) return false;
|
||||
if (el.value === v) return true;
|
||||
el.dispatchEvent(new FocusEvent('focus', { bubbles: true }));
|
||||
const proto = Object.getPrototypeOf(el);
|
||||
const desc = Object.getOwnPropertyDescriptor(proto, 'value') ||
|
||||
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value');
|
||||
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 KeyboardEvent('keyup', { bubbles: true, key: v.slice(-1) }));
|
||||
el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
|
||||
function tryFill() {
|
||||
if (userFilled && passFilled) return;
|
||||
const p = findPassField();
|
||||
const u = findUserField(p);
|
||||
if (CREDS.password && p && !passFilled) {
|
||||
if (setNativeValue(p, CREDS.password)) {
|
||||
passFilled = true;
|
||||
console.log('[PortalAutofill] password filled');
|
||||
}
|
||||
}
|
||||
if (CREDS.login && u && !userFilled) {
|
||||
// Fill login FIRST so password-triggered re-render doesn't clear it
|
||||
if (CREDS.login && u) {
|
||||
if (setNativeValue(u, CREDS.login)) {
|
||||
userFilled = true;
|
||||
console.log('[PortalAutofill] user filled');
|
||||
}
|
||||
}
|
||||
if (!CREDS.login) userFilled = true;
|
||||
if (!CREDS.password) passFilled = true;
|
||||
if (CREDS.password && p) {
|
||||
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') {
|
||||
@@ -137,17 +142,22 @@ _AUTOFILL_CONTENT_JS = r"""
|
||||
tryFill();
|
||||
}
|
||||
|
||||
const obs = new MutationObserver(() => {
|
||||
if (!(userFilled && passFilled)) tryFill();
|
||||
});
|
||||
// Periodic check for first 30s in case of async SPA resets
|
||||
let _checks = 0;
|
||||
const _interval = setInterval(() => {
|
||||
tryFill();
|
||||
if (++_checks >= 30) clearInterval(_interval);
|
||||
}, 1000);
|
||||
|
||||
const obs = new MutationObserver(scheduleFill);
|
||||
if (document.documentElement) {
|
||||
obs.observe(document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
const resetAndRefill = () => {
|
||||
userFilled = !CREDS.login;
|
||||
passFilled = !CREDS.password;
|
||||
_checks = 0;
|
||||
setTimeout(tryFill, 150);
|
||||
setTimeout(tryFill, 600);
|
||||
};
|
||||
['pushState', 'replaceState'].forEach(fn => {
|
||||
const orig = history[fn];
|
||||
|
||||
Reference in New Issue
Block a user