From 154ba3efc275af4ae8ee0732e86ae876b07a43eb Mon Sep 17 00:00:00 2001 From: PTah Date: Sat, 20 Jun 2026 00:01:17 +1000 Subject: [PATCH] feat: Windows admin in Settings UI and WinRM host test (0.10.1) Store domain admin in ui_settings (DB overrides env); qwinsta/logoff use effective config. Host card: POST /hosts/{id}/actions/winrm-test via pywinrm. --- .../versions/017_ui_settings_win_admin.py | 25 +++++ backend/app/api/v1/hosts.py | 55 +++++++++ backend/app/api/v1/settings.py | 54 +++++++++ backend/app/models/ui_settings.py | 4 +- backend/app/services/agent_commands.py | 16 +-- backend/app/services/win_admin_settings.py | 82 ++++++++++++++ backend/app/services/winrm_connect.py | 106 ++++++++++++++++++ backend/app/version.py | 2 +- backend/requirements.txt | 1 + backend/tests/test_health.py | 4 +- backend/tests/test_win_admin_settings.py | 93 +++++++++++++++ frontend/package.json | 2 +- frontend/src/api.ts | 34 ++++++ frontend/src/version.ts | 2 +- frontend/src/views/HostDetailView.vue | 38 +++++++ frontend/src/views/SettingsView.vue | 98 ++++++++++++++++ 16 files changed, 602 insertions(+), 14 deletions(-) create mode 100644 backend/alembic/versions/017_ui_settings_win_admin.py create mode 100644 backend/app/services/win_admin_settings.py create mode 100644 backend/app/services/winrm_connect.py create mode 100644 backend/tests/test_win_admin_settings.py diff --git a/backend/alembic/versions/017_ui_settings_win_admin.py b/backend/alembic/versions/017_ui_settings_win_admin.py new file mode 100644 index 0000000..70ff7f7 --- /dev/null +++ b/backend/alembic/versions/017_ui_settings_win_admin.py @@ -0,0 +1,25 @@ +"""ui_settings: Windows domain admin for agent commands + +Revision ID: 017 +Revises: 016 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "017" +down_revision: Union[str, None] = "016" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("ui_settings", sa.Column("win_admin_user", sa.String(256), nullable=True)) + op.add_column("ui_settings", sa.Column("win_admin_password", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("ui_settings", "win_admin_password") + op.drop_column("ui_settings", "win_admin_user") diff --git a/backend/app/api/v1/hosts.py b/backend/app/api/v1/hosts.py index 66218d9..287cc95 100644 --- a/backend/app/api/v1/hosts.py +++ b/backend/app/api/v1/hosts.py @@ -10,6 +10,14 @@ from app.models import Event, Host from app.schemas.list_models import HostDetail, HostListResponse, HostSummary from app.services.agent_version import latest_agent_versions_by_product from app.services.host_delete import delete_host_and_related +from app.services.win_admin_settings import get_effective_win_admin_config +from app.services.winrm_connect import ( + HostNotWindowsError, + HostTargetMissingError, + WinAdminNotConfiguredError, + resolve_windows_host_target, + test_winrm_connection, +) from app.services.host_health import ( HEARTBEAT_TYPE, agent_status as compute_agent_status, @@ -156,6 +164,53 @@ class HostDeleteResponse(BaseModel): deleted_problems: int +class HostWinRmTestResponse(BaseModel): + ok: bool + message: str + target: str + hostname: str | None = None + + +@router.post("/{host_id}/actions/winrm-test", response_model=HostWinRmTestResponse) +def test_host_winrm( + host_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostWinRmTestResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + + cfg = get_effective_win_admin_config(db) + if not cfg.configured: + raise HTTPException(status_code=400, detail="Windows domain admin is not configured") + + try: + target = resolve_windows_host_target(host) + except HostNotWindowsError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except HostTargetMissingError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + try: + result = test_winrm_connection( + target=target, + user=cfg.user, + password=cfg.password, + ) + except WinAdminNotConfiguredError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + + return HostWinRmTestResponse( + ok=result.ok, + message=result.message, + target=result.target, + hostname=result.hostname, + ) + + @router.delete("/{host_id}", response_model=HostDeleteResponse) def delete_host( host_id: int, diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index 9cde1bf..52c9a79 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -37,6 +37,17 @@ from app.services.ui_settings import ( get_effective_ui_settings, upsert_ui_settings, ) +from app.services.win_admin_settings import ( + get_effective_win_admin_config, + upsert_win_admin_settings, +) +from app.services.winrm_connect import ( + HostNotWindowsError, + HostTargetMissingError, + WinAdminNotConfiguredError, + resolve_windows_host_target, + test_winrm_connection, +) router = APIRouter(prefix="/settings", tags=["settings"]) @@ -439,3 +450,46 @@ def update_ui_settings( show_sidebar_system_stats=cfg.show_sidebar_system_stats, source=cfg.source, ) + + +class WinAdminSettingsResponse(BaseModel): + configured: bool + user: str | None = None + password_hint: str | None = None + source: str = Field(description="env или db") + + +class WinAdminSettingsUpdate(BaseModel): + user: str | None = None + password: str | None = None + + +@router.get("/win-admin", response_model=WinAdminSettingsResponse) +def get_win_admin_settings( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> WinAdminSettingsResponse: + cfg = get_effective_win_admin_config(db) + return WinAdminSettingsResponse( + configured=cfg.configured, + user=cfg.user or None, + password_hint=_mask_secret(cfg.password), + source=cfg.source, + ) + + +@router.put("/win-admin", response_model=WinAdminSettingsResponse) +def update_win_admin_settings( + body: WinAdminSettingsUpdate, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> WinAdminSettingsResponse: + if body.user is not None and not body.user.strip(): + raise HTTPException(status_code=422, detail="user must not be empty") + cfg = upsert_win_admin_settings(db, user=body.user, password=body.password) + return WinAdminSettingsResponse( + configured=cfg.configured, + user=cfg.user or None, + password_hint=_mask_secret(cfg.password), + source=cfg.source, + ) diff --git a/backend/app/models/ui_settings.py b/backend/app/models/ui_settings.py index d5a3050..65fe5df 100644 --- a/backend/app/models/ui_settings.py +++ b/backend/app/models/ui_settings.py @@ -1,4 +1,4 @@ -from sqlalchemy import Boolean +from sqlalchemy import Boolean, String, Text from sqlalchemy.orm import Mapped, mapped_column from app.database import Base @@ -13,3 +13,5 @@ class UiSettings(Base): id: Mapped[int] = mapped_column(primary_key=True) show_sidebar_system_stats: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + win_admin_user: Mapped[str | None] = mapped_column(String(256), nullable=True) + win_admin_password: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/services/agent_commands.py b/backend/app/services/agent_commands.py index a790f0e..4f4e450 100644 --- a/backend/app/services/agent_commands.py +++ b/backend/app/services/agent_commands.py @@ -12,20 +12,20 @@ from sqlalchemy.orm import Session from app.config import get_settings from app.models import AgentCommand, Event, Host from app.services.rdg_session_flap import event_has_rdg_flap +from app.services.win_admin_settings import get_effective_win_admin_config def _win_admin_configured() -> bool: - settings = get_settings() - return bool(settings.sac_win_admin_user.strip() and settings.sac_win_admin_password.strip()) + return get_effective_win_admin_config().configured def win_admin_run_as() -> dict[str, str] | None: - if not _win_admin_configured(): + cfg = get_effective_win_admin_config() + if not cfg.configured: return None - settings = get_settings() return { - "user": settings.sac_win_admin_user.strip(), - "password": settings.sac_win_admin_password, + "user": cfg.user, + "password": cfg.password, } @@ -53,7 +53,7 @@ def queue_qwinsta(db: Session, event: Event, *, requested_by: str) -> AgentComma if not _win_admin_configured(): raise HTTPException( status_code=503, - detail="SAC_WIN_ADMIN_USER / SAC_WIN_ADMIN_PASSWORD not configured", + detail="Windows domain admin is not configured (Settings or SAC_WIN_ADMIN_*)", ) details = event.details if isinstance(event.details, dict) else {} user = details.get("user") @@ -83,7 +83,7 @@ def queue_logoff( if not _win_admin_configured(): raise HTTPException( status_code=503, - detail="SAC_WIN_ADMIN_USER / SAC_WIN_ADMIN_PASSWORD not configured", + detail="Windows domain admin is not configured (Settings or SAC_WIN_ADMIN_*)", ) cmd = AgentCommand( command_uuid=str(uuid.uuid4()), diff --git a/backend/app/services/win_admin_settings.py b/backend/app/services/win_admin_settings.py new file mode 100644 index 0000000..3307a44 --- /dev/null +++ b/backend/app/services/win_admin_settings.py @@ -0,0 +1,82 @@ +"""Effective Windows domain admin credentials (DB overrides env).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings + + +@dataclass(frozen=True) +class WinAdminConfig: + user: str + password: str + source: str # env | db + + @property + def configured(self) -> bool: + return bool(self.user.strip() and self.password.strip()) + + +def _win_admin_from_env() -> WinAdminConfig: + settings = get_settings() + return WinAdminConfig( + user=settings.sac_win_admin_user.strip(), + password=settings.sac_win_admin_password, + source="env", + ) + + +def get_effective_win_admin_config(db: Session | None = None) -> WinAdminConfig: + if db is None: + from app.database import SessionLocal + + session = SessionLocal() + try: + return get_effective_win_admin_config(session) + finally: + session.close() + + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + env_cfg = _win_admin_from_env() + if row is None: + return env_cfg + + user = (row.win_admin_user or "").strip() or env_cfg.user + password = (row.win_admin_password or "") or env_cfg.password + has_db_values = bool((row.win_admin_user or "").strip() or (row.win_admin_password or "").strip()) + return WinAdminConfig( + user=user, + password=password, + source="db" if has_db_values else env_cfg.source, + ) + + +def upsert_win_admin_settings( + db: Session, + *, + user: str | None = None, + password: str | None = None, +) -> WinAdminConfig: + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + env_cfg = _win_admin_from_env() + if row is None: + row = UiSettings( + id=UI_SETTINGS_ROW_ID, + show_sidebar_system_stats=True, + win_admin_user=env_cfg.user or None, + win_admin_password=env_cfg.password or None, + ) + db.add(row) + + if user is not None and user.strip(): + row.win_admin_user = user.strip() + if password is not None and password.strip(): + row.win_admin_password = password + + db.commit() + db.refresh(row) + return get_effective_win_admin_config(db) diff --git a/backend/app/services/winrm_connect.py b/backend/app/services/winrm_connect.py new file mode 100644 index 0000000..6d8cefb --- /dev/null +++ b/backend/app/services/winrm_connect.py @@ -0,0 +1,106 @@ +"""WinRM connectivity test for Windows hosts using domain admin credentials.""" + +from __future__ import annotations + +import socket +from dataclasses import dataclass + +from app.models import Host + + +class WinAdminNotConfiguredError(Exception): + pass + + +class HostNotWindowsError(Exception): + pass + + +class HostTargetMissingError(Exception): + pass + + +@dataclass(frozen=True) +class WinRmTestResult: + ok: bool + message: str + target: str + hostname: str | None = None + + +def resolve_windows_host_target(host: Host) -> str: + if not is_windows_host(host): + raise HostNotWindowsError("Host is not Windows") + target = (host.ipv4 or "").strip() or (host.hostname or "").strip() + if not target: + raise HostTargetMissingError("Host has no IPv4 or hostname for WinRM test") + return target + + +def is_windows_host(host: Host) -> bool: + if (host.os_family or "").strip().lower() == "windows": + return True + return (host.product or "").strip() == "rdp-login-monitor" + + +def test_winrm_connection( + *, + target: str, + user: str, + password: str, + timeout_sec: int = 15, +) -> WinRmTestResult: + target = target.strip() + user = user.strip() + if not target or not user or not password: + raise WinAdminNotConfiguredError("Windows admin credentials or target missing") + + try: + import winrm + except ImportError as exc: + raise RuntimeError("pywinrm is not installed on SAC server") from exc + + endpoint = f"http://{target}:5985/wsman" + try: + session = winrm.Session( + endpoint, + auth=(user, password), + transport="ntlm", + read_timeout_sec=timeout_sec, + operation_timeout_sec=timeout_sec, + ) + result = session.run_cmd("hostname") + except winrm.exceptions.WinRMTransportError as exc: + return WinRmTestResult( + ok=False, + message=f"WinRM transport error: {exc}", + target=target, + ) + except (socket.timeout, TimeoutError): + return WinRmTestResult( + ok=False, + message=f"WinRM timeout ({timeout_sec}s) to {target}:5985", + target=target, + ) + except Exception as exc: + return WinRmTestResult( + ok=False, + message=f"WinRM error: {exc}", + target=target, + ) + + stdout = (result.std_out or b"").decode("utf-8", errors="replace").strip() + stderr = (result.std_err or b"").decode("utf-8", errors="replace").strip() + if result.status_code == 0 and stdout: + return WinRmTestResult( + ok=True, + message=f"WinRM OK, hostname={stdout}", + target=target, + hostname=stdout, + ) + detail = stderr or stdout or f"exit code {result.status_code}" + return WinRmTestResult( + ok=False, + message=f"WinRM command failed: {detail}", + target=target, + ) diff --git a/backend/app/version.py b/backend/app/version.py index 29648aa..5569f59 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,5 +1,5 @@ """Единый источник версии SAC (API, health, логи, OpenAPI).""" APP_NAME = "Security Alert Center" -APP_VERSION = "0.10.0" +APP_VERSION = "0.10.1" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/requirements.txt b/backend/requirements.txt index 6a8c8ad..f56a292 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,6 +13,7 @@ httpx>=0.28.0,<1 google-auth>=2.36.0,<3 requests>=2.32.0,<3 psutil>=6.1.0,<8 +pywinrm>=0.5.0,<1 # dev pytest>=8.3.0,<9 diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 7ebdeea..3d4b592 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL def test_version_constants(): - assert APP_VERSION == "0.10.0" + assert APP_VERSION == "0.10.1" assert APP_NAME == "Security Alert Center" - assert APP_VERSION_LABEL == "Security Alert Center v.0.10.0" + assert APP_VERSION_LABEL == "Security Alert Center v.0.10.1" diff --git a/backend/tests/test_win_admin_settings.py b/backend/tests/test_win_admin_settings.py new file mode 100644 index 0000000..ab8eb22 --- /dev/null +++ b/backend/tests/test_win_admin_settings.py @@ -0,0 +1,93 @@ +"""Win admin settings and WinRM host test.""" + +from unittest.mock import patch + +from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings + + +def test_get_win_admin_settings_env_default(jwt_headers, client, monkeypatch): + monkeypatch.setenv("SAC_WIN_ADMIN_USER", "") + monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "") + from app.config import get_settings + + get_settings.cache_clear() + + response = client.get("/api/v1/settings/win-admin", headers=jwt_headers) + assert response.status_code == 200 + body = response.json() + assert body["configured"] is False + assert body["source"] == "env" + + +def test_put_win_admin_settings_persists(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_WIN_ADMIN_USER", "") + monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "") + from app.config import get_settings + + get_settings.cache_clear() + + response = client.put( + "/api/v1/settings/win-admin", + headers=jwt_headers, + json={"user": r"B26\papatramp", "password": "secret-pass"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["configured"] is True + assert body["user"] == r"B26\papatramp" + assert body["source"] == "db" + assert body["password_hint"] + + row = db_session.get(UiSettings, UI_SETTINGS_ROW_ID) + assert row is not None + assert row.win_admin_user == r"B26\papatramp" + assert row.win_admin_password == "secret-pass" + + +def test_host_winrm_test_requires_admin(jwt_monitor_headers, client): + response = client.post("/api/v1/hosts/1/actions/winrm-test", headers=jwt_monitor_headers) + assert response.status_code == 403 + + +def test_host_winrm_test_not_found(jwt_headers, client): + response = client.post("/api/v1/hosts/99999/actions/winrm-test", headers=jwt_headers) + assert response.status_code == 404 + + +def test_host_winrm_test_success(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin") + monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw") + from app.config import get_settings + + get_settings.cache_clear() + + from app.models import Host + from app.services.winrm_connect import WinRmTestResult + + host = Host( + hostname="PC01", + os_family="windows", + product="rdp-login-monitor", + ipv4="192.168.1.10", + ) + db_session.add(host) + db_session.commit() + db_session.refresh(host) + + with patch("app.api.v1.hosts.test_winrm_connection") as mock_test: + mock_test.return_value = WinRmTestResult( + ok=True, + message="WinRM OK, hostname=PC01", + target="192.168.1.10", + hostname="PC01", + ) + response = client.post( + f"/api/v1/hosts/{host.id}/actions/winrm-test", + headers=jwt_headers, + ) + + assert response.status_code == 200 + body = response.json() + assert body["ok"] is True + assert body["hostname"] == "PC01" + assert body["target"] == "192.168.1.10" diff --git a/frontend/package.json b/frontend/package.json index fbae2c0..78ec829 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "sac-ui", "private": true, - "version": "0.10.0", + "version": "0.10.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c0755f0..fce571f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -412,6 +412,40 @@ export function testMobileDevicePush(deviceId: number): Promise<{ message: strin return apiFetch(`/api/v1/mobile/devices/${deviceId}/test-push`, { method: "POST" }); } +export interface WinAdminSettings { + configured: boolean; + user: string | null; + password_hint: string | null; + source: string; +} + +export function fetchWinAdminSettings(): Promise { + return apiFetch("/api/v1/settings/win-admin"); +} + +export function updateWinAdminSettings(payload: { + user?: string | null; + password?: string | null; +}): Promise { + return apiFetch("/api/v1/settings/win-admin", { + method: "PUT", + body: JSON.stringify(payload), + }); +} + +export interface HostWinRmTestResult { + ok: boolean; + message: string; + target: string; + hostname: string | null; +} + +export function testHostWinRm(hostId: number): Promise { + return apiFetch(`/api/v1/hosts/${hostId}/actions/winrm-test`, { + method: "POST", + }); +} + export interface DashboardSummary { events_last_24h: number; hosts_total: number; diff --git a/frontend/src/version.ts b/frontend/src/version.ts index a107ed9..d531f7a 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,4 +1,4 @@ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ export const APP_NAME = "Security Alert Center"; -export const APP_VERSION = "0.10.0"; +export const APP_VERSION = "0.10.1"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; diff --git a/frontend/src/views/HostDetailView.vue b/frontend/src/views/HostDetailView.vue index 9369a6f..f319614 100644 --- a/frontend/src/views/HostDetailView.vue +++ b/frontend/src/views/HostDetailView.vue @@ -47,6 +47,17 @@
{{ host.event_count ?? 0 }}
+
+ +

{{ winRmTestMessage }}

+
@@ -165,6 +176,7 @@ import { apiFetch, fetchHost, fetchRecentEvents, + testHostWinRm, type EventListResponse, type HostDetail, } from "../api"; @@ -180,6 +192,9 @@ const error = ref(""); const eventsError = ref(""); const eventsPage = ref(1); const eventsPageSize = 50; +const testingWinRm = ref(false); +const winRmTestMessage = ref(""); +const winRmTestOk = ref(false); let refreshingLive = false; useSacLiveEventStream(() => { @@ -188,6 +203,13 @@ useSacLiveEventStream(() => { const hostId = computed(() => parseInt(props.id, 10)); +const isWindowsHost = computed(() => { + const h = host.value; + if (!h) return false; + if ((h.os_family || "").toLowerCase() === "windows") return true; + return h.product === "rdp-login-monitor"; +}); + const inventory = computed(() => host.value?.inventory ?? null); const windowsFields = computed(() => { @@ -266,6 +288,7 @@ function agentLabel(status: string) { async function loadHost() { loading.value = true; error.value = ""; + winRmTestMessage.value = ""; try { host.value = await fetchHost(hostId.value); } catch (e) { @@ -275,6 +298,21 @@ async function loadHost() { } } +async function runWinRmTest() { + testingWinRm.value = true; + winRmTestMessage.value = ""; + try { + const result = await testHostWinRm(hostId.value); + winRmTestOk.value = result.ok; + winRmTestMessage.value = result.message; + } catch (e) { + winRmTestOk.value = false; + winRmTestMessage.value = e instanceof Error ? e.message : "Ошибка проверки WinRM"; + } finally { + testingWinRm.value = false; + } +} + async function refreshFromLatestEvent() { if (refreshingLive) return; refreshingLive = true; diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index e0b4b35..2b2ce10 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -44,6 +44,44 @@
+
+

Windows (qwinsta / logoff)

+

+ Доменный admin для удалённых команд на Windows-хостах (qwinsta, logoff через агент). + Формат логина: ДОМЕН\пользователь (например B26\papatramp). + Пока запись не создана в UI — используются SAC_WIN_ADMIN_* из sac-api.env. +

+
+ + +

+ Настроен: {{ winAdminLoaded.configured ? "да" : "нет" }} + · источник: {{ winAdminLoaded.source }} +

+
+ +
+
+
+

Правило оповещений

@@ -436,11 +474,13 @@ import { fetchMobileEnrollmentCodes, fetchMobileSettings, fetchUsers, + fetchWinAdminSettings, revokeMobileDevice, deleteMobileDevice, revokeMobileEnrollmentCode, testMobileDevicePush, updateMobileSettings, + updateWinAdminSettings, type MobileDevice, type MobileEnrollmentCode, type MobileSettings, @@ -495,6 +535,13 @@ interface UiSettings { source: string; } +interface WinAdminSettings { + configured: boolean; + user: string | null; + password_hint: string | null; + source: string; +} + interface SeverityOverrideRowApi { event_type: string; default_severity: string; @@ -508,6 +555,7 @@ interface SeverityOverrideRowUi extends SeverityOverrideRowApi { const loading = ref(true); const savingPolicy = ref(false); const savingUi = ref(false); +const savingWinAdmin = ref(false); const savingTelegram = ref(false); const savingWebhook = ref(false); const savingEmail = ref(false); @@ -525,6 +573,7 @@ const webhookLoaded = ref(null); const emailLoaded = ref(null); const policyLoaded = ref(null); const uiLoaded = ref(null); +const winAdminLoaded = ref(null); const severityRows = ref([]); const severityFilter = ref(""); const mobileLoaded = ref(null); @@ -587,6 +636,11 @@ const uiForm = reactive({ show_sidebar_system_stats: true, }); +const winAdminForm = reactive({ + user: "", + password: "", +}); + const telegramForm = reactive({ enabled: false, bot_token: "", @@ -637,6 +691,11 @@ const smtpPasswordPlaceholder = computed(() => const mailToPlaceholder = computed(() => emailLoaded.value?.mail_to_hint ? `оставить ${emailLoaded.value.mail_to_hint}` : "admin@example.com", ); +const winAdminPasswordPlaceholder = computed(() => + winAdminLoaded.value?.password_hint + ? `оставить ${winAdminLoaded.value.password_hint}` + : "обязателен при первой настройке", +); const filteredSeverityRows = computed(() => { const q = severityFilter.value.trim().toLowerCase(); @@ -715,6 +774,44 @@ async function saveUiSettings() { } } +async function loadWinAdminSettings() { + const data = await fetchWinAdminSettings(); + winAdminLoaded.value = data; + winAdminForm.user = data.user ?? ""; + winAdminForm.password = ""; +} + +async function saveWinAdminSettings() { + savingWinAdmin.value = true; + error.value = ""; + success.value = ""; + try { + const payload: { user?: string; password?: string } = {}; + if (winAdminForm.user.trim()) { + payload.user = winAdminForm.user.trim(); + } + if (winAdminForm.password.trim()) { + payload.password = winAdminForm.password; + } + if (!payload.user && !payload.password) { + error.value = "Укажите логин и/или новый пароль Windows admin"; + return; + } + if (!winAdminLoaded.value?.configured && (!payload.user || !payload.password)) { + error.value = "Для первой настройки укажите логин и пароль"; + return; + } + winAdminLoaded.value = await updateWinAdminSettings(payload); + winAdminForm.user = winAdminLoaded.value.user ?? winAdminForm.user; + winAdminForm.password = ""; + success.value = "Учётные данные Windows admin сохранены."; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка сохранения Windows admin"; + } finally { + savingWinAdmin.value = false; + } +} + async function loadMobileSection() { mobileLoaded.value = await fetchMobileSettings(); mobileForm.devices_allowed = mobileLoaded.value.devices_allowed; @@ -860,6 +957,7 @@ async function load() { emailForm.smtp_starttls = data.email.smtp_starttls; emailForm.smtp_ssl = data.email.smtp_ssl; await loadUiSettings(); + await loadWinAdminSettings(); await loadSeverityOverrides(); await loadMobileSection(); } catch (e) {