fixes
This commit is contained in:
@@ -21,6 +21,11 @@ class RouterBase(BaseModel):
|
|||||||
disable_binary_backups: bool = False
|
disable_binary_backups: bool = False
|
||||||
disable_ping: 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")
|
@field_validator("name")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_name(cls, value: str) -> str:
|
def validate_name(cls, value: str) -> str:
|
||||||
@@ -63,7 +68,22 @@ class RouterUpdate(BaseModel):
|
|||||||
disable_binary_backups: bool | None = None
|
disable_binary_backups: bool | None = None
|
||||||
disable_ping: 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
|
@classmethod
|
||||||
def normalize_text(cls, value: str | None) -> str | None:
|
def normalize_text(cls, value: str | None) -> str | None:
|
||||||
normalized = (value or "").strip()
|
normalized = (value or "").strip()
|
||||||
|
|||||||
@@ -60,3 +60,54 @@ def test_router_list_marks_global_ssh_key_usage(monkeypatch, tmp_path):
|
|||||||
payload = list_response.json()
|
payload = list_response.json()
|
||||||
assert payload[0]["uses_global_ssh_key"] is True
|
assert payload[0]["uses_global_ssh_key"] is True
|
||||||
assert payload[0]["has_effective_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
|
||||||
|
|||||||
@@ -296,6 +296,7 @@
|
|||||||
<span class="form-field">
|
<span class="form-field">
|
||||||
<label>{{ 'routers.name' | translate }}</label>
|
<label>{{ 'routers.name' | translate }}</label>
|
||||||
<input pInputText formControlName="name" />
|
<input pInputText formControlName="name" />
|
||||||
|
<small class="form-field-error" *ngIf="form.controls.name.invalid && (form.controls.name.dirty || form.controls.name.touched)">{{ 'routers.nameValidationHint' | translate }}</small>
|
||||||
</span>
|
</span>
|
||||||
<span class="form-field">
|
<span class="form-field">
|
||||||
<label>{{ 'routers.deviceType' | translate }}</label>
|
<label>{{ 'routers.deviceType' | translate }}</label>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { HttpResponse } from '@angular/common/http';
|
import { HttpResponse } from '@angular/common/http';
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { DEVICE_NAME_PATTERN, normalizeDeviceName } from '../../shared/utils/device-name';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { ButtonModule } from 'primeng/button';
|
import { ButtonModule } from 'primeng/button';
|
||||||
@@ -137,7 +138,7 @@ export class RouterDetailPageComponent implements OnInit {
|
|||||||
{ label: 'SwitchOS', value: 'switchos' }
|
{ label: 'SwitchOS', value: 'switchos' }
|
||||||
];
|
];
|
||||||
readonly form = this.fb.nonNullable.group({
|
readonly form = this.fb.nonNullable.group({
|
||||||
name: ['', Validators.required],
|
name: ['', [Validators.required, Validators.pattern(DEVICE_NAME_PATTERN)]],
|
||||||
device_type: ['routeros' as DeviceType, Validators.required],
|
device_type: ['routeros' as DeviceType, Validators.required],
|
||||||
host: ['', Validators.required],
|
host: ['', Validators.required],
|
||||||
port: [22, Validators.required],
|
port: [22, Validators.required],
|
||||||
@@ -237,8 +238,10 @@ export class RouterDetailPageComponent implements OnInit {
|
|||||||
if (this.form.invalid || this.saving) {
|
if (this.form.invalid || this.saving) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const normalizedName = normalizeDeviceName(this.form.controls.name.value);
|
||||||
|
this.form.controls.name.setValue(normalizedName, { emitEvent: false });
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
const payload = this.form.getRawValue();
|
const payload = { ...this.form.getRawValue(), name: normalizedName };
|
||||||
if (payload.device_type === 'switchos') {
|
if (payload.device_type === 'switchos') {
|
||||||
payload.ssh_key = '';
|
payload.ssh_key = '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<app-section-card [title]="'routers.listTitle' | translate" [subtitle]="'routers.listSubtitle' | translate">
|
<app-section-card [title]="'routers.listTitle' | translate" [subtitle]="'routers.listSubtitle' | translate">
|
||||||
<p-table [value]="routers" responsiveLayout="scroll" styleClass="app-table">
|
<p-table [value]="routers" responsiveLayout="stack" [breakpoint]="'960px'" styleClass="app-table repository-table">
|
||||||
<ng-template pTemplate="header">
|
<ng-template pTemplate="header">
|
||||||
<tr><th>{{ 'routers.name' | translate }}</th><th>{{ 'routers.endpoint' | translate }}</th><th>{{ 'routers.access' | translate }}</th><th>{{ 'routers.backupPolicy' | translate }}</th><th>{{ 'routers.ping' | translate }}</th><th>{{ 'common.actions' | translate }}</th></tr>
|
<tr><th>{{ 'routers.name' | translate }}</th><th>{{ 'routers.endpoint' | translate }}</th><th>{{ 'routers.access' | translate }}</th><th>{{ 'routers.backupPolicy' | translate }}</th><th>{{ 'routers.ping' | translate }}</th><th>{{ 'common.actions' | translate }}</th></tr>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
@@ -50,11 +50,20 @@
|
|||||||
<small class="table-secondary">{{ pingLabel(routerItem) }}</small>
|
<small class="table-secondary">{{ pingLabel(routerItem) }}</small>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="table-actions table-actions--labels">
|
<span class="p-column-title">{{ 'common.actions' | translate }}</span>
|
||||||
|
<div class="table-actions table-actions--labels table-actions--desktop-row">
|
||||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" icon="pi pi-arrow-right" [label]="'common.open' | translate" (click)="open(routerItem.id)"></button>
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" icon="pi pi-arrow-right" [label]="'common.open' | translate" (click)="open(routerItem.id)"></button>
|
||||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="secondary" icon="pi pi-pencil" [label]="'common.edit' | translate" (click)="edit(routerItem)"></button>
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="secondary" icon="pi pi-pencil" [label]="'common.edit' | translate" (click)="edit(routerItem)"></button>
|
||||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="danger" icon="pi pi-trash" [label]="'common.delete' | translate" (click)="remove(routerItem.id)"></button>
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="danger" icon="pi pi-trash" [label]="'common.delete' | translate" (click)="remove(routerItem.id)"></button>
|
||||||
</div>
|
</div>
|
||||||
|
<details class="table-row-menu">
|
||||||
|
<summary><i class="pi pi-ellipsis-h"></i><span>{{ 'common.actions' | translate }}</span></summary>
|
||||||
|
<div class="table-row-menu__list">
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" icon="pi pi-arrow-right" [label]="'common.open' | translate" (click)="open(routerItem.id)"></button>
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="secondary" icon="pi pi-pencil" [label]="'common.edit' | translate" (click)="edit(routerItem)"></button>
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="danger" icon="pi pi-trash" [label]="'common.delete' | translate" (click)="remove(routerItem.id)"></button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
@@ -100,6 +109,7 @@
|
|||||||
<span class="form-field">
|
<span class="form-field">
|
||||||
<label>{{ 'routers.name' | translate }}</label>
|
<label>{{ 'routers.name' | translate }}</label>
|
||||||
<input pInputText formControlName="name" placeholder="core-router-waw" />
|
<input pInputText formControlName="name" placeholder="core-router-waw" />
|
||||||
|
<small class="form-field-error" *ngIf="form.controls.name.invalid && (form.controls.name.dirty || form.controls.name.touched)">{{ 'routers.nameValidationHint' | translate }}</small>
|
||||||
</span>
|
</span>
|
||||||
<span class="form-field">
|
<span class="form-field">
|
||||||
<label>{{ 'routers.deviceType' | translate }}</label>
|
<label>{{ 'routers.deviceType' | translate }}</label>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { DEVICE_NAME_PATTERN, normalizeDeviceName } from '../../shared/utils/device-name';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { ButtonModule } from 'primeng/button';
|
import { ButtonModule } from 'primeng/button';
|
||||||
@@ -79,7 +80,7 @@ export class RoutersPageComponent implements OnInit {
|
|||||||
{ label: 'SwitchOS', value: 'switchos' }
|
{ label: 'SwitchOS', value: 'switchos' }
|
||||||
];
|
];
|
||||||
readonly form = this.fb.nonNullable.group({
|
readonly form = this.fb.nonNullable.group({
|
||||||
name: ['', Validators.required],
|
name: ['', [Validators.required, Validators.pattern(DEVICE_NAME_PATTERN)]],
|
||||||
device_type: ['routeros' as DeviceType, Validators.required],
|
device_type: ['routeros' as DeviceType, Validators.required],
|
||||||
host: ['', Validators.required],
|
host: ['', Validators.required],
|
||||||
port: [22, Validators.required],
|
port: [22, Validators.required],
|
||||||
@@ -159,7 +160,8 @@ export class RoutersPageComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.saving = true;
|
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') {
|
if (payload.device_type === 'switchos') {
|
||||||
payload.ssh_key = '';
|
payload.ssh_key = '';
|
||||||
}
|
}
|
||||||
|
|||||||
5
frontend/src/app/shared/utils/device-name.ts
Normal file
5
frontend/src/app/shared/utils/device-name.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const DEVICE_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||||
|
|
||||||
|
export function normalizeDeviceName(value: string | null | undefined): string {
|
||||||
|
return (value ?? '').trim();
|
||||||
|
}
|
||||||
@@ -165,6 +165,7 @@
|
|||||||
"editDialogTitle": "Edit device",
|
"editDialogTitle": "Edit device",
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
|
"nameValidationHint": "Use only letters, digits, dashes, and underscores without spaces.",
|
||||||
"sshUser": "Username",
|
"sshUser": "Username",
|
||||||
"sshPrivateKey": "SSH private key",
|
"sshPrivateKey": "SSH private key",
|
||||||
"optionalPassword": "Optional password",
|
"optionalPassword": "Optional password",
|
||||||
@@ -539,7 +540,6 @@
|
|||||||
"host": "Host / URL",
|
"host": "Host / URL",
|
||||||
"hostPlaceholder": "for example 192.168.88.1 or http://192.168.88.1",
|
"hostPlaceholder": "for example 192.168.88.1 or http://192.168.88.1",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"username": "Username",
|
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"passwordPlaceholder": "Leave empty when the device has no password",
|
"passwordPlaceholder": "Leave empty when the device has no password",
|
||||||
"probeButton": "Check access",
|
"probeButton": "Check access",
|
||||||
|
|||||||
@@ -165,7 +165,8 @@
|
|||||||
"editDialogTitle": "Rediger enhet",
|
"editDialogTitle": "Rediger enhet",
|
||||||
"host": "Vert",
|
"host": "Vert",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"sshUser": "Bruker",
|
"nameValidationHint": "Use only letters, digits, dashes, and underscores without spaces.",
|
||||||
|
"sshUser": "Brukernavn",
|
||||||
"sshPrivateKey": "SSH privat nøkkel",
|
"sshPrivateKey": "SSH privat nøkkel",
|
||||||
"optionalPassword": "Valgfritt passord",
|
"optionalPassword": "Valgfritt passord",
|
||||||
"optionalPrivateKey": "Valgfri privat nøkkel",
|
"optionalPrivateKey": "Valgfri privat nøkkel",
|
||||||
@@ -521,7 +522,6 @@
|
|||||||
"host": "Vert / URL",
|
"host": "Vert / URL",
|
||||||
"hostPlaceholder": "for eksempel 192.168.88.1 eller http://192.168.88.1",
|
"hostPlaceholder": "for eksempel 192.168.88.1 eller http://192.168.88.1",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"username": "Brukernavn",
|
|
||||||
"password": "Passord",
|
"password": "Passord",
|
||||||
"passwordPlaceholder": "La stå tomt hvis enheten ikke har passord",
|
"passwordPlaceholder": "La stå tomt hvis enheten ikke har passord",
|
||||||
"probeButton": "Sjekk tilgang",
|
"probeButton": "Sjekk tilgang",
|
||||||
|
|||||||
@@ -165,6 +165,7 @@
|
|||||||
"editDialogTitle": "Edytuj urządzenie",
|
"editDialogTitle": "Edytuj urządzenie",
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
|
"nameValidationHint": "Użyj tylko liter, cyfr, myślnika i podkreślenia bez spacji.",
|
||||||
"sshUser": "Użytkownik",
|
"sshUser": "Użytkownik",
|
||||||
"sshPrivateKey": "Klucz prywatny SSH",
|
"sshPrivateKey": "Klucz prywatny SSH",
|
||||||
"optionalPassword": "Opcjonalne hasło",
|
"optionalPassword": "Opcjonalne hasło",
|
||||||
@@ -539,7 +540,6 @@
|
|||||||
"host": "Host / URL",
|
"host": "Host / URL",
|
||||||
"hostPlaceholder": "np. 192.168.88.1 albo http://192.168.88.1",
|
"hostPlaceholder": "np. 192.168.88.1 albo http://192.168.88.1",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"username": "Użytkownik",
|
|
||||||
"password": "Hasło",
|
"password": "Hasło",
|
||||||
"passwordPlaceholder": "Puste, jeśli urządzenie nie ma hasła",
|
"passwordPlaceholder": "Puste, jeśli urządzenie nie ma hasła",
|
||||||
"probeButton": "Sprawdź dostęp",
|
"probeButton": "Sprawdź dostęp",
|
||||||
|
|||||||
@@ -3758,3 +3758,22 @@ body.dark-theme .device-toggle.is-active{background:linear-gradient(135deg,color
|
|||||||
max-width: none;
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user