login: left panel 1/4, distrib button, text tweaks, dashboard light theme polish

This commit is contained in:
2026-05-12 12:42:12 +00:00
parent 666093f1c6
commit fff7ecdce2
5 changed files with 349 additions and 36 deletions
+101
View File
@@ -0,0 +1,101 @@
import os
import sys
# SQLite in-memory for tests — no PostgreSQL needed
os.environ.setdefault("DATABASE_URL", "sqlite:///./test.db")
os.environ.setdefault("SIGNING_KEY", "test-signing-key-32chars-padding!!")
os.environ.setdefault("ADMIN_USERNAME", "admin")
os.environ.setdefault("ADMIN_PASSWORD", "testpass123")
os.environ.setdefault("PUBLIC_HOST", "http://localhost")
os.environ.setdefault("ENABLE_STARTUP_MAINTENANCE", "0")
os.environ.setdefault("LOG_LEVEL", "ERROR")
import pytest
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
# Patch docker before importing app modules
_docker_mock = MagicMock()
_docker_mock.containers.get.side_effect = Exception("no docker in tests")
_docker_mock.containers.list.return_value = []
_docker_mock.containers.run.return_value = MagicMock(id="test-container-id", status="running", name="test")
sys.modules.setdefault("docker", MagicMock())
with patch("docker.from_env", return_value=_docker_mock):
with patch("runtime.ensure_schema_compatibility", lambda: None):
from database import Base, get_db
import main as app_module
engine = create_engine(
"sqlite:///./test.db",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(bind=engine)
Base.metadata.create_all(bind=engine)
# Create admin user
from auth import hash_password
from models import User
with TestingSessionLocal() as db:
if not db.query(User).filter(User.username == "admin").first():
import datetime as _dt
db.add(User(
username="admin",
password_hash=hash_password("testpass123"),
is_admin=True,
active=True,
expires_at=_dt.datetime(2099, 1, 1, tzinfo=_dt.timezone.utc),
))
db.commit()
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app_module.app.dependency_overrides[get_db] = override_get_db
@pytest.fixture(scope="session")
def client():
with patch("runtime.docker_client", return_value=_docker_mock), \
patch("runtime.ensure_schema_compatibility", lambda: None):
with TestClient(app_module.app, raise_server_exceptions=False, base_url="https://testserver") as c:
yield c
def _extract_csrf(client) -> str:
"""GET / → берём CSRF из HTML и ставим куку вручную."""
import re
r = client.get("/", follow_redirects=True)
assert r.status_code == 200
m = re.search(r'name=["\']csrf_token["\'][^>]*value=["\']([^"\']+)["\']'
r'|value=["\']([^"\']+)["\'][^>]*name=["\']csrf_token["\']', r.text)
if not m:
m = re.search(r'csrf_token["\']?\s*[=:]\s*["\']([^"\']{10,})["\']', r.text)
assert m, f"csrf_token not found in HTML: {r.text[:500]}"
csrf = m.group(1) or m.group(2)
client.cookies.set("portal_csrf", csrf, domain="testserver")
return csrf
@pytest.fixture(scope="session")
def auth_client(client):
"""Client with admin session cookie."""
csrf = _extract_csrf(client)
r = client.post("/login", data={
"username": "admin",
"password": "testpass123",
"csrf_token": csrf,
}, follow_redirects=True)
assert r.status_code == 200, f"login failed: {r.status_code} {r.text[:300]}"
return client
+140 -35
View File
@@ -742,7 +742,7 @@ button {
100% { transform: translate3d(-6%, 0, 0); } 100% { transform: translate3d(-6%, 0, 0); }
} }
/* Effects disabled per request: no parallax, no animated clouds, no hover motion */ /* ========== Dashboard refined light theme ========== */
.parallax-scene, .parallax-scene,
.parallax-layer, .parallax-layer,
.cloud-layer, .cloud-layer,
@@ -753,64 +753,146 @@ button {
animation: none !important; animation: none !important;
transform: none !important; transform: none !important;
} }
/* Richer background */
.dashboard-page { .dashboard-page {
background: linear-gradient(180deg, #a8d2ee 0%, #d8ecf9 100%) !important; background:
} radial-gradient(ellipse 80% 40% at 15% 0%, rgba(120,185,240,0.35) 0%, transparent 60%),
.dashboard-page .panel, radial-gradient(ellipse 60% 50% at 90% 100%, rgba(90,160,230,0.22) 0%, transparent 60%),
.dashboard-page .tile { linear-gradient(170deg, #deeffe 0%, #c8e4f8 45%, #b0d5f0 100%) !important;
backdrop-filter: none !important;
background: rgba(255, 255, 255, 0.9) !important;
border: 1px solid rgba(198, 218, 235, 0.9) !important;
} }
/* Header — keep dark, refine */
.dashboard-page .header { .dashboard-page .header {
background: rgba(10, 25, 41, 0.92) !important; background: rgba(8, 20, 38, 0.94) !important;
backdrop-filter: blur(8px) !important; backdrop-filter: blur(12px) !important;
border-bottom: 1px solid rgba(255,255,255,0.08) !important; border-bottom: 1px solid rgba(255,255,255,0.09) !important;
} box-shadow: 0 1px 0 rgba(0,0,0,0.18) !important;
.dashboard-page .svc-credentials {
background: rgba(220, 238, 252, 0.9) !important;
border-color: rgba(180, 210, 235, 0.85) !important;
} }
/* Top info panel */
.dashboard-page .panel { .dashboard-page .panel {
backdrop-filter: none !important;
background: rgba(255, 255, 255, 0.82) !important;
border: 1px solid rgba(185, 215, 238, 0.75) !important;
box-shadow: 0 4px 24px rgba(15, 64, 103, 0.1) !important;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
box-sizing: border-box; box-sizing: border-box;
} }
.tile,
.tile:hover, /* Admin intro banner */
.made-by, .dashboard-page .admin-intro {
.made-by:hover, background: linear-gradient(135deg, #e8f5ff 0%, #d8ecfa 100%);
border: 1px solid rgba(160, 210, 240, 0.7);
color: #0e3d5f;
border-radius: 10px;
}
/* Rules banner */
.dashboard-page .rules-banner {
background: linear-gradient(135deg, #f0f8ff 0%, #e4f2fd 100%);
border: 1px solid rgba(160, 210, 240, 0.6);
}
.dashboard-page .rules-pill {
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(180, 215, 238, 0.7);
color: #1e4a6a;
}
/* Category chips */
.dashboard-page .category-chip {
background: rgba(255, 255, 255, 0.75) !important;
border: 1px solid rgba(160, 205, 235, 0.75) !important;
color: #1a4a6e !important;
font-weight: 600;
}
.dashboard-page .category-chip.active {
background: linear-gradient(135deg, #1675b4 0%, #0f5b94 100%) !important;
border-color: transparent !important;
color: #fff !important;
box-shadow: 0 2px 8px rgba(15, 91, 148, 0.35);
}
/* Session notice */
.dashboard-page .session-notice {
background: #e4f3ff;
border-color: #aad4f0;
color: #174768;
}
/* Service tiles — clean white cards with depth */
.dashboard-page .tile {
backdrop-filter: none !important;
background: #ffffff !important;
border: 1px solid rgba(185, 215, 238, 0.7) !important;
box-shadow:
0 1px 3px rgba(15, 64, 103, 0.06),
0 6px 20px rgba(15, 64, 103, 0.09) !important;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease !important;
}
.dashboard-page .tile-wrap:hover .tile {
transform: translateY(-3px) !important;
border-color: rgba(42, 130, 210, 0.45) !important;
box-shadow:
0 2px 6px rgba(15, 64, 103, 0.08),
0 12px 32px rgba(15, 64, 103, 0.16) !important;
}
/* Icon box */
.dashboard-page .tile-icon-box {
background: #f2f8fd !important;
border-color: rgba(185, 215, 238, 0.65) !important;
}
/* Credentials block */
.dashboard-page .svc-credentials {
background: linear-gradient(135deg, #edf6ff 0%, #e2f0fb 100%) !important;
border-color: rgba(180, 215, 240, 0.8) !important;
}
/* Category badges on tile */
.dashboard-page .service-cat-badge {
background: #edf5fc;
border-color: rgba(175, 210, 235, 0.8);
color: #1e4a6e;
}
/* Made by footer */
.dashboard-page .made-by {
color: rgba(20, 60, 95, 0.38) !important;
text-shadow: 0 1px 4px rgba(255,255,255,0.5) !important;
font-family: "Segoe UI", sans-serif !important;
font-size: 0.82rem !important;
font-weight: 500 !important;
letter-spacing: 0.04em;
}
.dashboard-page .made-by:hover {
color: rgba(15, 91, 148, 0.65) !important;
}
/* Suppress transitions on non-tile elements */
.category-chip, .category-chip,
.category-chip.active, .category-chip.active,
.btn-link, .btn-link,
.btn-link.secondary, .btn-link.secondary,
button { button {
transition: none !important; transition: none !important;
transform: none !important;
}
.tile:hover {
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.06) !important;
border-color: transparent !important;
} }
/* 4-up desktop grid with adaptive breakpoints */ /* 4-up desktop grid */
.service-grid { .service-grid {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }
@media (max-width: 1400px) { @media (max-width: 1400px) {
.service-grid { .service-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
grid-template-columns: repeat(3, minmax(0, 1fr));
}
} }
@media (max-width: 1050px) { @media (max-width: 1050px) {
.service-grid { .service-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.service-grid { .service-grid { grid-template-columns: 1fr; }
grid-template-columns: 1fr;
}
} }
/* ========== Login page redesign ========== */ /* ========== Login page redesign ========== */
@@ -832,7 +914,7 @@ button {
/* LEFT PANEL */ /* LEFT PANEL */
.login-left { .login-left {
flex: 0 0 33.333%; flex: 0 0 25%;
background: linear-gradient(150deg, #050d1a 0%, #091829 55%, #0e2344 100%); background: linear-gradient(150deg, #050d1a 0%, #091829 55%, #0e2344 100%);
position: relative; position: relative;
display: flex; display: flex;
@@ -918,6 +1000,29 @@ button {
font-size: 0.9rem; 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 */ /* RIGHT PANEL */
.login-right { .login-right {
flex: 1; flex: 1;
+2 -1
View File
@@ -35,7 +35,7 @@
<ul class="login-features"> <ul class="login-features">
<li class="login-feature"> <li class="login-feature">
<span class="login-feature-icon">🖥</span> <span class="login-feature-icon">🖥</span>
<span>Рабочие столы через RDP</span> <span>Доступ к рабочим столам ОС</span>
</li> </li>
<li class="login-feature"> <li class="login-feature">
<span class="login-feature-icon">🌐</span> <span class="login-feature-icon">🌐</span>
@@ -50,6 +50,7 @@
<span>Защищённый контур</span> <span>Защищённый контур</span>
</li> </li>
</ul> </ul>
<button type="button" class="login-distrib-btn" onclick="window.open('https://maps.4mont.ru','_blank')">Продукты нашей дистрибуции</button>
</div> </div>
</aside> </aside>
<main class="login-right"> <main class="login-right">
+3
View File
@@ -0,0 +1,3 @@
pytest==8.3.5
httpx==0.28.1
pytest-asyncio==0.24.0
+103
View File
@@ -0,0 +1,103 @@
"""
Smoke-tests: проверяем что все ключевые роуты не падают с NameError/ImportError.
Не проверяем бизнес-логику — только что страницы отдают ответ.
"""
import pytest
# ── Публичные страницы ──────────────────────────────────────────────────────
def test_index_anonymous(client):
"""Главная без авторизации — либо страница сервисов, либо логин."""
r = client.get("/", follow_redirects=True)
assert r.status_code == 200
def test_login_form_on_index(client):
"""Форма логина рендерится на /."""
r = client.get("/", follow_redirects=True)
assert r.status_code == 200
assert "csrf" in r.text.lower() or "login" in r.text.lower() or "пароль" in r.text.lower()
def _get_csrf(client):
from conftest import _extract_csrf
return _extract_csrf(client)
def test_login_wrong_password(client):
csrf = _get_csrf(client)
r = client.post("/login", data={
"username": "admin",
"password": "wrongpass",
"csrf_token": csrf,
})
assert r.status_code in (200, 401)
def test_login_csrf_fail(client):
r = client.post("/login", data={
"username": "admin",
"password": "testpass123",
"csrf_token": "bad-token",
})
assert r.status_code == 403
def test_login_no_such_method(client):
r = client.get("/login")
assert r.status_code in (200, 405) # только документируем поведение
# ── Авторизованные страницы ─────────────────────────────────────────────────
def test_login_success(auth_client):
r = auth_client.get("/", follow_redirects=True)
assert r.status_code == 200
def test_admin_page(auth_client):
r = auth_client.get("/admin")
assert r.status_code == 200
def test_admin_requires_auth(client):
from fastapi.testclient import TestClient
import main as app_module
fresh = TestClient(app_module.app, raise_server_exceptions=False)
r = fresh.get("/admin", follow_redirects=False)
assert r.status_code in (302, 303, 401, 403)
# ── API роуты ───────────────────────────────────────────────────────────────
def test_api_services_list(auth_client):
r = auth_client.get("/api/admin/services")
assert r.status_code in (200, 404, 405)
def test_api_users_list(auth_client):
r = auth_client.get("/api/admin/users")
assert r.status_code in (200, 404, 405)
def test_api_categories(auth_client):
r = auth_client.get("/api/admin/categories")
assert r.status_code in (200, 404, 405)
# ── Несуществующие роуты ────────────────────────────────────────────────────
def test_404(client):
r = client.get("/this-does-not-exist-xyz")
assert r.status_code == 404
def test_session_unknown(auth_client):
r = auth_client.get("/s/00000000-0000-0000-0000-000000000000/")
assert r.status_code in (200, 302, 303, 404)
def test_go_unknown_slug(auth_client):
r = auth_client.get("/go/nonexistent-service-slug", follow_redirects=False)
assert r.status_code in (302, 303, 404)