Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4d0d82f7f | |||
| 2915755a7c |
@@ -21,6 +21,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<Routes/>
|
<Routes/>
|
||||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/nav-state.js"></script>
|
||||||
<script src="_framework/blazor.web.js"></script>
|
<script src="_framework/blazor.web.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
@* Minimal layout for the login page: no side rail, no brand block. The page
|
||||||
|
renders its own centred card. Mirrors ScadaLink CentralUI's LoginLayout. *@
|
||||||
|
@Body
|
||||||
@@ -1,24 +1,9 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
<header class="app-bar">
|
@* Layout chrome ported from ScadaLink CentralUI: no separate top bar — brand sits
|
||||||
<span class="brand"><span class="mark">▮</span> OtOpcUa</span>
|
at the top of the side rail. The sidebar itself is the interactive island
|
||||||
<span class="crumb">›</span>
|
(<NavSidebar/>); MainLayout stays statically rendered so the Body RenderFragment
|
||||||
<span class="crumb">admin console</span>
|
doesn't have to cross an interactive boundary. *@
|
||||||
<span class="spacer"></span>
|
|
||||||
<AuthorizeView>
|
|
||||||
<Authorized>
|
|
||||||
<span class="meta">@context.User.Identity?.Name</span>
|
|
||||||
<span class="conn-pill" data-state="connected">
|
|
||||||
<span class="dot"></span><span>signed in</span>
|
|
||||||
</span>
|
|
||||||
</Authorized>
|
|
||||||
<NotAuthorized>
|
|
||||||
<span class="conn-pill" data-state="disconnected">
|
|
||||||
<span class="dot"></span><span>signed out</span>
|
|
||||||
</span>
|
|
||||||
</NotAuthorized>
|
|
||||||
</AuthorizeView>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="app-shell d-flex flex-column flex-lg-row">
|
<div class="app-shell d-flex flex-column flex-lg-row">
|
||||||
@* Hamburger toggle: visible only on viewports <lg.
|
@* Hamburger toggle: visible only on viewports <lg.
|
||||||
@@ -34,47 +19,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="collapse d-lg-block" id="sidebar-collapse">
|
<div class="collapse d-lg-block" id="sidebar-collapse">
|
||||||
<nav class="side-rail">
|
<NavSidebar />
|
||||||
<div class="rail-eyebrow">Navigation</div>
|
|
||||||
<NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Overview</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/fleet" Match="NavLinkMatch.Prefix">Fleet status</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/hosts" Match="NavLinkMatch.Prefix">Host status</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/clusters" Match="NavLinkMatch.Prefix">Clusters</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/reservations" Match="NavLinkMatch.Prefix">Reservations</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/certificates" Match="NavLinkMatch.Prefix">Certificates</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/role-grants" Match="NavLinkMatch.Prefix">Role grants</NavLink>
|
|
||||||
<div class="rail-eyebrow">Scripting</div>
|
|
||||||
<NavLink class="rail-link" href="/virtual-tags" Match="NavLinkMatch.Prefix">Virtual tags</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/scripted-alarms" Match="NavLinkMatch.Prefix">Scripted alarms</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/scripts" Match="NavLinkMatch.Prefix">Scripts</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/script-log" Match="NavLinkMatch.Prefix">Script log</NavLink>
|
|
||||||
|
|
||||||
<div class="rail-eyebrow">Live</div>
|
|
||||||
<NavLink class="rail-link" href="/deployments" Match="NavLinkMatch.Prefix">Deployments</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/alerts" Match="NavLinkMatch.Prefix">Alerts</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/alarms-historian" Match="NavLinkMatch.Prefix">Alarms historian</NavLink>
|
|
||||||
|
|
||||||
<div class="rail-foot">
|
|
||||||
<AuthorizeView>
|
|
||||||
<Authorized>
|
|
||||||
<div class="rail-eyebrow">Session</div>
|
|
||||||
<a class="rail-user" href="/account">@context.User.Identity?.Name</a>
|
|
||||||
<div class="rail-roles">
|
|
||||||
@string.Join(", ", context.User.Claims
|
|
||||||
.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
|
|
||||||
</div>
|
|
||||||
<form method="post" action="/auth/logout">
|
|
||||||
<AntiforgeryToken />
|
|
||||||
<button class="rail-btn" type="submit">Sign out</button>
|
|
||||||
</form>
|
|
||||||
</Authorized>
|
|
||||||
<NotAuthorized>
|
|
||||||
<div class="rail-eyebrow">Session</div>
|
|
||||||
<a class="rail-btn" href="/login">Sign in</a>
|
|
||||||
</NotAuthorized>
|
|
||||||
</AuthorizeView>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="page">
|
<main class="page">
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
@* A collapsible sidebar nav section: an uppercase-eyebrow button that toggles
|
||||||
|
the visibility of its child nav items. Mirrors the ScadaLink NavSection at
|
||||||
|
/Users/dohertj2/Desktop/scadalink-design/src/ScadaLink.CentralUI/Components/Layout/NavSection.razor
|
||||||
|
but uses OtOpcUa's rail-eyebrow + rail-link classes. *@
|
||||||
|
|
||||||
|
<button type="button"
|
||||||
|
class="rail-eyebrow-toggle"
|
||||||
|
@onclick="OnToggle"
|
||||||
|
aria-expanded="@(Expanded ? "true" : "false")">
|
||||||
|
<span class="rail-eyebrow-chevron">@(Expanded ? "▼" : "▶")</span>
|
||||||
|
<span class="rail-eyebrow-label">@Title</span>
|
||||||
|
</button>
|
||||||
|
@if (Expanded)
|
||||||
|
{
|
||||||
|
<div class="rail-section-body">
|
||||||
|
@ChildContent
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Section label shown in the eyebrow (e.g. "Scripting").</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Whether the section is expanded — its child links rendered.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public bool Expanded { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Raised when the eyebrow button is clicked.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnToggle { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The section's child nav links, rendered only while expanded.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public RenderFragment? ChildContent { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
@rendermode InteractiveServer
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
@implements IDisposable
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
|
@* Interactive sidebar — extracted from MainLayout so the layout itself can stay
|
||||||
|
statically rendered (layouts can't take RenderFragment Body across an interactive
|
||||||
|
boundary). Hosts the collapsible NavSection groups and cookie persistence. *@
|
||||||
|
|
||||||
|
<nav class="side-rail">
|
||||||
|
<div class="brand"><span class="mark">▮</span> OtOpcUa</div>
|
||||||
|
|
||||||
|
<NavSection Title="Navigation"
|
||||||
|
Expanded="@_expanded.Contains("nav")"
|
||||||
|
OnToggle="@(() => ToggleAsync("nav"))">
|
||||||
|
<NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Overview</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/fleet" Match="NavLinkMatch.Prefix">Fleet status</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/hosts" Match="NavLinkMatch.Prefix">Host status</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/clusters" Match="NavLinkMatch.Prefix">Clusters</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/reservations" Match="NavLinkMatch.Prefix">Reservations</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/certificates" Match="NavLinkMatch.Prefix">Certificates</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/role-grants" Match="NavLinkMatch.Prefix">Role grants</NavLink>
|
||||||
|
</NavSection>
|
||||||
|
|
||||||
|
<NavSection Title="Scripting"
|
||||||
|
Expanded="@_expanded.Contains("scripting")"
|
||||||
|
OnToggle="@(() => ToggleAsync("scripting"))">
|
||||||
|
<NavLink class="rail-link" href="/virtual-tags" Match="NavLinkMatch.Prefix">Virtual tags</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/scripted-alarms" Match="NavLinkMatch.Prefix">Scripted alarms</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/scripts" Match="NavLinkMatch.Prefix">Scripts</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/script-log" Match="NavLinkMatch.Prefix">Script log</NavLink>
|
||||||
|
</NavSection>
|
||||||
|
|
||||||
|
<NavSection Title="Live"
|
||||||
|
Expanded="@_expanded.Contains("live")"
|
||||||
|
OnToggle="@(() => ToggleAsync("live"))">
|
||||||
|
<NavLink class="rail-link" href="/deployments" Match="NavLinkMatch.Prefix">Deployments</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/alerts" Match="NavLinkMatch.Prefix">Alerts</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/alarms-historian" Match="NavLinkMatch.Prefix">Alarms historian</NavLink>
|
||||||
|
</NavSection>
|
||||||
|
|
||||||
|
<div class="rail-foot">
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
<div class="rail-eyebrow">Session</div>
|
||||||
|
<a class="rail-user" href="/account">@context.User.Identity?.Name</a>
|
||||||
|
<div class="rail-roles">
|
||||||
|
@string.Join(", ", context.User.Claims
|
||||||
|
.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/auth/logout">
|
||||||
|
<AntiforgeryToken />
|
||||||
|
<button class="rail-btn" type="submit">Sign out</button>
|
||||||
|
</form>
|
||||||
|
</Authorized>
|
||||||
|
<NotAuthorized>
|
||||||
|
<div class="rail-eyebrow">Session</div>
|
||||||
|
<a class="rail-btn" href="/login">Sign in</a>
|
||||||
|
</NotAuthorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
// Expanded-section state persists in the `otopcua_nav` cookie via
|
||||||
|
// wwwroot/js/nav-state.js (window.navState.get/.set). Same pattern as
|
||||||
|
// ScadaLink CentralUI's NavMenu.
|
||||||
|
|
||||||
|
private static readonly string[] SectionIds = { "nav", "scripting", "live" };
|
||||||
|
|
||||||
|
private readonly HashSet<string> _expanded = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
Navigation.LocationChanged += OnLocationChanged;
|
||||||
|
// Seed from the URL so the current page's section is expanded on the
|
||||||
|
// initial render — works even before JS interop is ready.
|
||||||
|
EnsureCurrentSectionExpanded();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender) return;
|
||||||
|
|
||||||
|
string saved;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
saved = await JS.InvokeAsync<string>("navState.get") ?? string.Empty;
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException) { return; }
|
||||||
|
catch (InvalidOperationException) { return; }
|
||||||
|
|
||||||
|
foreach (var id in saved.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
{
|
||||||
|
if (Array.IndexOf(SectionIds, id) >= 0)
|
||||||
|
_expanded.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EnsureCurrentSectionExpanded())
|
||||||
|
await PersistAsync();
|
||||||
|
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (EnsureCurrentSectionExpanded())
|
||||||
|
{
|
||||||
|
_ = PersistAsync();
|
||||||
|
_ = InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ToggleAsync(string id)
|
||||||
|
{
|
||||||
|
if (!_expanded.Remove(id))
|
||||||
|
_expanded.Add(id);
|
||||||
|
await PersistAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool EnsureCurrentSectionExpanded()
|
||||||
|
{
|
||||||
|
var section = CurrentSection();
|
||||||
|
return section is not null && _expanded.Add(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? CurrentSection()
|
||||||
|
{
|
||||||
|
var relative = Navigation.ToBaseRelativePath(Navigation.Uri);
|
||||||
|
var firstSegment = relative.Split('?', '#')[0]
|
||||||
|
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
return firstSegment switch
|
||||||
|
{
|
||||||
|
null or "" => "nav",
|
||||||
|
"fleet" or "hosts" or "clusters" or "reservations" or "certificates" or "role-grants" => "nav",
|
||||||
|
"virtual-tags" or "scripted-alarms" or "scripts" or "script-log" => "scripting",
|
||||||
|
"deployments" or "alerts" or "alarms-historian" => "live",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PersistAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("navState.set", string.Join(',', _expanded));
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException) { }
|
||||||
|
catch (InvalidOperationException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Navigation.LocationChanged -= OnLocationChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
@page "/login"
|
@page "/login"
|
||||||
|
@layout LoginLayout
|
||||||
@* Login MUST stay anonymously reachable — otherwise the fallback authorization policy
|
@* Login MUST stay anonymously reachable — otherwise the fallback authorization policy
|
||||||
would lock operators out of the only way in (Admin-001). Static-rendered on purpose:
|
would lock operators out of the only way in (Admin-001). Static-rendered on purpose:
|
||||||
the form POSTs to /auth/login while ASP.NET still owns an unstarted HTTP response.
|
the form POSTs to /auth/login while ASP.NET still owns an unstarted HTTP response.
|
||||||
Calling SignInAsync from an interactive circuit would be too late. *@
|
Calling SignInAsync from an interactive circuit would be too late.
|
||||||
|
|
||||||
|
Uses LoginLayout (no side rail) so the page renders as a clean centred card. *@
|
||||||
@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||||
|
|
||||||
<div class="login-wrap rise" style="animation-delay:.02s">
|
<div class="login-wrap rise" style="animation-delay:.02s">
|
||||||
@@ -32,12 +35,6 @@
|
|||||||
|
|
||||||
<button class="btn btn-primary w-100" type="submit">Sign in</button>
|
<button class="btn btn-primary w-100" type="submit">Sign in</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div style="margin-top:1rem;padding-top:.85rem;border-top:1px solid var(--rule);
|
|
||||||
font-size:.78rem;color:var(--ink-faint)">
|
|
||||||
LDAP bind against the configured directory (per Q5 of the AdminUI rebuild plan:
|
|
||||||
generic error in production; specific reason when <span class="mono">Authentication:Ldap:AllowInsecureLdap=true</span>).
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
public static IEndpointRouteBuilder MapAdminUI<TApp>(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapAdminUI<TApp>(this IEndpointRouteBuilder app)
|
||||||
where TApp : IComponent
|
where TApp : IComponent
|
||||||
{
|
{
|
||||||
|
// Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are
|
||||||
|
// served via the Host's app.UseStaticFiles() middleware which must run BEFORE
|
||||||
|
// UseAuthentication() — see Program.cs.
|
||||||
app.MapRazorComponents<TApp>()
|
app.MapRazorComponents<TApp>()
|
||||||
.AddInteractiveServerRenderMode();
|
.AddInteractiveServerRenderMode();
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -6,3 +6,4 @@
|
|||||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Layout
|
||||||
|
|||||||
@@ -49,6 +49,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Brand block pinned at the top of the side rail. Mirrors ScadaLink's
|
||||||
|
.sidebar .brand styling — used now that the top app-bar was dropped. */
|
||||||
|
.side-rail .brand {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.side-rail .brand .mark { color: var(--accent); }
|
||||||
|
|
||||||
.rail-eyebrow {
|
.rail-eyebrow {
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -58,6 +71,36 @@
|
|||||||
padding: 0.3rem 0.6rem;
|
padding: 0.3rem 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Collapsible variant — rendered by NavSection.razor. Looks like .rail-eyebrow
|
||||||
|
plus a leading chevron; clicking flips chevron + expanded state. */
|
||||||
|
.rail-eyebrow-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
padding: 0.45rem 0.6rem 0.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.rail-eyebrow-toggle:hover { color: var(--ink); }
|
||||||
|
.rail-eyebrow-chevron {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.7rem;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.rail-section-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.rail-link {
|
.rail-link {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Sidebar nav collapse state — persisted in the `otopcua_nav` cookie so it
|
||||||
|
// survives full page reloads and reconnects. Invoked from MainLayout.razor via
|
||||||
|
// JS interop (window.navState.get / .set). Mirrors the ScadaLink pattern at
|
||||||
|
// /Users/dohertj2/Desktop/scadalink-design/src/ScadaLink.CentralUI/wwwroot/js/nav-state.js.
|
||||||
|
window.navState = {
|
||||||
|
// Returns the raw cookie value (comma-separated expanded section ids), or
|
||||||
|
// an empty string when the cookie is absent.
|
||||||
|
get: function () {
|
||||||
|
const match = document.cookie.match(/(?:^|;\s*)otopcua_nav=([^;]*)/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : "";
|
||||||
|
},
|
||||||
|
// Writes the cookie with a one-year lifetime. SameSite=Lax; not HttpOnly
|
||||||
|
// (JS must write it) and not sensitive.
|
||||||
|
set: function (value) {
|
||||||
|
const oneYearSeconds = 60 * 60 * 24 * 365;
|
||||||
|
document.cookie = "otopcua_nav=" + encodeURIComponent(value) +
|
||||||
|
";path=/;max-age=" + oneYearSeconds + ";samesite=lax";
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -28,6 +28,11 @@ var hasDriver = roles.Contains("driver");
|
|||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Razor class library static assets (_content/<libname>/...) only auto-enable in
|
||||||
|
// the Development environment. Opt in explicitly so the AdminUI's CSS/JS works
|
||||||
|
// regardless of ASPNETCORE_ENVIRONMENT.
|
||||||
|
builder.WebHost.UseStaticWebAssets();
|
||||||
|
|
||||||
// Per-role appsettings overlay: appsettings.{role}.json (single role) or appsettings.admin-driver.json
|
// Per-role appsettings overlay: appsettings.{role}.json (single role) or appsettings.admin-driver.json
|
||||||
// (both). Optional — base appsettings.json carries enough to boot if these don't exist.
|
// (both). Optional — base appsettings.json carries enough to boot if these don't exist.
|
||||||
var roleSuffix = roles.Length == 0 ? null : string.Join('-', roles.OrderBy(r => r, StringComparer.Ordinal));
|
var roleSuffix = roles.Length == 0 ? null : string.Join('-', roles.OrderBy(r => r, StringComparer.Ordinal));
|
||||||
@@ -111,6 +116,9 @@ if (hasAdmin)
|
|||||||
// Auth + AdminUI surface only mounted on admin-role nodes. Driver-only nodes have no UI.
|
// Auth + AdminUI surface only mounted on admin-role nodes. Driver-only nodes have no UI.
|
||||||
builder.Services.AddOtOpcUaAuth(builder.Configuration);
|
builder.Services.AddOtOpcUaAuth(builder.Configuration);
|
||||||
builder.Services.AddAdminUI();
|
builder.Services.AddAdminUI();
|
||||||
|
// Flow AuthenticationState through cascading parameters so <AuthorizeView/> works
|
||||||
|
// inside interactive components (NavSidebar's session block).
|
||||||
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
builder.Services.AddSignalR();
|
builder.Services.AddSignalR();
|
||||||
builder.Services.AddOtOpcUaAdminClients();
|
builder.Services.AddOtOpcUaAdminClients();
|
||||||
}
|
}
|
||||||
@@ -121,6 +129,12 @@ builder.Services.AddOtOpcUaObservability();
|
|||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
app.UseSerilogRequestLogging();
|
app.UseSerilogRequestLogging();
|
||||||
|
|
||||||
|
// Razor class library static assets (_content/<libname>/...) are served via endpoint
|
||||||
|
// routing, NOT the UseStaticFiles middleware — so we MUST mark the static-asset
|
||||||
|
// endpoints AllowAnonymous, otherwise the AddOtOpcUaAuth fallback RequireAuthenticatedUser
|
||||||
|
// policy 401s every CSS/JS request and the login page renders unstyled.
|
||||||
|
app.MapStaticAssets().AllowAnonymous();
|
||||||
|
|
||||||
if (hasAdmin)
|
if (hasAdmin)
|
||||||
{
|
{
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -12,13 +13,20 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Endpoints;
|
|||||||
|
|
||||||
public static class AuthEndpoints
|
public static class AuthEndpoints
|
||||||
{
|
{
|
||||||
|
/// <summary>JSON body schema for API-side login callers (kept stable for tests).</summary>
|
||||||
public sealed record LoginRequest(string Username, string Password);
|
public sealed record LoginRequest(string Username, string Password);
|
||||||
|
|
||||||
public sealed record TokenResponse(string Token);
|
public sealed record TokenResponse(string Token);
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapOtOpcUaAuth(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapOtOpcUaAuth(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
app.MapPost("/auth/login", (Delegate)LoginAsync).AllowAnonymous();
|
// The login endpoint serves two callers with different ergonomics:
|
||||||
|
// - Browser form POST (application/x-www-form-urlencoded) → redirect dance
|
||||||
|
// - API JSON POST (application/json) → 204 / 401 / 503 status codes
|
||||||
|
// DisableAntiforgery: the login form is the entry point — anonymous by definition,
|
||||||
|
// no prior session, so XSRF doesn't apply. AllowAnonymous: override the
|
||||||
|
// AddOtOpcUaAuth fallback policy that would otherwise 401 the request.
|
||||||
|
app.MapPost("/auth/login", (Delegate)LoginAsync).AllowAnonymous().DisableAntiforgery();
|
||||||
app.MapGet("/auth/ping", (Delegate)Ping).AllowAnonymous();
|
app.MapGet("/auth/ping", (Delegate)Ping).AllowAnonymous();
|
||||||
app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization();
|
app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization();
|
||||||
app.MapPost("/auth/logout", (Delegate)LogoutAsync).RequireAuthorization();
|
app.MapPost("/auth/logout", (Delegate)LogoutAsync).RequireAuthorization();
|
||||||
@@ -26,15 +34,35 @@ public static class AuthEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> LoginAsync(
|
private static async Task<IResult> LoginAsync(
|
||||||
LoginRequest request,
|
|
||||||
HttpContext http,
|
HttpContext http,
|
||||||
ILdapAuthService ldap,
|
ILdapAuthService ldap,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var isForm = http.Request.HasFormContentType;
|
||||||
|
string username, password, returnUrl;
|
||||||
|
|
||||||
|
if (isForm)
|
||||||
|
{
|
||||||
|
var form = await http.Request.ReadFormAsync(ct);
|
||||||
|
username = form["username"].ToString();
|
||||||
|
password = form["password"].ToString();
|
||||||
|
returnUrl = form["returnUrl"].ToString();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var body = await JsonSerializer.DeserializeAsync<LoginRequest>(
|
||||||
|
http.Request.Body,
|
||||||
|
new JsonSerializerOptions(JsonSerializerDefaults.Web),
|
||||||
|
ct);
|
||||||
|
username = body?.Username ?? string.Empty;
|
||||||
|
password = body?.Password ?? string.Empty;
|
||||||
|
returnUrl = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
LdapAuthResult result;
|
LdapAuthResult result;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
result = await ldap.AuthenticateAsync(request.Username, request.Password, ct);
|
result = await ldap.AuthenticateAsync(username, password, ct);
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
@@ -42,13 +70,20 @@ public static class AuthEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
return Results.Unauthorized();
|
{
|
||||||
|
if (!isForm) return Results.Unauthorized();
|
||||||
|
|
||||||
|
var qs = $"?error={Uri.EscapeDataString(result.Error ?? "Invalid credentials")}";
|
||||||
|
if (!string.IsNullOrWhiteSpace(returnUrl))
|
||||||
|
qs += $"&returnUrl={Uri.EscapeDataString(returnUrl)}";
|
||||||
|
return Results.Redirect("/login" + qs);
|
||||||
|
}
|
||||||
|
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(ClaimTypes.NameIdentifier, result.Username ?? request.Username),
|
new(ClaimTypes.NameIdentifier, result.Username ?? username),
|
||||||
new(JwtTokenService.UsernameClaimType, result.Username ?? request.Username),
|
new(JwtTokenService.UsernameClaimType, result.Username ?? username),
|
||||||
new(JwtTokenService.DisplayNameClaimType, result.DisplayName ?? request.Username),
|
new(JwtTokenService.DisplayNameClaimType, result.DisplayName ?? username),
|
||||||
};
|
};
|
||||||
foreach (var role in result.Roles)
|
foreach (var role in result.Roles)
|
||||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||||
@@ -57,7 +92,9 @@ public static class AuthEndpoints
|
|||||||
var principal = new ClaimsPrincipal(identity);
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
||||||
return Results.NoContent();
|
|
||||||
|
if (!isForm) return Results.NoContent();
|
||||||
|
return Results.Redirect(string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IResult Ping(HttpContext http) =>
|
private static IResult Ping(HttpContext http) =>
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapA
|
|||||||
if (string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
return new(false, null, null, [], [], "Password is required");
|
return new(false, null, null, [], [], "Password is required");
|
||||||
|
|
||||||
|
if (_options.DevStubMode)
|
||||||
|
{
|
||||||
|
logger.LogWarning("LdapAuthService: DevStubMode bypass — accepting {User} without a real LDAP bind", username);
|
||||||
|
return new(true, username, username, ["dev"], ["FleetAdmin"], null);
|
||||||
|
}
|
||||||
|
|
||||||
if (!_options.UseTls && !_options.AllowInsecureLdap)
|
if (!_options.UseTls && !_options.AllowInsecureLdap)
|
||||||
return new(false, null, username, [], [],
|
return new(false, null, username, [], [],
|
||||||
"Insecure LDAP is disabled. Enable UseTls or set AllowInsecureLdap for dev/test.");
|
"Insecure LDAP is disabled. Enable UseTls or set AllowInsecureLdap for dev/test.");
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ public sealed class LdapOptions
|
|||||||
/// <summary>Dev-only escape hatch — must be <c>false</c> in production.</summary>
|
/// <summary>Dev-only escape hatch — must be <c>false</c> in production.</summary>
|
||||||
public bool AllowInsecureLdap { get; set; }
|
public bool AllowInsecureLdap { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dev-only stub: when <c>true</c>, <see cref="LdapAuthService"/> bypasses the real LDAP
|
||||||
|
/// bind and accepts any non-empty username/password, returning a single FleetAdmin role
|
||||||
|
/// so the operator can navigate the full Admin UI. MUST be <c>false</c> in production.
|
||||||
|
/// </summary>
|
||||||
|
public bool DevStubMode { get; set; }
|
||||||
|
|
||||||
public string SearchBase { get; set; } = "dc=lmxopcua,dc=local";
|
public string SearchBase { get; set; } = "dc=lmxopcua,dc=local";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
|
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
|
||||||
|
|
||||||
services.AddSingleton<JwtTokenService>();
|
services.AddSingleton<JwtTokenService>();
|
||||||
services.AddScoped<ILdapAuthService, LdapAuthService>();
|
// Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and
|
||||||
|
// must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
|
||||||
|
// The driver-branch in Host/Program.cs registers the same way; consistent lifetime
|
||||||
|
// across both paths keeps ValidateScopes-on-Build clean.
|
||||||
|
services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
||||||
|
|
||||||
services.AddDataProtection()
|
services.AddDataProtection()
|
||||||
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
|
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
|
||||||
|
|||||||
Reference in New Issue
Block a user