diff --git a/ScadaLink.slnx b/ScadaLink.slnx index a5ae9c3..465035b 100644 --- a/ScadaLink.slnx +++ b/ScadaLink.slnx @@ -41,5 +41,6 @@ + diff --git a/docs/test_infra/test_infra.md b/docs/test_infra/test_infra.md index f18f740..babb402 100644 --- a/docs/test_infra/test_infra.md +++ b/docs/test_infra/test_infra.md @@ -1,6 +1,6 @@ # Test Infrastructure -This document describes the local Docker-based test infrastructure for ScadaLink development. Six services provide the external dependencies needed to run and test the system locally. The first six run in `infra/docker-compose.yml`; Traefik runs alongside the cluster nodes in `docker/docker-compose.yml`. +This document describes the local Docker-based test infrastructure for ScadaLink development. Seven services provide the external dependencies needed to run and test the system locally. The first seven run in `infra/docker-compose.yml`; Traefik runs alongside the cluster nodes in `docker/docker-compose.yml`. ## Services @@ -12,6 +12,7 @@ This document describes the local Docker-based test infrastructure for ScadaLink | SMTP (Mailpit) | `axllent/mailpit:latest` | 1025 (SMTP), 8025 (web) | Environment vars | `infra/` | | REST API (Flask) | Custom build (`infra/restapi/Dockerfile`) | 5200 | `infra/restapi/app.py` | `infra/` | | LmxFakeProxy | Custom build (`infra/lmxfakeproxy/Dockerfile`) | 50051 (gRPC) | Environment vars | `infra/` | +| Playwright | `mcr.microsoft.com/playwright:v1.58.2-noble` | 3000 (WebSocket) | Command args | `infra/` | | Traefik LB | `traefik:v3.4` | 9000 (proxy), 8180 (dashboard) | `docker/traefik/` | `docker/` | ## Quick Start @@ -43,6 +44,7 @@ Each service has a dedicated document with configuration details, verification s - [test_infra_smtp.md](test_infra_smtp.md) — SMTP test server (Mailpit) - [test_infra_restapi.md](test_infra_restapi.md) — REST API test server (Flask) - [test_infra_lmxfakeproxy.md](test_infra_lmxfakeproxy.md) — LmxProxy fake server (OPC UA bridge) +- [test_infra_playwright.md](test_infra_playwright.md) — Playwright browser server (Central UI testing) - Traefik LB — see `docker/README.md` and `docker/traefik/` (runs with the cluster, not in `infra/`) ## Connection Strings @@ -103,7 +105,7 @@ After a full teardown, the next `docker compose up -d` starts fresh — re-run t ``` infra/ - docker-compose.yml # All five services + docker-compose.yml # All seven services teardown.sh # Teardown script (volumes, images, venv) glauth/config.toml # LDAP users and groups mssql/setup.sql # Database and user creation diff --git a/docs/test_infra/test_infra_playwright.md b/docs/test_infra/test_infra_playwright.md new file mode 100644 index 0000000..098d7f8 --- /dev/null +++ b/docs/test_infra/test_infra_playwright.md @@ -0,0 +1,104 @@ +# Test Infrastructure: Playwright Browser Server + +## Overview + +The Playwright browser server provides a remote headless browser (Chromium, Firefox, WebKit) that test scripts connect to over the network. It runs as a Playwright Server on port 3000, allowing UI tests for the Central UI (Blazor Server) to run from the host machine while the browser executes inside the container with access to the Docker network. + +## Image & Ports + +- **Image**: `mcr.microsoft.com/playwright:v1.58.2-noble` (Ubuntu 24.04 LTS) +- **Server port**: 3000 (Playwright Server WebSocket endpoint) + +## Configuration + +| Setting | Value | Description | +|---------|-------|-------------| +| `--host 0.0.0.0` | Bind address | Listen on all interfaces | +| `--port 3000` | Server port | Playwright Server WebSocket port | +| `ipc: host` | Docker IPC | Shared IPC namespace (required for Chromium) | + +No additional config files are needed. The container runs `npx playwright run-server` on startup. + +## Connecting from Test Scripts + +Test scripts run on the host and connect to the browser server via WebSocket. The connection URL is: + +``` +ws://localhost:3000 +``` + +### .NET (Microsoft.Playwright) + +```csharp +using var playwright = await Playwright.CreateAsync(); +var browser = await playwright.Chromium.ConnectAsync("ws://localhost:3000"); +var page = await browser.NewPageAsync(); + +// Browser runs inside Docker — use the Docker network hostname for Traefik. +await page.GotoAsync("http://scadalink-traefik"); +``` + +### Node.js + +```javascript +const { chromium } = require('playwright'); +const browser = await chromium.connect('ws://localhost:3000'); +const page = await browser.newPage(); +await page.goto('http://scadalink-traefik'); +``` + +### Python + +```python +from playwright.sync_api import sync_playwright + +with sync_playwright() as p: + browser = p.chromium.connect("ws://localhost:3000") + page = browser.new_page() + page.goto("http://scadalink-traefik") +``` + +## Central UI Access + +The Playwright container is on the `scadalink-net` Docker network, so it can reach the Central UI cluster nodes directly: + +| Target | URL in Test Scripts | +|--------|---------------------| +| Traefik LB | `http://scadalink-traefik` | +| Central Node A | `http://scadalink-central-a:5000` | +| Central Node B | `http://scadalink-central-b:5000` | + +**Important**: The browser runs inside the Docker container, so `page.goto()` URLs must use Docker network hostnames (not `localhost`). The test script itself connects to the Playwright server via `ws://localhost:3000` (host-mapped port), but all URLs navigated by the browser resolve inside the container. + +## Verification + +1. Check the container is running: + +```bash +docker ps --filter name=scadalink-playwright +``` + +2. Check the server is accepting connections (look for the WebSocket endpoint in logs): + +```bash +docker logs scadalink-playwright 2>&1 | head -5 +``` + +3. Quick smoke test with a one-liner (requires `npx` and `playwright` on the host): + +```bash +npx playwright@1.58.2 test --browser chromium --connect ws://localhost:3000 +``` + +## Relevance to ScadaLink Components + +- **Central UI** — end-to-end UI testing of all Blazor Server pages (login, admin, design, deployment, monitoring workflows). +- **Traefik Proxy** — verify load balancer behavior, failover, and active node routing from a browser perspective. + +## Notes + +- The container includes Chromium, Firefox, and WebKit. Connect to the desired browser via `playwright.chromium.connect()`, `playwright.firefox.connect()`, or `playwright.webkit.connect()`. +- The `ipc: host` flag is required for Chromium to avoid out-of-memory crashes in the container. +- The Playwright Server version (`1.58.2`) must match the `@playwright` package version used by test scripts on the host. +- The container is stateless — no test data or browser state persists between restarts. +- To stop only the Playwright container: `cd infra && docker compose stop playwright`. diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index e2f0e6a..c295532 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -88,6 +88,20 @@ services: - scadalink-net restart: unless-stopped + playwright: + image: mcr.microsoft.com/playwright:v1.58.2-noble + container_name: scadalink-playwright + ports: + - "3000:3000" + command: > + npx -y playwright@1.58.2 run-server + --host 0.0.0.0 + --port 3000 + ipc: host + networks: + - scadalink-net + restart: unless-stopped + volumes: scadalink-mssql-data: diff --git a/infra/tools/playwright_smoke.py b/infra/tools/playwright_smoke.py new file mode 100644 index 0000000..bb6fae5 --- /dev/null +++ b/infra/tools/playwright_smoke.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Quick smoke test: verify Playwright can reach the Central UI through Traefik.""" + +import sys +from playwright.sync_api import sync_playwright + +# The browser runs inside Docker, so use the Docker network hostname for Traefik. +# The Playwright server WebSocket is exposed to the host on port 3000. +TRAEFIK_URL = "http://scadalink-traefik" +PLAYWRIGHT_WS = "ws://localhost:3000" + + +def main(): + with sync_playwright() as p: + print(f"Connecting to Playwright server at {PLAYWRIGHT_WS} ...") + browser = p.chromium.connect(PLAYWRIGHT_WS) + + page = browser.new_page() + print(f"Navigating to {TRAEFIK_URL} ...") + response = page.goto(TRAEFIK_URL, wait_until="networkidle", timeout=15000) + + status = response.status if response else None + title = page.title() + url = page.url + + print(f" Status: {status}") + print(f" Title: {title}") + print(f" URL: {url}") + + # Check for the login page (unauthenticated users get redirected) + has_login = page.locator("input[type='password'], form[action*='login'], button:has-text('Login'), button:has-text('Sign in')").count() > 0 + if has_login: + print(" Login form detected: YES") + + browser.close() + + if status and 200 <= status < 400: + print("\nSMOKE TEST PASSED: Central UI is reachable through Traefik.") + return 0 + else: + print(f"\nSMOKE TEST FAILED: unexpected status {status}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs b/src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs index a638f51..f989fb9 100644 --- a/src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs +++ b/src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs @@ -80,6 +80,47 @@ public static class AuthEndpoints context.Response.Redirect("/"); }).DisableAntiforgery(); + endpoints.MapPost("/auth/token", async (HttpContext context) => + { + var form = await context.Request.ReadFormAsync(); + var username = form["username"].ToString(); + var password = form["password"].ToString(); + + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + { + return Results.Json(new { error = "Username and password are required." }, statusCode: 400); + } + + var ldapAuth = context.RequestServices.GetRequiredService(); + var jwtService = context.RequestServices.GetRequiredService(); + var roleMapper = context.RequestServices.GetRequiredService(); + + var authResult = await ldapAuth.AuthenticateAsync(username, password); + if (!authResult.Success) + { + return Results.Json( + new { error = authResult.ErrorMessage ?? "Authentication failed." }, + statusCode: 401); + } + + var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []); + + var token = jwtService.GenerateToken( + authResult.DisplayName ?? username, + authResult.Username ?? username, + roleMappingResult.Roles, + roleMappingResult.IsSystemWideDeployment ? null : roleMappingResult.PermittedSiteIds); + + return Results.Json(new + { + access_token = token, + token_type = "Bearer", + username = authResult.Username ?? username, + display_name = authResult.DisplayName ?? username, + roles = roleMappingResult.Roles, + }); + }).DisableAntiforgery(); + endpoints.MapPost("/auth/logout", async (HttpContext context) => { await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor new file mode 100644 index 0000000..9c372f9 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor @@ -0,0 +1,144 @@ +@page "/admin/api-keys/create" +@page "/admin/api-keys/{Id:int}/edit" +@using ScadaLink.Security +@using ScadaLink.Commons.Entities.InboundApi +@using ScadaLink.Commons.Interfaces.Repositories +@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] +@inject IInboundApiRepository InboundApiRepository +@inject NavigationManager NavigationManager + +
+
+ @if (_saved) + { + ← Back to API Keys + } + else + { + ← Back + } +

@(_saved ? "API Key Created" : (_editingKey != null ? "Edit API Key" : "Add API Key"))

+
+ + @if (_loading) + { + + } + else if (_saved && _newlyCreatedKeyValue != null) + { +
+ New API Key Created +
+ @_newlyCreatedKeyValue + +
+ Save this key now. It will not be shown again in full. +
+ Back to API Keys + } + else if (_errorMessage != null) + { +
@_errorMessage
+ } + else + { +
+
+
+ + +
+ @if (_formError != null) + { +
@_formError
+ } +
+ + +
+
+
+ } +
+ +@code { + [Parameter] public int? Id { get; set; } + + private ApiKey? _editingKey; + private string _formName = string.Empty; + private string? _formError; + private string? _errorMessage; + private string? _newlyCreatedKeyValue; + private bool _loading = true; + private bool _saved; + + protected override async Task OnInitializedAsync() + { + try + { + if (Id.HasValue) + { + _editingKey = await InboundApiRepository.GetApiKeyByIdAsync(Id.Value); + if (_editingKey == null) + { + _errorMessage = $"API key with ID {Id.Value} not found."; + } + else + { + _formName = _editingKey.Name; + } + } + } + catch (Exception ex) + { + _errorMessage = $"Failed to load API key: {ex.Message}"; + } + _loading = false; + } + + private async Task SaveKey() + { + _formError = null; + if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; } + + try + { + if (_editingKey != null) + { + _editingKey.Name = _formName.Trim(); + await InboundApiRepository.UpdateApiKeyAsync(_editingKey); + await InboundApiRepository.SaveChangesAsync(); + NavigationManager.NavigateTo("/admin/api-keys"); + } + else + { + var keyValue = GenerateApiKey(); + var key = new ApiKey(_formName.Trim(), keyValue) { IsEnabled = true }; + await InboundApiRepository.AddApiKeyAsync(key); + await InboundApiRepository.SaveChangesAsync(); + _newlyCreatedKeyValue = keyValue; + _saved = true; + } + } + catch (Exception ex) + { + _formError = $"Save failed: {ex.Message}"; + } + } + + private void GoBack() => NavigationManager.NavigateTo("/admin/api-keys"); + + private void CopyKeyToClipboard() + { + // Note: JS interop for clipboard would be needed for actual copy. + // For now the key is displayed for manual copy. + } + + private static string GenerateApiKey() + { + var bytes = new byte[32]; + using var rng = System.Security.Cryptography.RandomNumberGenerator.Create(); + rng.GetBytes(bytes); + return Convert.ToBase64String(bytes).Replace("+", "").Replace("/", "").Replace("=", "")[..40]; + } +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeys.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeys.razor index 9bbc5b3..a36b47e 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeys.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeys.razor @@ -4,11 +4,12 @@ @using ScadaLink.Commons.Interfaces.Repositories @attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] @inject IInboundApiRepository InboundApiRepository +@inject NavigationManager NavigationManager

API Key Management

- +
@@ -24,42 +25,6 @@ } else { - @if (_showForm) - { -
-
-
@(_editingKey == null ? "Add New API Key" : "Edit API Key")
-
-
- - -
-
- - -
-
- @if (_formError != null) - { -
@_formError
- } -
-
- } - - @if (_newlyCreatedKeyValue != null) - { -
- New API Key Created -
- @_newlyCreatedKeyValue - -
- Save this key now. It will not be shown again in full. - -
- } - @@ -95,7 +60,7 @@ @@ -172,13 +136,6 @@ private bool _loading = true; private string? _errorMessage; - private bool _showForm; - private DataConnection? _editingConnection; - private string _formName = string.Empty; - private string _formProtocol = string.Empty; - private string? _formConfiguration; - private string? _formError; - private bool _showAssignForm; private int _assignConnectionId; private int _assignSiteId; @@ -224,67 +181,6 @@ _loading = false; } - private void ShowAddForm() - { - _editingConnection = null; - _formName = string.Empty; - _formProtocol = string.Empty; - _formConfiguration = null; - _formError = null; - _showForm = true; - } - - private void EditConnection(DataConnection conn) - { - _editingConnection = conn; - _formName = conn.Name; - _formProtocol = conn.Protocol; - _formConfiguration = conn.Configuration; - _formError = null; - _showForm = true; - } - - private void CancelForm() - { - _showForm = false; - _editingConnection = null; - _formError = null; - } - - private async Task SaveConnection() - { - _formError = null; - if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; } - if (string.IsNullOrWhiteSpace(_formProtocol)) { _formError = "Protocol is required."; return; } - - try - { - if (_editingConnection != null) - { - _editingConnection.Name = _formName.Trim(); - _editingConnection.Protocol = _formProtocol; - _editingConnection.Configuration = _formConfiguration?.Trim(); - await SiteRepository.UpdateDataConnectionAsync(_editingConnection); - } - else - { - var conn = new DataConnection(_formName.Trim(), _formProtocol) - { - Configuration = _formConfiguration?.Trim() - }; - await SiteRepository.AddDataConnectionAsync(conn); - } - await SiteRepository.SaveChangesAsync(); - _showForm = false; - _toast.ShowSuccess("Connection saved."); - await LoadDataAsync(); - } - catch (Exception ex) - { - _formError = $"Save failed: {ex.Message}"; - } - } - private async Task DeleteConnection(DataConnection conn) { var confirmed = await _confirmDialog.ShowAsync( diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappingForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappingForm.razor new file mode 100644 index 0000000..8d76862 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappingForm.razor @@ -0,0 +1,213 @@ +@page "/admin/ldap-mappings/create" +@page "/admin/ldap-mappings/{Id:int}/edit" +@using ScadaLink.Commons.Entities.Security +@using ScadaLink.Commons.Interfaces.Repositories +@using ScadaLink.Security +@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] +@inject ISecurityRepository SecurityRepository +@inject NavigationManager NavigationManager + +
+
+ +
+ + + +
+
+
@(IsEditMode ? "Edit LDAP Mapping" : "Add LDAP Mapping")
+
+ + +
+
+ + +
+ @if (_formError != null) + { +
@_formError
+ } +
+ + +
+
+
+ + @if (IsEditMode && _formRole.Equals("Deployment", StringComparison.OrdinalIgnoreCase)) + { +
+
+
Site Scope Rules
+ + @if (_scopeRules.Count > 0) + { +
+ @onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.Id}/edit")'>Edit @if (key.IsEnabled) { +

@(Id.HasValue ? "Edit Data Connection" : "Add Data Connection")

+ + + @if (_loading) + { + + } + else + { +
+
+
+ + +
+
+ + +
+
+ + +
+ @if (_formError != null) + { +
@_formError
+ } +
+ + +
+
+
+ } + + +@code { + [Parameter] public int? Id { get; set; } + + private bool _loading = true; + private DataConnection? _editingConnection; + private string _formName = string.Empty; + private string _formProtocol = string.Empty; + private string? _formConfiguration; + private string? _formError; + + protected override async Task OnInitializedAsync() + { + if (Id.HasValue) + { + try + { + _editingConnection = await SiteRepository.GetDataConnectionByIdAsync(Id.Value); + if (_editingConnection != null) + { + _formName = _editingConnection.Name; + _formProtocol = _editingConnection.Protocol; + _formConfiguration = _editingConnection.Configuration; + } + } + catch (Exception ex) + { + _formError = $"Failed to load connection: {ex.Message}"; + } + } + _loading = false; + } + + private async Task SaveConnection() + { + _formError = null; + if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; } + if (string.IsNullOrWhiteSpace(_formProtocol)) { _formError = "Protocol is required."; return; } + + try + { + if (_editingConnection != null) + { + _editingConnection.Name = _formName.Trim(); + _editingConnection.Protocol = _formProtocol; + _editingConnection.Configuration = _formConfiguration?.Trim(); + await SiteRepository.UpdateDataConnectionAsync(_editingConnection); + } + else + { + var conn = new DataConnection(_formName.Trim(), _formProtocol) + { + Configuration = _formConfiguration?.Trim() + }; + await SiteRepository.AddDataConnectionAsync(conn); + } + await SiteRepository.SaveChangesAsync(); + NavigationManager.NavigateTo("/admin/data-connections"); + } + catch (Exception ex) + { + _formError = $"Save failed: {ex.Message}"; + } + } + + private void GoBack() + { + NavigationManager.NavigateTo("/admin/data-connections"); + } +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor index 5d9183e..d5ba31a 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor @@ -4,11 +4,12 @@ @using ScadaLink.Commons.Interfaces.Repositories @attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] @inject ISiteRepository SiteRepository +@inject NavigationManager NavigationManager

Data Connections

- +
@@ -24,43 +25,6 @@ } else { - @if (_showForm) - { -
-
-
@(_editingConnection == null ? "Add New Connection" : "Edit Connection")
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- @if (_formError != null) - { -
@_formError
- } -
-
- } - @* Assignment form *@ @if (_showAssignForm) { @@ -154,7 +118,7 @@
+ @onclick='() => NavigationManager.NavigateTo($"/admin/data-connections/{conn.Id}/edit")'>Edit
+ + + + + + + + + @foreach (var rule in _scopeRules) + { + + + + + + } + +
IDSite IDActions
@rule.Id@rule.SiteId + +
+ } + else + { +

All sites (no restrictions)

+ } + +
+ + +
+ @if (_scopeRuleError != null) + { +
@_scopeRuleError
+ } +
+ +
+
+ + } + + +@code { + [Parameter] public int? Id { get; set; } + + private bool IsEditMode => Id.HasValue; + + private LdapGroupMapping? _editingMapping; + private string _formGroupName = string.Empty; + private string _formRole = string.Empty; + private string? _formError; + + private List _scopeRules = new(); + private int _scopeRuleSiteId; + private string? _scopeRuleError; + + private ConfirmDialog _confirmDialog = default!; + + protected override async Task OnInitializedAsync() + { + if (Id.HasValue) + { + _editingMapping = await SecurityRepository.GetMappingByIdAsync(Id.Value); + if (_editingMapping != null) + { + _formGroupName = _editingMapping.LdapGroupName; + _formRole = _editingMapping.Role; + _scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id.Value)).ToList(); + } + } + } + + private void GoBack() + { + NavigationManager.NavigateTo("/admin/ldap-mappings"); + } + + private async Task SaveMapping() + { + _formError = null; + + if (string.IsNullOrWhiteSpace(_formGroupName)) + { + _formError = "LDAP Group Name is required."; + return; + } + if (string.IsNullOrWhiteSpace(_formRole)) + { + _formError = "Role is required."; + return; + } + + try + { + if (_editingMapping != null) + { + _editingMapping.LdapGroupName = _formGroupName.Trim(); + _editingMapping.Role = _formRole; + await SecurityRepository.UpdateMappingAsync(_editingMapping); + } + else + { + var mapping = new LdapGroupMapping(_formGroupName.Trim(), _formRole); + await SecurityRepository.AddMappingAsync(mapping); + } + + await SecurityRepository.SaveChangesAsync(); + NavigationManager.NavigateTo("/admin/ldap-mappings"); + } + catch (Exception ex) + { + _formError = $"Save failed: {ex.Message}"; + } + } + + private async Task AddScopeRule() + { + _scopeRuleError = null; + + if (_scopeRuleSiteId <= 0) + { + _scopeRuleError = "Site ID must be a positive number."; + return; + } + + try + { + var rule = new SiteScopeRule { LdapGroupMappingId = Id!.Value, SiteId = _scopeRuleSiteId }; + await SecurityRepository.AddScopeRuleAsync(rule); + await SecurityRepository.SaveChangesAsync(); + _scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id.Value)).ToList(); + _scopeRuleSiteId = 0; + } + catch (Exception ex) + { + _scopeRuleError = $"Save failed: {ex.Message}"; + } + } + + private async Task DeleteScopeRule(SiteScopeRule rule) + { + var confirmed = await _confirmDialog.ShowAsync( + $"Delete scope rule for Site {rule.SiteId}? This cannot be undone.", + "Delete Scope Rule"); + if (!confirmed) return; + + try + { + await SecurityRepository.DeleteScopeRuleAsync(rule.Id); + await SecurityRepository.SaveChangesAsync(); + _scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id!.Value)).ToList(); + } + catch (Exception ex) + { + _scopeRuleError = $"Delete failed: {ex.Message}"; + } + } +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappings.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappings.razor index 7c81445..0ffd1f8 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappings.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappings.razor @@ -4,11 +4,12 @@ @using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Security @inject ISecurityRepository SecurityRepository +@inject NavigationManager NavigationManager

LDAP Group Mappings

- +
@if (_loading) @@ -21,39 +22,6 @@ } else { - @* Add / Edit form *@ - @if (_showForm) - { -
-
-
@(_editingMapping == null ? "Add New Mapping" : "Edit Mapping")
-
-
- - -
-
- - -
-
- - -
-
- @if (_formError != null) - { -
@_formError
- } -
-
- } - @* Mappings table *@ @@ -95,13 +63,12 @@ } @if (mapping.Role.Equals("Deployment", StringComparison.OrdinalIgnoreCase)) { - + (manage on edit page) } @@ -110,29 +77,6 @@
+ @onclick='() => NavigationManager.NavigateTo($"/admin/ldap-mappings/{mapping.Id}/edit")'>Edit
- @* Scope rule form *@ - @if (_showScopeRuleForm) - { -
-
-
Add Site Scope Rule (Mapping #@_scopeRuleMappingId)
-
-
- - -
-
- - -
-
- @if (_scopeRuleError != null) - { -
@_scopeRuleError
- } -
-
- } }
@@ -142,19 +86,6 @@ private bool _loading = true; private string? _errorMessage; - // Mapping form state - private bool _showForm; - private LdapGroupMapping? _editingMapping; - private string _formGroupName = string.Empty; - private string _formRole = string.Empty; - private string? _formError; - - // Scope rule form state - private bool _showScopeRuleForm; - private int _scopeRuleMappingId; - private int _scopeRuleSiteId; - private string? _scopeRuleError; - protected override async Task OnInitializedAsync() { await LoadDataAsync(); @@ -184,71 +115,6 @@ _loading = false; } - private void ShowAddForm() - { - _editingMapping = null; - _formGroupName = string.Empty; - _formRole = string.Empty; - _formError = null; - _showForm = true; - } - - private void EditMapping(LdapGroupMapping mapping) - { - _editingMapping = mapping; - _formGroupName = mapping.LdapGroupName; - _formRole = mapping.Role; - _formError = null; - _showForm = true; - } - - private void CancelForm() - { - _showForm = false; - _editingMapping = null; - _formError = null; - } - - private async Task SaveMapping() - { - _formError = null; - - if (string.IsNullOrWhiteSpace(_formGroupName)) - { - _formError = "LDAP Group Name is required."; - return; - } - if (string.IsNullOrWhiteSpace(_formRole)) - { - _formError = "Role is required."; - return; - } - - try - { - if (_editingMapping != null) - { - _editingMapping.LdapGroupName = _formGroupName.Trim(); - _editingMapping.Role = _formRole; - await SecurityRepository.UpdateMappingAsync(_editingMapping); - } - else - { - var mapping = new LdapGroupMapping(_formGroupName.Trim(), _formRole); - await SecurityRepository.AddMappingAsync(mapping); - } - - await SecurityRepository.SaveChangesAsync(); - _showForm = false; - _editingMapping = null; - await LoadDataAsync(); - } - catch (Exception ex) - { - _formError = $"Save failed: {ex.Message}"; - } - } - private async Task DeleteMapping(int id) { try @@ -269,45 +135,4 @@ } } - private void ShowScopeRuleForm(int mappingId) - { - _scopeRuleMappingId = mappingId; - _scopeRuleSiteId = 0; - _scopeRuleError = null; - _showScopeRuleForm = true; - } - - private void CancelScopeRuleForm() - { - _showScopeRuleForm = false; - _scopeRuleError = null; - } - - private async Task SaveScopeRule() - { - _scopeRuleError = null; - - if (_scopeRuleSiteId <= 0) - { - _scopeRuleError = "Site ID must be a positive number."; - return; - } - - try - { - var rule = new SiteScopeRule - { - LdapGroupMappingId = _scopeRuleMappingId, - SiteId = _scopeRuleSiteId - }; - await SecurityRepository.AddScopeRuleAsync(rule); - await SecurityRepository.SaveChangesAsync(); - _showScopeRuleForm = false; - await LoadDataAsync(); - } - catch (Exception ex) - { - _scopeRuleError = $"Save failed: {ex.Message}"; - } - } } diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/SiteForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/SiteForm.razor new file mode 100644 index 0000000..3c80438 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/SiteForm.razor @@ -0,0 +1,163 @@ +@page "/admin/sites/create" +@page "/admin/sites/{Id:int}/edit" +@using ScadaLink.Security +@using ScadaLink.Commons.Entities.Sites +@using ScadaLink.Commons.Interfaces.Repositories +@using ScadaLink.Communication +@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] +@inject ISiteRepository SiteRepository +@inject CommunicationService CommunicationService +@inject NavigationManager NavigationManager + +
+
+ +
+ + + +
+
+
@(IsEditMode ? "Edit Site" : "Add Site")
+
+ + +
+
+ + +
+
+ + +
+ +
Node A
+
+ + +
+
+ + +
+ +
Node B
+
+ + +
+
+ + +
+ + @if (_formError != null) + { +
@_formError
+ } +
+ + +
+
+
+
+ +@code { + [Parameter] public int? Id { get; set; } + + private bool IsEditMode => Id.HasValue; + + private Site? _editingSite; + private string _formName = string.Empty; + private string _formIdentifier = string.Empty; + private string? _formDescription; + private string? _formNodeAAddress; + private string? _formNodeBAddress; + private string? _formGrpcNodeAAddress; + private string? _formGrpcNodeBAddress; + private string? _formError; + + private ToastNotification _toast = default!; + + protected override async Task OnInitializedAsync() + { + if (Id.HasValue) + { + _editingSite = await SiteRepository.GetSiteByIdAsync(Id.Value); + if (_editingSite != null) + { + _formName = _editingSite.Name; + _formIdentifier = _editingSite.SiteIdentifier; + _formDescription = _editingSite.Description; + _formNodeAAddress = _editingSite.NodeAAddress; + _formNodeBAddress = _editingSite.NodeBAddress; + _formGrpcNodeAAddress = _editingSite.GrpcNodeAAddress; + _formGrpcNodeBAddress = _editingSite.GrpcNodeBAddress; + } + } + } + + private void GoBack() + { + NavigationManager.NavigateTo("/admin/sites"); + } + + private async Task SaveSite() + { + _formError = null; + + if (string.IsNullOrWhiteSpace(_formName)) + { + _formError = "Name is required."; + return; + } + + try + { + if (_editingSite != null) + { + _editingSite.Name = _formName.Trim(); + _editingSite.Description = _formDescription?.Trim(); + _editingSite.NodeAAddress = _formNodeAAddress?.Trim(); + _editingSite.NodeBAddress = _formNodeBAddress?.Trim(); + _editingSite.GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim(); + _editingSite.GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim(); + await SiteRepository.UpdateSiteAsync(_editingSite); + } + else + { + if (string.IsNullOrWhiteSpace(_formIdentifier)) + { + _formError = "Identifier is required."; + return; + } + var site = new Site(_formName.Trim(), _formIdentifier.Trim()) + { + Description = _formDescription?.Trim(), + NodeAAddress = _formNodeAAddress?.Trim(), + NodeBAddress = _formNodeBAddress?.Trim(), + GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim(), + GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim() + }; + await SiteRepository.AddSiteAsync(site); + } + + await SiteRepository.SaveChangesAsync(); + CommunicationService.RefreshSiteAddresses(); + NavigationManager.NavigateTo("/admin/sites"); + } + catch (Exception ex) + { + _formError = $"Save failed: {ex.Message}"; + } + } +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor index 59b1538..9a1b942 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor @@ -9,6 +9,7 @@ @inject ArtifactDeploymentService ArtifactDeploymentService @inject CommunicationService CommunicationService @inject AuthenticationStateProvider AuthStateProvider +@inject NavigationManager NavigationManager
@@ -22,7 +23,7 @@ } Deploy Artifacts to All Sites - +
@@ -39,62 +40,6 @@ } else { - @if (_showForm) - { -
-
-
@(_editingSite == null ? "Add New Site" : "Edit Site")
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- @if (_formError != null) - { -
@_formError
- } -
-
- } - @@ -146,7 +91,7 @@
+ @onclick='() => NavigationManager.NavigateTo($"/admin/sites/{site.Id}/edit")'>Edit @@ -172,17 +117,6 @@ private bool _loading = true; private string? _errorMessage; - private bool _showForm; - private Site? _editingSite; - private string _formName = string.Empty; - private string _formIdentifier = string.Empty; - private string? _formDescription; - private string? _formNodeAAddress; - private string? _formNodeBAddress; - private string? _formGrpcNodeAAddress; - private string? _formGrpcNodeBAddress; - private string? _formError; - private bool _deploying; private ToastNotification _toast = default!; @@ -217,94 +151,6 @@ _loading = false; } - private void ShowAddForm() - { - _editingSite = null; - _formName = string.Empty; - _formIdentifier = string.Empty; - _formDescription = null; - _formNodeAAddress = null; - _formNodeBAddress = null; - _formGrpcNodeAAddress = null; - _formGrpcNodeBAddress = null; - _formError = null; - _showForm = true; - } - - private void EditSite(Site site) - { - _editingSite = site; - _formName = site.Name; - _formIdentifier = site.SiteIdentifier; - _formDescription = site.Description; - _formNodeAAddress = site.NodeAAddress; - _formNodeBAddress = site.NodeBAddress; - _formGrpcNodeAAddress = site.GrpcNodeAAddress; - _formGrpcNodeBAddress = site.GrpcNodeBAddress; - _formError = null; - _showForm = true; - } - - private void CancelForm() - { - _showForm = false; - _editingSite = null; - _formError = null; - } - - private async Task SaveSite() - { - _formError = null; - - if (string.IsNullOrWhiteSpace(_formName)) - { - _formError = "Name is required."; - return; - } - - try - { - if (_editingSite != null) - { - _editingSite.Name = _formName.Trim(); - _editingSite.Description = _formDescription?.Trim(); - _editingSite.NodeAAddress = _formNodeAAddress?.Trim(); - _editingSite.NodeBAddress = _formNodeBAddress?.Trim(); - _editingSite.GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim(); - _editingSite.GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim(); - await SiteRepository.UpdateSiteAsync(_editingSite); - } - else - { - if (string.IsNullOrWhiteSpace(_formIdentifier)) - { - _formError = "Identifier is required."; - return; - } - var site = new Site(_formName.Trim(), _formIdentifier.Trim()) - { - Description = _formDescription?.Trim(), - NodeAAddress = _formNodeAAddress?.Trim(), - NodeBAddress = _formNodeBAddress?.Trim(), - GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim(), - GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim() - }; - await SiteRepository.AddSiteAsync(site); - } - - await SiteRepository.SaveChangesAsync(); - CommunicationService.RefreshSiteAddresses(); - _showForm = false; - _editingSite = null; - _toast.ShowSuccess(_editingSite == null ? "Site created." : "Site updated."); - await LoadDataAsync(); - } - catch (Exception ex) - { - _formError = $"Save failed: {ex.Message}"; - } - } - private async Task DeleteSite(Site site) { var confirmed = await _confirmDialog.ShowAsync( diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceCreate.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceCreate.razor new file mode 100644 index 0000000..358a049 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceCreate.razor @@ -0,0 +1,143 @@ +@page "/deployment/instances/create" +@using ScadaLink.Security +@using ScadaLink.Commons.Entities.Instances +@using ScadaLink.Commons.Entities.Sites +@using ScadaLink.Commons.Entities.Templates +@using ScadaLink.Commons.Interfaces.Repositories +@using ScadaLink.TemplateEngine.Services +@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)] +@inject ITemplateEngineRepository TemplateEngineRepository +@inject ISiteRepository SiteRepository +@inject InstanceService InstanceService +@inject AuthenticationStateProvider AuthStateProvider +@inject NavigationManager NavigationManager + +
+
+ ← Back +

Create Instance

+
+ + @if (_loading) + { + + } + else + { +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ @if (_formError != null) + { +
@_formError
+ } +
+ + +
+
+
+ } +
+ +@code { + private List _sites = new(); + private List