@using System.Linq @using ScadaLink.Security @using Microsoft.AspNetCore.Components.Routing @using Microsoft.JSInterop @implements IDisposable @inject NavigationManager Navigation @inject IJSRuntime JS ▮ ScadaBridge Dashboard @* Admin section — Admin role only *@ LDAP Mappings Sites API Keys @* Import Bundle requires Admin only — Design role is not sufficient. Export Bundle lives in the Design section (RequireDesign). *@ Import Bundle @* Design section — Design role *@ Templates Shared Scripts Connections External Systems Export Bundle @* Deployment section — Deployment role *@ Topology Deployments Debug View @* Notifications — mixed-role section; each item gated by its own policy. The section is ungated: every authenticated user holds at least one of Admin/Design/Deployment, so it always has a visible child. *@ SMTP Configuration Notification Lists Notification Report Notification KPIs @* Site Calls — Site Call Audit (#22). Deployment-role only, matching the Notification Report page's gate; the whole section sits inside the policy block so a non-Deployment user does not see the heading. *@ Site Calls @* Monitoring — Health Dashboard is all-roles; Event Logs and Parked Messages are Deployment-role only (Component-CentralUI). The section is ungated because Health Dashboard is always a visible child. *@ Health Dashboard Event Logs Parked Messages @* Audit — gated on the OperationalAudit policy (#23 M7-T15 / Bundle G). Hosts the Audit Log page (#23 M7) and the Configuration Audit Log (IAuditService config-change viewer). The whole section sits inside the policy block: a non-audit user does not even see the heading. OperationalAudit is satisfied by the Admin, Audit, and AuditReadOnly roles. *@ Audit Log Configuration Audit Log @* CentralUI-024: claim type resolved via JwtTokenService. *@ @context.User.GetDisplayName() @* CentralUI-017: logout is a state-changing POST and is CSRF-protected — the antiforgery token is required. *@ Sign Out @code { // Expanded-section state persists in the "scadabridge_nav" cookie, written // by navState.set / read by navState.get (wwwroot/js/nav-state.js) — a // comma-separated list of section ids. // Every collapsible section id. Also the allow-list for parsing the cookie. private static readonly string[] SectionIds = { "admin", "design", "deployment", "notifications", "sitecalls", "monitoring", "audit" }; // The currently-expanded sections. Populated from the cookie on first // render; mutated by ToggleAsync and by navigating into a section. private readonly HashSet _expanded = new(StringComparer.Ordinal); protected override void OnInitialized() { Navigation.LocationChanged += OnLocationChanged; } protected override async Task OnAfterRenderAsync(bool firstRender) { if (!firstRender) { return; } // Hydrate from the cookie. Until this completes the sidebar paints // collapsed (the "collapsed by default" state) — matching how TreeView // hydrates its expand state in OnAfterRenderAsync(firstRender). string saved; try { saved = await JS.InvokeAsync("navState.get") ?? string.Empty; } catch (JSDisconnectedException) { return; } foreach (var id in saved.Split( ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { if (Array.IndexOf(SectionIds, id) >= 0) { _expanded.Add(id); } } // The section of the page we loaded on is always expanded. if (EnsureCurrentSectionExpanded()) { await PersistAsync(); } StateHasChanged(); } private void OnLocationChanged(object? sender, LocationChangedEventArgs e) { // Navigating into a collapsed section expands it (and remembers it). if (EnsureCurrentSectionExpanded()) { _ = PersistAsync(); _ = InvokeAsync(StateHasChanged); } } private async Task ToggleAsync(string id) { if (!_expanded.Remove(id)) { _expanded.Add(id); } await PersistAsync(); } // Adds the current page's section to _expanded; returns true if it changed. private bool EnsureCurrentSectionExpanded() { var section = CurrentSection(); return section is not null && _expanded.Add(section); } // Maps the current URL's first path segment to a section id, or null for // sectionless pages (Dashboard, Login). private string? CurrentSection() { var relative = Navigation.ToBaseRelativePath(Navigation.Uri); var firstSegment = relative.Split('?', '#')[0] .Split('/', StringSplitOptions.RemoveEmptyEntries) .FirstOrDefault(); return firstSegment switch { "admin" => "admin", "design" => "design", "deployment" => "deployment", "notifications" => "notifications", "site-calls" => "sitecalls", "monitoring" => "monitoring", "audit" => "audit", _ => null, }; } private async Task PersistAsync() { try { await JS.InvokeVoidAsync("navState.set", string.Join(',', _expanded)); } catch (JSDisconnectedException) { // The circuit is gone — nothing to persist to. } } public void Dispose() { Navigation.LocationChanged -= OnLocationChanged; } }