From 05fc5bd6ff030020c24d2b32817e3bea9712c0bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 15 Apr 2026 13:03:38 +0200 Subject: [PATCH] fixes --- backend/app/schemas/router.py | 22 +++++++- backend/tests/test_routers.py | 51 +++++++++++++++++++ .../routers/router-detail-page.component.html | 1 + .../routers/router-detail-page.component.ts | 7 ++- .../routers/routers-page.component.html | 14 ++++- .../routers/routers-page.component.ts | 6 ++- frontend/src/app/shared/utils/device-name.ts | 5 ++ frontend/src/assets/i18n/en.json | 2 +- frontend/src/assets/i18n/no.json | 4 +- frontend/src/assets/i18n/pl.json | 2 +- frontend/src/styles/pages.css | 19 +++++++ 11 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 frontend/src/app/shared/utils/device-name.ts diff --git a/backend/app/schemas/router.py b/backend/app/schemas/router.py index 61aba08..8762bf7 100644 --- a/backend/app/schemas/router.py +++ b/backend/app/schemas/router.py @@ -21,6 +21,11 @@ class RouterBase(BaseModel): disable_binary_backups: bool = False disable_ping: bool = False + @field_validator("name", mode="before") + @classmethod + def normalize_name(cls, value: str | None) -> str: + return (value or "").strip() + @field_validator("name") @classmethod def validate_name(cls, value: str) -> str: @@ -63,7 +68,22 @@ class RouterUpdate(BaseModel): disable_binary_backups: bool | None = None disable_ping: bool | None = None - @field_validator("name", "host", "ssh_user", "ssh_key", "ssh_password", mode="before") + @field_validator("name", mode="before") + @classmethod + def normalize_name(cls, value: str | None) -> str | None: + normalized = (value or "").strip() + return normalized or None + + @field_validator("name") + @classmethod + def validate_name(cls, value: str | None) -> str | None: + if value is None: + return value + if not ALLOWED_NAME_REGEX.match(value): + raise ValueError("Only letters, digits, dashes and underscores are allowed") + return value + + @field_validator("host", "ssh_user", "ssh_key", "ssh_password", mode="before") @classmethod def normalize_text(cls, value: str | None) -> str | None: normalized = (value or "").strip() diff --git a/backend/tests/test_routers.py b/backend/tests/test_routers.py index 567c8e6..400659e 100644 --- a/backend/tests/test_routers.py +++ b/backend/tests/test_routers.py @@ -60,3 +60,54 @@ def test_router_list_marks_global_ssh_key_usage(monkeypatch, tmp_path): payload = list_response.json() assert payload[0]["uses_global_ssh_key"] is True assert payload[0]["has_effective_ssh_key"] is True + + + +def test_create_router_rejects_name_with_spaces(monkeypatch, tmp_path): + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'routers-invalid-create.db'}") + monkeypatch.setenv("DATA_DIR", str(tmp_path / 'data-create')) + monkeypatch.setenv("SECRET_KEY", "test-secret") + monkeypatch.setenv("DEFAULT_ADMIN_USERNAME", "admin") + monkeypatch.setenv("DEFAULT_ADMIN_PASSWORD", "admin") + + with TestClient(app) as client: + login_response = client.post("/api/auth/login", data={"username": "admin", "password": "admin"}) + token = login_response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + response = client.post( + "/api/routers", + headers=headers, + json={"name": "core router", "host": "10.0.0.1", "port": 22, "ssh_user": "admin", "ssh_key": "KEY"}, + ) + + assert response.status_code == 422 + + +def test_update_router_rejects_name_with_spaces(monkeypatch, tmp_path): + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'routers-invalid-update.db'}") + monkeypatch.setenv("DATA_DIR", str(tmp_path / 'data-update')) + monkeypatch.setenv("SECRET_KEY", "test-secret") + monkeypatch.setenv("DEFAULT_ADMIN_USERNAME", "admin") + monkeypatch.setenv("DEFAULT_ADMIN_PASSWORD", "admin") + + with TestClient(app) as client: + login_response = client.post("/api/auth/login", data={"username": "admin", "password": "admin"}) + token = login_response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + create = client.post( + "/api/routers", + headers=headers, + json={"name": "core-router", "host": "10.0.0.1", "port": 22, "ssh_user": "admin", "ssh_key": "KEY"}, + ) + assert create.status_code == 200 + router_id = create.json()["id"] + + response = client.put( + f"/api/routers/{router_id}", + headers=headers, + json={"name": "branch router"}, + ) + + assert response.status_code == 422 diff --git a/frontend/src/app/features/routers/router-detail-page.component.html b/frontend/src/app/features/routers/router-detail-page.component.html index eebd999..9cbc0fc 100644 --- a/frontend/src/app/features/routers/router-detail-page.component.html +++ b/frontend/src/app/features/routers/router-detail-page.component.html @@ -296,6 +296,7 @@ + {{ 'routers.nameValidationHint' | translate }} diff --git a/frontend/src/app/features/routers/router-detail-page.component.ts b/frontend/src/app/features/routers/router-detail-page.component.ts index e7ba275..edd0ff8 100644 --- a/frontend/src/app/features/routers/router-detail-page.component.ts +++ b/frontend/src/app/features/routers/router-detail-page.component.ts @@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common'; import { HttpResponse } from '@angular/common/http'; import { Component, OnInit, inject } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { DEVICE_NAME_PATTERN, normalizeDeviceName } from '../../shared/utils/device-name'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { ButtonModule } from 'primeng/button'; @@ -137,7 +138,7 @@ export class RouterDetailPageComponent implements OnInit { { label: 'SwitchOS', value: 'switchos' } ]; readonly form = this.fb.nonNullable.group({ - name: ['', Validators.required], + name: ['', [Validators.required, Validators.pattern(DEVICE_NAME_PATTERN)]], device_type: ['routeros' as DeviceType, Validators.required], host: ['', Validators.required], port: [22, Validators.required], @@ -237,8 +238,10 @@ export class RouterDetailPageComponent implements OnInit { if (this.form.invalid || this.saving) { return; } + const normalizedName = normalizeDeviceName(this.form.controls.name.value); + this.form.controls.name.setValue(normalizedName, { emitEvent: false }); this.saving = true; - const payload = this.form.getRawValue(); + const payload = { ...this.form.getRawValue(), name: normalizedName }; if (payload.device_type === 'switchos') { payload.ssh_key = ''; } diff --git a/frontend/src/app/features/routers/routers-page.component.html b/frontend/src/app/features/routers/routers-page.component.html index 8947194..4352de4 100644 --- a/frontend/src/app/features/routers/routers-page.component.html +++ b/frontend/src/app/features/routers/routers-page.component.html @@ -22,7 +22,7 @@ - + {{ 'routers.name' | translate }}{{ 'routers.endpoint' | translate }}{{ 'routers.access' | translate }}{{ 'routers.backupPolicy' | translate }}{{ 'routers.ping' | translate }}{{ 'common.actions' | translate }} @@ -50,11 +50,20 @@ {{ pingLabel(routerItem) }} -
+ {{ 'common.actions' | translate }} +
+
+ {{ 'common.actions' | translate }} +
+ + + +
+
@@ -100,6 +109,7 @@ + {{ 'routers.nameValidationHint' | translate }} diff --git a/frontend/src/app/features/routers/routers-page.component.ts b/frontend/src/app/features/routers/routers-page.component.ts index d929c13..9d8b377 100644 --- a/frontend/src/app/features/routers/routers-page.component.ts +++ b/frontend/src/app/features/routers/routers-page.component.ts @@ -1,6 +1,7 @@ import { CommonModule } from '@angular/common'; import { Component, OnInit, inject } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { DEVICE_NAME_PATTERN, normalizeDeviceName } from '../../shared/utils/device-name'; import { Router } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { ButtonModule } from 'primeng/button'; @@ -79,7 +80,7 @@ export class RoutersPageComponent implements OnInit { { label: 'SwitchOS', value: 'switchos' } ]; readonly form = this.fb.nonNullable.group({ - name: ['', Validators.required], + name: ['', [Validators.required, Validators.pattern(DEVICE_NAME_PATTERN)]], device_type: ['routeros' as DeviceType, Validators.required], host: ['', Validators.required], port: [22, Validators.required], @@ -159,7 +160,8 @@ export class RoutersPageComponent implements OnInit { return; } this.saving = true; - const payload = this.form.getRawValue(); + const payload = { ...this.form.getRawValue(), name: normalizeDeviceName(this.form.controls.name.value) }; + this.form.controls.name.setValue(payload.name, { emitEvent: false }); if (payload.device_type === 'switchos') { payload.ssh_key = ''; } diff --git a/frontend/src/app/shared/utils/device-name.ts b/frontend/src/app/shared/utils/device-name.ts new file mode 100644 index 0000000..d5d992b --- /dev/null +++ b/frontend/src/app/shared/utils/device-name.ts @@ -0,0 +1,5 @@ +export const DEVICE_NAME_PATTERN = /^[A-Za-z0-9_-]+$/; + +export function normalizeDeviceName(value: string | null | undefined): string { + return (value ?? '').trim(); +} diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index fa263af..12457b2 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -165,6 +165,7 @@ "editDialogTitle": "Edit device", "host": "Host", "port": "Port", + "nameValidationHint": "Use only letters, digits, dashes, and underscores without spaces.", "sshUser": "Username", "sshPrivateKey": "SSH private key", "optionalPassword": "Optional password", @@ -539,7 +540,6 @@ "host": "Host / URL", "hostPlaceholder": "for example 192.168.88.1 or http://192.168.88.1", "port": "Port", - "username": "Username", "password": "Password", "passwordPlaceholder": "Leave empty when the device has no password", "probeButton": "Check access", diff --git a/frontend/src/assets/i18n/no.json b/frontend/src/assets/i18n/no.json index f937800..f18b0dd 100644 --- a/frontend/src/assets/i18n/no.json +++ b/frontend/src/assets/i18n/no.json @@ -165,7 +165,8 @@ "editDialogTitle": "Rediger enhet", "host": "Vert", "port": "Port", - "sshUser": "Bruker", + "nameValidationHint": "Use only letters, digits, dashes, and underscores without spaces.", + "sshUser": "Brukernavn", "sshPrivateKey": "SSH privat nøkkel", "optionalPassword": "Valgfritt passord", "optionalPrivateKey": "Valgfri privat nøkkel", @@ -521,7 +522,6 @@ "host": "Vert / URL", "hostPlaceholder": "for eksempel 192.168.88.1 eller http://192.168.88.1", "port": "Port", - "username": "Brukernavn", "password": "Passord", "passwordPlaceholder": "La stå tomt hvis enheten ikke har passord", "probeButton": "Sjekk tilgang", diff --git a/frontend/src/assets/i18n/pl.json b/frontend/src/assets/i18n/pl.json index 768ec40..0cb99e1 100644 --- a/frontend/src/assets/i18n/pl.json +++ b/frontend/src/assets/i18n/pl.json @@ -165,6 +165,7 @@ "editDialogTitle": "Edytuj urządzenie", "host": "Host", "port": "Port", + "nameValidationHint": "Użyj tylko liter, cyfr, myślnika i podkreślenia bez spacji.", "sshUser": "Użytkownik", "sshPrivateKey": "Klucz prywatny SSH", "optionalPassword": "Opcjonalne hasło", @@ -539,7 +540,6 @@ "host": "Host / URL", "hostPlaceholder": "np. 192.168.88.1 albo http://192.168.88.1", "port": "Port", - "username": "Użytkownik", "password": "Hasło", "passwordPlaceholder": "Puste, jeśli urządzenie nie ma hasła", "probeButton": "Sprawdź dostęp", diff --git a/frontend/src/styles/pages.css b/frontend/src/styles/pages.css index 50f8bed..4a935ff 100644 --- a/frontend/src/styles/pages.css +++ b/frontend/src/styles/pages.css @@ -3758,3 +3758,22 @@ body.dark-theme .device-toggle.is-active{background:linear-gradient(135deg,color max-width: none; } } + +.form-field-error{ + display:block; + margin-top:0.35rem; + font-size:0.78rem; + line-height:1.35; + color:var(--red-400); +} + +.repository-table .table-row-menu__list .p-button{justify-content:flex-start;} + +@media (max-width: 960px){ + .repository-table .table-row-menu{ + display:inline-block !important; + } + .repository-table .table-row-menu__list{ + min-width:min(220px,calc(100vw - 32px)); + } +}