Fase 3: Hub portal for vendor staff
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
Server-rendered portal on the Hub: staff login (PBKDF2 hashes, rate limited, antiforgery, local-only return URLs), customer overview with the most widespread problems across customers, customer and finding pages, support cases linked to findings, and license revocation that takes effect on the next agent submission. add-staff CLI command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
This commit is contained in:
parent
0fcc31b509
commit
7088efff52
28 changed files with 1266 additions and 1 deletions
|
|
@ -12,4 +12,7 @@ public sealed class HubServerOptions
|
||||||
|
|
||||||
/// <summary>Most findings accepted per submission.</summary>
|
/// <summary>Most findings accepted per submission.</summary>
|
||||||
public int MaxFindingsPerSubmission { get; set; } = 2000;
|
public int MaxFindingsPerSubmission { get; set; } = 2000;
|
||||||
|
|
||||||
|
/// <summary>Portal login attempts allowed per client IP per minute.</summary>
|
||||||
|
public int LoginAttemptsPerMinute { get; set; } = 10;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
src/SQLInsight.Hub/Portal/Pages/Cases.razor
Normal file
37
src/SQLInsight.Hub/Portal/Pages/Cases.razor
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
@page "/portal/sager"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject PortalQueries Queries
|
||||||
|
|
||||||
|
<PageTitle>Supportsager · SQLInsight Hub</PageTitle>
|
||||||
|
<h1>Åbne supportsager</h1>
|
||||||
|
<section class="card">
|
||||||
|
@if (_cases.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="empty">Ingen åbne sager.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Sag</th><th>Kunde</th><th>Titel</th><th class="num">Fund</th><th>Aktive</th><th>Oprettet</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var c in _cases)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="mono">@c.Reference</td>
|
||||||
|
<td><a href="/portal/kunder/@c.CustomerId">@c.Customer!.Name</a></td>
|
||||||
|
<td>@c.Title</td>
|
||||||
|
<td class="num">@c.Findings.Count</td>
|
||||||
|
<td>@c.Findings.Count(f => f.ResolvedAt == null)</td>
|
||||||
|
<td class="nowrap">@c.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)</td>
|
||||||
|
<td><form method="post" action="/portal/actions/cases/@c.Id/close"><AntiforgeryToken /><button type="submit" class="theme-toggle">Luk</button></form></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private IReadOnlyList<SupportCase> _cases = [];
|
||||||
|
protected override async Task OnInitializedAsync() => _cases = await Queries.OpenCasesAsync();
|
||||||
|
}
|
||||||
70
src/SQLInsight.Hub/Portal/Pages/CustomerDetail.razor
Normal file
70
src/SQLInsight.Hub/Portal/Pages/CustomerDetail.razor
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
@page "/portal/kunder/{Id:int}"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject PortalQueries Queries
|
||||||
|
|
||||||
|
@if (_customer is null)
|
||||||
|
{
|
||||||
|
<h1>Ukendt kunde</h1>
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
<PageTitle>@_customer.Name · SQLInsight Hub</PageTitle>
|
||||||
|
<p class="crumbs"><a href="/portal">Kunder</a> / @_customer.Name</p>
|
||||||
|
<h1>@_customer.Name</h1>
|
||||||
|
@{ var license = _customer.Licenses.Where(l => l.RevokedAt == null).OrderByDescending(l => l.ExpiresAt).FirstOrDefault(); }
|
||||||
|
<p class="lead">@(license is null ? "Ingen gyldig licens" : $"{license.Plan} til {license.ExpiresAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}") · @_customer.Instances.Count instanser</p>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head"><h2>Instanser</h2></div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Instans</th><th>Version</th><th>Agent</th><th>Seneste data</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var i in _customer.Instances.OrderBy(i => i.InstanceKey))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td><strong>@i.InstanceKey</strong><div class="muted">@i.ServerName</div></td>
|
||||||
|
<td>@i.Release<div class="muted">@i.ProductVersion @i.ProductLevel @i.ProductUpdateLevel · @i.Edition</div></td>
|
||||||
|
<td>@i.AgentVersion</td>
|
||||||
|
<td class="nowrap @(i.LastSubmissionAt < Queries.Now.AddHours(-1) ? "bad" : "")">@(i.LastSubmissionAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)) UTC</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head"><h2>Aktive fund</h2></div>
|
||||||
|
<FindingTable Rows="_findings" ShowCustomer="false" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head"><h2>Supportsager</h2></div>
|
||||||
|
@if (_customer.SupportCases.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="muted">Ingen.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Sag</th><th>Titel</th><th>Status</th><th>Oprettet</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var c in _customer.SupportCases.OrderByDescending(c => c.CreatedAt))
|
||||||
|
{
|
||||||
|
<tr><td class="mono">@c.Reference</td><td>@c.Title</td><td>@(c.IsOpen ? "Åben" : "Lukket")</td><td class="nowrap">@c.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public int Id { get; set; }
|
||||||
|
private Customer? _customer;
|
||||||
|
private IReadOnlyList<FindingRow> _findings = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_customer = await Queries.CustomerAsync(Id);
|
||||||
|
_findings = _customer is null ? [] : await Queries.FindingsAsync(Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/SQLInsight.Hub/Portal/Pages/FindingDetail.razor
Normal file
68
src/SQLInsight.Hub/Portal/Pages/FindingDetail.razor
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
@page "/portal/fund/{Id:long}"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject PortalQueries Queries
|
||||||
|
|
||||||
|
@if (_finding is null)
|
||||||
|
{
|
||||||
|
<h1>Ukendt fund</h1>
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@{ var customer = _finding.Instance!.Customer!; }
|
||||||
|
<PageTitle>@_finding.Title · SQLInsight Hub</PageTitle>
|
||||||
|
<p class="crumbs"><a href="/portal">Kunder</a> / <a href="/portal/kunder/@customer.Id">@customer.Name</a> / @_finding.Instance.InstanceKey / @_finding.RuleId</p>
|
||||||
|
<h1><Sev Severity="_finding.Severity" /> @_finding.Title</h1>
|
||||||
|
<p class="lead">
|
||||||
|
@(_finding.ResolvedAt is { } r ? "Løst " + r.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) + " UTC · " : "")Set første gang @_finding.FirstSeenAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) · senest @_finding.LastSeenAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) UTC
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section class="panel"><h2>Forklaring</h2><p>@_finding.Explanation</p></section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Målinger</h2>
|
||||||
|
<table class="evidence">
|
||||||
|
@foreach (var e in Evidence)
|
||||||
|
{
|
||||||
|
<tr><th>@e.Name</th><td>@e.Value</td></tr>
|
||||||
|
}
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel recommendation"><h2>Anbefaling til kunden</h2><p>@_finding.Recommendation</p></section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Supportsag</h2>
|
||||||
|
@if (_finding.SupportCase is { } linked)
|
||||||
|
{
|
||||||
|
<p>Knyttet til <strong class="mono">@linked.Reference</strong>: @linked.Title (@(linked.IsOpen ? "åben" : "lukket"))</p>
|
||||||
|
<form method="post" action="/portal/actions/findings/@_finding.Id/unlink"><AntiforgeryToken /><button type="submit" class="theme-toggle">Fjern tilknytning</button></form>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var open = customer.SupportCases.Where(c => c.IsOpen).ToList();
|
||||||
|
@if (open.Count > 0)
|
||||||
|
{
|
||||||
|
<form method="post" action="/portal/actions/findings/@_finding.Id/case" class="inline-form">
|
||||||
|
<AntiforgeryToken />
|
||||||
|
<select name="caseId">@foreach (var c in open) { <option value="@c.Id">@c.Reference: @c.Title</option> }</select>
|
||||||
|
<button type="submit" class="primary">Knyt til sag</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
<form method="post" action="/portal/actions/findings/@_finding.Id/case" class="inline-form">
|
||||||
|
<AntiforgeryToken />
|
||||||
|
<input name="reference" placeholder="Sagsnummer, fx SUP-1234" required />
|
||||||
|
<input name="title" placeholder="Titel (valgfri)" />
|
||||||
|
<button type="submit" class="primary">Opret sag</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public long Id { get; set; }
|
||||||
|
private HubFinding? _finding;
|
||||||
|
|
||||||
|
private IReadOnlyList<SQLInsight.Contracts.EvidenceDto> Evidence =>
|
||||||
|
System.Text.Json.JsonSerializer.Deserialize<List<SQLInsight.Contracts.EvidenceDto>>(_finding!.EvidenceJson, System.Text.Json.JsonSerializerOptions.Web) ?? [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync() => _finding = await Queries.FindingAsync(Id);
|
||||||
|
}
|
||||||
13
src/SQLInsight.Hub/Portal/Pages/Findings.razor
Normal file
13
src/SQLInsight.Hub/Portal/Pages/Findings.razor
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
@page "/portal/fund"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject PortalQueries Queries
|
||||||
|
|
||||||
|
<PageTitle>Fund · SQLInsight Hub</PageTitle>
|
||||||
|
<h1>Aktive fund på tværs af kunder</h1>
|
||||||
|
<p class="lead">Middel og derover, sorteret efter alvor.</p>
|
||||||
|
<section class="card"><FindingTable Rows="_rows" /></section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private IReadOnlyList<FindingRow> _rows = [];
|
||||||
|
protected override async Task OnInitializedAsync() => _rows = await Queries.FindingsAsync(minSeverity: 2);
|
||||||
|
}
|
||||||
43
src/SQLInsight.Hub/Portal/Pages/Licenses.razor
Normal file
43
src/SQLInsight.Hub/Portal/Pages/Licenses.razor
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
@page "/portal/licenser"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject PortalQueries Queries
|
||||||
|
|
||||||
|
<PageTitle>Licenser · SQLInsight Hub</PageTitle>
|
||||||
|
<h1>Licenser</h1>
|
||||||
|
<p class="lead">Licenser, som agenter har brugt. En spærret licens kan ikke længere sende data, selvom den ikke er udløbet.</p>
|
||||||
|
<section class="card">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Kunde</th><th>Aftale</th><th>Udløber</th><th class="num">Maks. instanser</th><th>Senest brugt</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var l in _licenses)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td><a href="/portal/kunder/@l.CustomerId">@l.Customer!.Name</a><div class="muted mono">@l.LicenseId</div></td>
|
||||||
|
<td>@l.Plan</td>
|
||||||
|
<td class="nowrap @(l.ExpiresAt < Queries.Now.AddDays(30) ? "bad" : "")">@l.ExpiresAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)</td>
|
||||||
|
<td class="num">@(l.MaxInstances == 0 ? "ubegrænset" : l.MaxInstances)</td>
|
||||||
|
<td class="nowrap">@l.LastSeenAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) UTC</td>
|
||||||
|
<td>
|
||||||
|
@if (l.RevokedAt is { } revoked)
|
||||||
|
{
|
||||||
|
<span class="badge sev-critical">Spærret @revoked.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)</span><div class="muted">@l.RevokedReason</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<form method="post" action="/portal/actions/licenses/@l.LicenseId/revoke" class="inline-form">
|
||||||
|
<AntiforgeryToken />
|
||||||
|
<input name="reason" placeholder="Årsag" required />
|
||||||
|
<button type="submit" class="theme-toggle">Spær</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private IReadOnlyList<LicenseRecord> _licenses = [];
|
||||||
|
protected override async Task OnInitializedAsync() => _licenses = await Queries.LicensesAsync();
|
||||||
|
}
|
||||||
25
src/SQLInsight.Hub/Portal/Pages/Login.razor
Normal file
25
src/SQLInsight.Hub/Portal/Pages/Login.razor
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
@page "/portal/login"
|
||||||
|
@attribute [AllowAnonymous]
|
||||||
|
@layout EmptyLayout
|
||||||
|
|
||||||
|
<PageTitle>Log ind · SQLInsight Hub</PageTitle>
|
||||||
|
|
||||||
|
<div class="login">
|
||||||
|
<form method="post" action="/portal/login" class="card">
|
||||||
|
<AntiforgeryToken />
|
||||||
|
<h1><span class="logo" aria-hidden="true">SI</span> SQLInsight Hub</h1>
|
||||||
|
@if (Fejl is not null)
|
||||||
|
{
|
||||||
|
<p class="alert">Forkert brugernavn eller adgangskode.</p>
|
||||||
|
}
|
||||||
|
<label>Brugernavn<input name="userName" autocomplete="username" required autofocus /></label>
|
||||||
|
<label>Adgangskode<input name="password" type="password" autocomplete="current-password" required /></label>
|
||||||
|
<input type="hidden" name="returnUrl" value="@ReturnUrl" />
|
||||||
|
<button type="submit" class="primary">Log ind</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[SupplyParameterFromQuery] public string? Fejl { get; set; }
|
||||||
|
[SupplyParameterFromQuery] public string? ReturnUrl { get; set; }
|
||||||
|
}
|
||||||
69
src/SQLInsight.Hub/Portal/Pages/Overview.razor
Normal file
69
src/SQLInsight.Hub/Portal/Pages/Overview.razor
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
@page "/portal"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject PortalQueries Queries
|
||||||
|
|
||||||
|
<PageTitle>Overblik · SQLInsight Hub</PageTitle>
|
||||||
|
|
||||||
|
<h1>Kundeoverblik</h1>
|
||||||
|
<p class="lead">Betalende kunders instanser og deres aktuelle problemer.</p>
|
||||||
|
|
||||||
|
<div class="kpis">
|
||||||
|
<div class="kpi"><div><strong>@_customers.Count</strong><span>kunder</span></div></div>
|
||||||
|
<div class="kpi"><div><strong>@_customers.Sum(c => c.Instances)</strong><span>instanser</span></div></div>
|
||||||
|
<div class="kpi sev-critical"><div><strong>@_customers.Sum(c => c.Critical)</strong><span>kritiske fund</span></div></div>
|
||||||
|
<div class="kpi sev-high"><div><strong>@_customers.Sum(c => c.High)</strong><span>høje fund</span></div></div>
|
||||||
|
<div class="kpi"><div><strong>@_new</strong><span>nye fund, 7 dage</span></div></div>
|
||||||
|
<div class="kpi sev-info"><div><strong>@_silent</strong><span>instanser uden data i 1 time</span></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head"><h2>Kunder</h2><span class="muted">sorteret efter alvor</span></div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Kunde</th><th>Aftale</th><th>Licens udløber</th><th class="num">Instanser</th><th>Fund</th><th>Seneste data</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var c in _customers)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td><a href="/portal/kunder/@c.Id"><strong>@c.Name</strong></a></td>
|
||||||
|
<td>@(c.Plan ?? "–")</td>
|
||||||
|
<td class="nowrap @(c.LicenseExpires < Queries.Now.AddDays(30) ? "bad" : "")">@(c.LicenseExpires?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "–")</td>
|
||||||
|
<td class="num">@c.Instances</td>
|
||||||
|
<td class="counts">
|
||||||
|
@if (c.Critical > 0) { <Sev Severity="4" Count="c.Critical" /> }
|
||||||
|
@if (c.High > 0) { <Sev Severity="3" Count="c.High" /> }
|
||||||
|
@if (c.Critical == 0 && c.High == 0) { <span class="muted">ingen kritiske/høje</span> }
|
||||||
|
</td>
|
||||||
|
<td class="nowrap @(c.LastSubmission < Queries.Now.AddHours(-1) ? "bad" : "")">@(c.LastSubmission?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? "aldrig") UTC</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head"><h2>Mest udbredte problemer</h2><span class="muted">hvor en vejledning eller en produktændring hjælper flest</span></div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Regel</th><th>Eksempel</th><th class="num">Kunder</th><th class="num">Fund</th><th>Værst</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var p in _common)
|
||||||
|
{
|
||||||
|
<tr><td class="mono">@p.RuleId</td><td>@p.Title</td><td class="num">@p.Customers</td><td class="num">@p.Findings</td><td><Sev Severity="p.WorstSeverity" /></td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private IReadOnlyList<CustomerRow> _customers = [];
|
||||||
|
private IReadOnlyList<CommonProblem> _common = [];
|
||||||
|
private int _new;
|
||||||
|
private int _silent;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_customers = await Queries.CustomersAsync();
|
||||||
|
_common = await Queries.CommonProblemsAsync();
|
||||||
|
_new = await Queries.NewFindingsSinceAsync(Queries.Now.AddDays(-7));
|
||||||
|
_silent = await Queries.SilentInstancesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/SQLInsight.Hub/Portal/PortalApp.razor
Normal file
21
src/SQLInsight.Hub/Portal/PortalApp.razor
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<base href="/" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="@Assets["favicon.svg"]" />
|
||||||
|
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||||
|
<script src="@Assets["theme.js"]"></script>
|
||||||
|
<HeadOutlet />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<Router AppAssembly="typeof(PortalApp).Assembly">
|
||||||
|
<Found Context="routeData">
|
||||||
|
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(PortalLayout)">
|
||||||
|
<NotAuthorized><RedirectToLogin /></NotAuthorized>
|
||||||
|
</AuthorizeRouteView>
|
||||||
|
</Found>
|
||||||
|
</Router>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
121
src/SQLInsight.Hub/Portal/PortalEndpoints.cs
Normal file
121
src/SQLInsight.Hub/Portal/PortalEndpoints.cs
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SQLInsight.Hub.Data;
|
||||||
|
|
||||||
|
namespace SQLInsight.Hub.Portal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Form posts from the portal (login and the few actions). Pages are server-rendered Razor components.
|
||||||
|
/// </summary>
|
||||||
|
public static class PortalEndpoints
|
||||||
|
{
|
||||||
|
public const string LoginRateLimit = "portal-login";
|
||||||
|
|
||||||
|
public static void Map(IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapPost("/portal/login", LoginAsync).AllowAnonymous().RequireRateLimiting(LoginRateLimit);
|
||||||
|
app.MapPost("/portal/logout", async (HttpContext http) =>
|
||||||
|
{
|
||||||
|
await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
return Results.Redirect("/portal/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
var portal = app.MapGroup("/portal/actions").RequireAuthorization();
|
||||||
|
portal.MapPost("/findings/{id:long}/case", LinkCaseAsync);
|
||||||
|
portal.MapPost("/findings/{id:long}/unlink", async (long id, HubDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var finding = await db.Findings.FindAsync([id], ct);
|
||||||
|
if (finding is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
finding.SupportCaseId = null;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Redirect($"/portal/fund/{id}");
|
||||||
|
});
|
||||||
|
portal.MapPost("/cases/{id:int}/close", async (int id, HubDbContext db, TimeProvider time, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var supportCase = await db.SupportCases.FindAsync([id], ct);
|
||||||
|
if (supportCase is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
supportCase.IsOpen = false;
|
||||||
|
supportCase.ClosedAt = time.GetUtcNow().UtcDateTime;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Redirect("/portal/sager");
|
||||||
|
});
|
||||||
|
portal.MapPost("/licenses/{id}/revoke", async (string id, [FromForm] string reason, HubDbContext db, TimeProvider time, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var license = await db.Licenses.FindAsync([id], ct);
|
||||||
|
if (license is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
license.RevokedAt = time.GetUtcNow().UtcDateTime;
|
||||||
|
license.RevokedReason = string.IsNullOrWhiteSpace(reason) ? "Spærret" : reason.Trim();
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Redirect("/portal/licenser");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> LoginAsync(HttpContext http, StaffAccounts accounts, [FromForm] string userName, [FromForm] string password, [FromForm] string? returnUrl)
|
||||||
|
{
|
||||||
|
var user = await accounts.VerifyAsync(userName, password, http.RequestAborted);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return Results.Redirect("/portal/login?fejl=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(
|
||||||
|
[new Claim(ClaimTypes.NameIdentifier, user.Id.ToString(System.Globalization.CultureInfo.InvariantCulture)), new Claim(ClaimTypes.Name, user.DisplayName), new Claim("username", user.UserName)],
|
||||||
|
CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity));
|
||||||
|
|
||||||
|
// Only local redirects: never an open redirect to another site.
|
||||||
|
return Results.LocalRedirect(returnUrl is { Length: > 0 } && returnUrl.StartsWith("/portal", StringComparison.Ordinal) && !returnUrl.StartsWith("//", StringComparison.Ordinal) ? returnUrl : "/portal");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> LinkCaseAsync(long id, [FromForm] int? caseId, [FromForm] string? reference, [FromForm] string? title,
|
||||||
|
HubDbContext db, TimeProvider time, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var finding = await db.Findings.Include(f => f.Instance).SingleOrDefaultAsync(f => f.Id == id, ct);
|
||||||
|
if (finding is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (caseId is { } existing)
|
||||||
|
{
|
||||||
|
var supportCase = await db.SupportCases.SingleOrDefaultAsync(c => c.Id == existing && c.CustomerId == finding.Instance!.CustomerId, ct);
|
||||||
|
if (supportCase is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest("Sagen tilhører en anden kunde.");
|
||||||
|
}
|
||||||
|
|
||||||
|
finding.SupportCaseId = supportCase.Id;
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(reference))
|
||||||
|
{
|
||||||
|
var supportCase = new SupportCase
|
||||||
|
{
|
||||||
|
CustomerId = finding.Instance!.CustomerId,
|
||||||
|
Reference = reference.Trim(),
|
||||||
|
Title = string.IsNullOrWhiteSpace(title) ? finding.Title : title.Trim(),
|
||||||
|
CreatedAt = time.GetUtcNow().UtcDateTime,
|
||||||
|
};
|
||||||
|
db.SupportCases.Add(supportCase);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
finding.SupportCaseId = supportCase.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Redirect($"/portal/fund/{id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
88
src/SQLInsight.Hub/Portal/PortalQueries.cs
Normal file
88
src/SQLInsight.Hub/Portal/PortalQueries.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SQLInsight.Hub.Data;
|
||||||
|
|
||||||
|
namespace SQLInsight.Hub.Portal;
|
||||||
|
|
||||||
|
public sealed record CustomerRow(int Id, string Name, string? Plan, DateTime? LicenseExpires, int Instances, int Critical, int High, DateTime? LastSubmission);
|
||||||
|
|
||||||
|
public sealed record CommonProblem(string RuleId, string Title, int Customers, int Findings, int WorstSeverity);
|
||||||
|
|
||||||
|
public sealed record FindingRow(long Id, string Customer, int CustomerId, string Instance, string RuleId, string Subject, int Severity, string Title, DateTime FirstSeenAt, string? CaseReference);
|
||||||
|
|
||||||
|
/// <summary>Read models for the portal pages.</summary>
|
||||||
|
public sealed class PortalQueries(HubDbContext db, TimeProvider time)
|
||||||
|
{
|
||||||
|
public DateTime Now => time.GetUtcNow().UtcDateTime;
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<CustomerRow>> CustomersAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var customers = await db.Customers
|
||||||
|
.Select(c => new
|
||||||
|
{
|
||||||
|
c.Id,
|
||||||
|
c.Name,
|
||||||
|
License = c.Licenses.Where(l => l.RevokedAt == null).OrderByDescending(l => l.ExpiresAt).Select(l => new { l.Plan, l.ExpiresAt }).FirstOrDefault(),
|
||||||
|
Instances = c.Instances.Count,
|
||||||
|
Critical = c.Instances.SelectMany(i => i.Findings).Count(f => f.ResolvedAt == null && f.Severity == 4),
|
||||||
|
High = c.Instances.SelectMany(i => i.Findings).Count(f => f.ResolvedAt == null && f.Severity == 3),
|
||||||
|
LastSubmission = c.Instances.Max(i => i.LastSubmissionAt),
|
||||||
|
})
|
||||||
|
.ToListAsync(ct).ConfigureAwait(false);
|
||||||
|
return customers
|
||||||
|
.Select(c => new CustomerRow(c.Id, c.Name, c.License?.Plan, c.License?.ExpiresAt, c.Instances, c.Critical, c.High, c.LastSubmission))
|
||||||
|
.OrderByDescending(c => c.Critical).ThenByDescending(c => c.High).ThenBy(c => c.Name)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The problems affecting the most customers: where a knowledge base article or a product fix pays off.</summary>
|
||||||
|
public async Task<IReadOnlyList<CommonProblem>> CommonProblemsAsync(int top = 10, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var rows = await db.Findings.Where(f => f.ResolvedAt == null)
|
||||||
|
.GroupBy(f => f.RuleId)
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
RuleId = g.Key,
|
||||||
|
Title = g.OrderByDescending(f => f.Severity).Select(f => f.Title).First(),
|
||||||
|
Customers = g.Select(f => f.Instance!.CustomerId).Distinct().Count(),
|
||||||
|
Findings = g.Count(),
|
||||||
|
Worst = g.Max(f => f.Severity),
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.Customers).ThenByDescending(x => x.Worst).ThenByDescending(x => x.Findings)
|
||||||
|
.Take(top)
|
||||||
|
.ToListAsync(ct).ConfigureAwait(false);
|
||||||
|
return rows.Select(r => new CommonProblem(r.RuleId, r.Title, r.Customers, r.Findings, r.Worst)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> NewFindingsSinceAsync(DateTime since, CancellationToken ct = default) =>
|
||||||
|
db.Findings.CountAsync(f => f.FirstSeenAt >= since, ct);
|
||||||
|
|
||||||
|
/// <summary>Instances that have not reported for an hour: agent stopped, network or license problem.</summary>
|
||||||
|
public Task<int> SilentInstancesAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var limit = Now.AddHours(-1);
|
||||||
|
return db.Instances.CountAsync(i => i.LastSubmissionAt < limit, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<FindingRow>> FindingsAsync(int? customerId = null, int minSeverity = 0, CancellationToken ct = default) =>
|
||||||
|
await db.Findings
|
||||||
|
.Where(f => f.ResolvedAt == null && f.Severity >= minSeverity && (customerId == null || f.Instance!.CustomerId == customerId))
|
||||||
|
.OrderByDescending(f => f.Severity).ThenBy(f => f.FirstSeenAt)
|
||||||
|
.Select(f => new FindingRow(f.Id, f.Instance!.Customer!.Name, f.Instance.CustomerId, f.Instance.InstanceKey, f.RuleId, f.Subject, f.Severity, f.Title, f.FirstSeenAt,
|
||||||
|
f.SupportCase == null ? null : f.SupportCase.Reference))
|
||||||
|
.Take(500)
|
||||||
|
.ToListAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
public Task<Customer?> CustomerAsync(int id, CancellationToken ct = default) =>
|
||||||
|
db.Customers.Include(c => c.Instances).Include(c => c.Licenses).Include(c => c.SupportCases).AsSplitQuery().SingleOrDefaultAsync(c => c.Id == id, ct);
|
||||||
|
|
||||||
|
public Task<HubFinding?> FindingAsync(long id, CancellationToken ct = default) =>
|
||||||
|
db.Findings.Include(f => f.Instance!).ThenInclude(i => i.Customer!).ThenInclude(c => c.SupportCases).Include(f => f.SupportCase)
|
||||||
|
.AsSplitQuery().SingleOrDefaultAsync(f => f.Id == id, ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SupportCase>> OpenCasesAsync(CancellationToken ct = default) =>
|
||||||
|
await db.SupportCases.Include(c => c.Customer).Include(c => c.Findings).Where(c => c.IsOpen)
|
||||||
|
.OrderBy(c => c.CreatedAt).AsSplitQuery().ToListAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<LicenseRecord>> LicensesAsync(CancellationToken ct = default) =>
|
||||||
|
await db.Licenses.Include(l => l.Customer).OrderBy(l => l.ExpiresAt).ToListAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
3
src/SQLInsight.Hub/Portal/Shared/EmptyLayout.razor
Normal file
3
src/SQLInsight.Hub/Portal/Shared/EmptyLayout.razor
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
@Body
|
||||||
29
src/SQLInsight.Hub/Portal/Shared/FindingTable.razor
Normal file
29
src/SQLInsight.Hub/Portal/Shared/FindingTable.razor
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
@if (Rows.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="empty">Ingen aktive fund.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Alvor</th>@if (ShowCustomer) { <th>Kunde</th> }<th>Instans</th><th>Fund</th><th>Regel</th><th>Sag</th><th>Siden</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var f in Rows)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td><Sev Severity="f.Severity" /></td>
|
||||||
|
@if (ShowCustomer) { <td><a href="/portal/kunder/@f.CustomerId">@f.Customer</a></td> }
|
||||||
|
<td>@f.Instance</td>
|
||||||
|
<td><a href="/portal/fund/@f.Id">@f.Title</a></td>
|
||||||
|
<td class="mono">@f.RuleId</td>
|
||||||
|
<td>@(f.CaseReference ?? "–")</td>
|
||||||
|
<td class="nowrap">@f.FirstSeenAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired] public IReadOnlyList<FindingRow> Rows { get; set; } = [];
|
||||||
|
[Parameter] public bool ShowCustomer { get; set; } = true;
|
||||||
|
}
|
||||||
25
src/SQLInsight.Hub/Portal/Shared/PortalLayout.razor
Normal file
25
src/SQLInsight.Hub/Portal/Shared/PortalLayout.razor
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
<div class="shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<a class="brand" href="/portal"><span class="logo" aria-hidden="true">SI</span><span>SQLInsight <strong>Hub</strong></span></a>
|
||||||
|
<nav class="nav">
|
||||||
|
<span class="nav-label">Kunder</span>
|
||||||
|
<NavLink href="/portal" Match="NavLinkMatch.All">Overblik</NavLink>
|
||||||
|
<NavLink href="/portal/fund">Alle fund</NavLink>
|
||||||
|
<NavLink href="/portal/sager">Supportsager</NavLink>
|
||||||
|
<NavLink href="/portal/licenser">Licenser</NavLink>
|
||||||
|
</nav>
|
||||||
|
<div class="support-card">
|
||||||
|
<strong>Intern portal</strong>
|
||||||
|
<p>Data fra betalende kunders agenter. Følsomme målinger er udeladt, medmindre kunden har tilladt dem.</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<AuthorizeView><span class="topbar-note">Logget ind som @context.User.Identity?.Name</span></AuthorizeView>
|
||||||
|
<form method="post" action="/portal/logout"><AntiforgeryToken /><button type="submit" class="theme-toggle">Log ud</button></form>
|
||||||
|
</header>
|
||||||
|
<main class="page">@Body</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
5
src/SQLInsight.Hub/Portal/Shared/RedirectToLogin.razor
Normal file
5
src/SQLInsight.Hub/Portal/Shared/RedirectToLogin.razor
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@code {
|
||||||
|
protected override void OnInitialized() =>
|
||||||
|
Navigation.NavigateTo("/portal/login?returnUrl=" + Uri.EscapeDataString("/" + Navigation.ToBaseRelativePath(Navigation.Uri)), forceLoad: true);
|
||||||
|
}
|
||||||
9
src/SQLInsight.Hub/Portal/Shared/Sev.razor
Normal file
9
src/SQLInsight.Hub/Portal/Shared/Sev.razor
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<span class="badge @Class">@Name@(Count is { } c ? $" {c}" : "")</span>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public int Severity { get; set; }
|
||||||
|
[Parameter] public int? Count { get; set; }
|
||||||
|
|
||||||
|
private string Name => Severity switch { 4 => "Kritisk", 3 => "Høj", 2 => "Middel", 1 => "Lav", _ => "Info" };
|
||||||
|
private string Class => Severity switch { 4 => "sev-critical", 3 => "sev-high", 2 => "sev-medium", 1 => "sev-low", _ => "sev-info" };
|
||||||
|
}
|
||||||
55
src/SQLInsight.Hub/Portal/StaffAccounts.cs
Normal file
55
src/SQLInsight.Hub/Portal/StaffAccounts.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SQLInsight.Hub.Data;
|
||||||
|
|
||||||
|
namespace SQLInsight.Hub.Portal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vendor staff accounts for the portal. Passwords are hashed with ASP.NET Core Identity's PBKDF2 hasher.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StaffAccounts(HubDbContext db, TimeProvider time)
|
||||||
|
{
|
||||||
|
private static readonly PasswordHasher<StaffUser> Hasher = new();
|
||||||
|
|
||||||
|
public const int MinPasswordLength = 12;
|
||||||
|
|
||||||
|
public async Task<StaffUser> CreateAsync(string userName, string displayName, string password, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(userName);
|
||||||
|
ArgumentNullException.ThrowIfNull(password);
|
||||||
|
if (password.Length < MinPasswordLength)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Adgangskoden skal være mindst {MinPasswordLength} tegn.", nameof(password));
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = new StaffUser { UserName = userName.Trim().ToLowerInvariant(), DisplayName = displayName, PasswordHash = string.Empty, CreatedAt = time.GetUtcNow().UtcDateTime };
|
||||||
|
user.PasswordHash = Hasher.HashPassword(user, password);
|
||||||
|
db.StaffUsers.Add(user);
|
||||||
|
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the user if the password is right. Takes the same time whether or not the user exists.</summary>
|
||||||
|
public async Task<StaffUser?> VerifyAsync(string userName, string password, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var normalized = (userName ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
var user = await db.StaffUsers.SingleOrDefaultAsync(u => u.UserName == normalized && u.IsActive, cancellationToken).ConfigureAwait(false);
|
||||||
|
var candidate = user ?? new StaffUser { UserName = normalized, DisplayName = string.Empty, PasswordHash = DummyHash };
|
||||||
|
var result = Hasher.VerifyHashedPassword(candidate, candidate.PasswordHash, password ?? string.Empty);
|
||||||
|
if (user is null || result == PasswordVerificationResult.Failed)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result == PasswordVerificationResult.SuccessRehashNeeded)
|
||||||
|
{
|
||||||
|
user.PasswordHash = Hasher.HashPassword(user, password!);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.LastLoginAt = time.GetUtcNow().UtcDateTime;
|
||||||
|
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly string DummyHash = Hasher.HashPassword(new StaffUser { UserName = "x", DisplayName = "x", PasswordHash = "x" }, Guid.NewGuid().ToString());
|
||||||
|
}
|
||||||
10
src/SQLInsight.Hub/Portal/_Imports.razor
Normal file
10
src/SQLInsight.Hub/Portal/_Imports.razor
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
@using System.Globalization
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using SQLInsight.Hub.Data
|
||||||
|
@using SQLInsight.Hub.Portal
|
||||||
|
@using SQLInsight.Hub.Portal.Shared
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SQLInsight.Hub;
|
using SQLInsight.Hub;
|
||||||
using SQLInsight.Hub.Api;
|
using SQLInsight.Hub.Api;
|
||||||
using SQLInsight.Hub.Data;
|
using SQLInsight.Hub.Data;
|
||||||
|
using SQLInsight.Hub.Portal;
|
||||||
|
|
||||||
namespace SQLInsight.Hub;
|
namespace SQLInsight.Hub;
|
||||||
|
|
||||||
|
|
@ -12,6 +15,12 @@ public static class HubProgram
|
||||||
{
|
{
|
||||||
public static async Task Main(string[] args)
|
public static async Task Main(string[] args)
|
||||||
{
|
{
|
||||||
|
if (args.FirstOrDefault() == "add-staff")
|
||||||
|
{
|
||||||
|
await AddStaffAsync(args).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var app = Build(args);
|
var app = Build(args);
|
||||||
await MigrateAsync(app.Services).ConfigureAwait(false);
|
await MigrateAsync(app.Services).ConfigureAwait(false);
|
||||||
await app.RunAsync().ConfigureAwait(false);
|
await app.RunAsync().ConfigureAwait(false);
|
||||||
|
|
@ -29,12 +38,69 @@ public static class HubProgram
|
||||||
?? throw new InvalidOperationException("ConnectionStrings:Hub mangler (PostgreSQL).");
|
?? throw new InvalidOperationException("ConnectionStrings:Hub mangler (PostgreSQL).");
|
||||||
builder.Services.AddDbContext<HubDbContext>(o => o.UseNpgsql(connectionString).UseSnakeCaseNamingConvention());
|
builder.Services.AddDbContext<HubDbContext>(o => o.UseNpgsql(connectionString).UseSnakeCaseNamingConvention());
|
||||||
|
|
||||||
|
builder.Services.AddScoped<StaffAccounts>();
|
||||||
|
builder.Services.AddScoped<PortalQueries>();
|
||||||
|
builder.Services.AddRazorComponents();
|
||||||
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
|
builder.Services.AddSingleton(System.Text.Encodings.Web.HtmlEncoder.Create(System.Text.Unicode.UnicodeRanges.All));
|
||||||
|
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(o =>
|
||||||
|
{
|
||||||
|
o.LoginPath = "/portal/login";
|
||||||
|
o.Cookie.Name = "SQLInsightHub";
|
||||||
|
o.Cookie.HttpOnly = true;
|
||||||
|
o.Cookie.SameSite = SameSiteMode.Strict;
|
||||||
|
o.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always;
|
||||||
|
o.ExpireTimeSpan = TimeSpan.FromHours(8);
|
||||||
|
o.SlidingExpiration = true;
|
||||||
|
});
|
||||||
|
builder.Services.AddAuthorization();
|
||||||
|
var loginAttempts = builder.Configuration.GetSection(HubServerOptions.SectionName).Get<HubServerOptions>()?.LoginAttemptsPerMinute ?? 10;
|
||||||
|
builder.Services.AddRateLimiter(o =>
|
||||||
|
{
|
||||||
|
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
o.AddPolicy(PortalEndpoints.LoginRateLimit, http => RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
http.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
_ => new FixedWindowRateLimiterOptions { PermitLimit = loginAttempts, Window = TimeSpan.FromMinutes(1) }));
|
||||||
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
if (!app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseHsts();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
context.Response.Headers.XContentTypeOptions = "nosniff";
|
||||||
|
context.Response.Headers.XFrameOptions = "DENY";
|
||||||
|
context.Response.Headers["Referrer-Policy"] = "no-referrer";
|
||||||
|
context.Response.Headers.ContentSecurityPolicy = "default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; form-action 'self'";
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.UseRateLimiter();
|
||||||
|
app.UseAntiforgery();
|
||||||
|
app.MapStaticAssets();
|
||||||
|
app.MapGet("/", () => Results.Redirect("/portal"));
|
||||||
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
|
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
|
||||||
FindingsEndpoint.Map(app);
|
FindingsEndpoint.Map(app);
|
||||||
|
PortalEndpoints.Map(app);
|
||||||
|
app.MapRazorComponents<PortalApp>();
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary><c>SQLInsight.Hub add-staff <brugernavn> <navn></c>, adgangskode fra SQLINSIGHT_STAFF_PASSWORD eller stdin.</summary>
|
||||||
|
private static async Task AddStaffAsync(string[] args)
|
||||||
|
{
|
||||||
|
var app = Build(args.Skip(3).ToArray());
|
||||||
|
await MigrateAsync(app.Services).ConfigureAwait(false);
|
||||||
|
var password = Environment.GetEnvironmentVariable("SQLINSIGHT_STAFF_PASSWORD") ?? Console.ReadLine() ?? string.Empty;
|
||||||
|
await using var scope = app.Services.CreateAsyncScope();
|
||||||
|
var user = await scope.ServiceProvider.GetRequiredService<StaffAccounts>().CreateAsync(args[1], args[2], password).ConfigureAwait(false);
|
||||||
|
Console.WriteLine($"Oprettet: {user.UserName}");
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task MigrateAsync(IServiceProvider services)
|
public static async Task MigrateAsync(IServiceProvider services)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(services);
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
|
|
||||||
169
src/SQLInsight.Hub/wwwroot/app.css
Normal file
169
src/SQLInsight.Hub/wwwroot/app.css
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
/* SQLInsight Pro design system: light first, neutral surfaces, one accent, semantic severity colours. */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Inter";
|
||||||
|
src: url("fonts/inter-latin-wght-normal.woff2") format("woff2");
|
||||||
|
font-weight: 100 900; font-style: normal; font-display: swap;
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Inter";
|
||||||
|
src: url("fonts/inter-latin-ext-wght-normal.woff2") format("woff2");
|
||||||
|
font-weight: 100 900; font-style: normal; font-display: swap;
|
||||||
|
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #f6f7f9;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #f1f3f6;
|
||||||
|
--border: #e4e7ec;
|
||||||
|
--text: #101828;
|
||||||
|
--text-2: #475467;
|
||||||
|
--text-3: #98a2b3;
|
||||||
|
--accent: #0e7490; /* deep teal */
|
||||||
|
--accent-soft: #e0f2f6;
|
||||||
|
--accent-text: #0b5c72;
|
||||||
|
--critical: #d92d20; --critical-soft: #fee4e2;
|
||||||
|
--high: #e04f16; --high-soft: #ffead5;
|
||||||
|
--medium: #b54708; --medium-soft: #fef0c7;
|
||||||
|
--low: #1570ef; --low-soft: #d1e9ff;
|
||||||
|
--info: #667085; --info-soft: #f2f4f7;
|
||||||
|
--ok: #079455; --ok-soft: #dcfae6;
|
||||||
|
--radius: 12px;
|
||||||
|
--shadow: 0 1px 2px rgba(16, 24, 40, .05), 0 1px 3px rgba(16, 24, 40, .06);
|
||||||
|
--sidebar: 256px;
|
||||||
|
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
font-feature-settings: "cv11", "ss01";
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg: #0c111d; --surface: #161b26; --surface-2: #1f2533; --border: #2a3140;
|
||||||
|
--text: #f5f5f6; --text-2: #cecfd2; --text-3: #85888e;
|
||||||
|
--accent: #22b8cf; --accent-soft: #0e2d36; --accent-text: #7dd9e8;
|
||||||
|
--critical-soft: #3b1512; --high-soft: #3a1d0d; --medium-soft: #3a2a07; --low-soft: #0f2744; --info-soft: #1f2533; --ok-soft: #0b2e1d;
|
||||||
|
--shadow: none;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
|
body { margin: 0; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.55; }
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
.icon { width: 18px; height: 18px; flex: none; }
|
||||||
|
|
||||||
|
/* Shell */
|
||||||
|
.shell { display: grid; grid-template-columns: var(--sidebar) 1fr; min-height: 100vh; background: linear-gradient(to right, var(--surface) calc(var(--sidebar) - 1px), var(--border) calc(var(--sidebar) - 1px), var(--border) var(--sidebar), transparent var(--sidebar)); }
|
||||||
|
.sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; gap: 1.5rem; padding: 1.25rem 1rem; }
|
||||||
|
.brand { display: flex; align-items: center; gap: .6rem; color: var(--text); font-size: 1.05rem; padding: 0 .5rem; }
|
||||||
|
.brand:hover { text-decoration: none; }
|
||||||
|
.brand strong { color: var(--accent); }
|
||||||
|
.logo { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; background: linear-gradient(135deg, var(--accent), #155e75); color: #fff; font-weight: 700; font-size: .8rem; letter-spacing: .02em; }
|
||||||
|
.nav { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.nav-label { font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-3); padding: .9rem .75rem .35rem; }
|
||||||
|
.nav a { display: flex; align-items: center; gap: .65rem; padding: .5rem .75rem; border-radius: 8px; color: var(--text-2); font-weight: 500; }
|
||||||
|
.nav a:hover { background: var(--surface-2); text-decoration: none; color: var(--text); }
|
||||||
|
.nav a.active { background: var(--accent-soft); color: var(--accent-text); }
|
||||||
|
.support-card { margin-top: auto; padding: 1rem; border-radius: var(--radius); background: var(--surface-2); border: 1px solid var(--border); display: flex; flex-direction: column; gap: .35rem; }
|
||||||
|
.support-card .icon { color: var(--accent); width: 22px; height: 22px; }
|
||||||
|
.support-card p { margin: 0; color: var(--text-2); font-size: .82rem; }
|
||||||
|
.support-card a { font-weight: 600; font-size: .85rem; }
|
||||||
|
.main { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.topbar { display: flex; justify-content: space-between; align-items: center; padding: .75rem 2rem; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--surface) 85%, transparent); backdrop-filter: blur(8px); position: sticky; top: 0; z-index: 5; }
|
||||||
|
.topbar-note { color: var(--text-3); font-size: .82rem; }
|
||||||
|
.theme-toggle { display: inline-flex; align-items: center; gap: .4rem; border: 1px solid var(--border); background: var(--surface); color: var(--text-2); border-radius: 8px; padding: .35rem .7rem; font: inherit; font-size: .82rem; cursor: pointer; }
|
||||||
|
.theme-toggle:hover { color: var(--text); background: var(--surface-2); }
|
||||||
|
.page { width: 100%; max-width: 1320px; padding: 1.75rem 2rem 2rem; }
|
||||||
|
.footer { color: var(--text-3); font-size: .8rem; padding: 1rem 2rem 2rem; }
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
h1 { font-size: 1.6rem; line-height: 1.25; letter-spacing: -.02em; margin: .2rem 0 .35rem; display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
|
||||||
|
h2 { font-size: 1.02rem; letter-spacing: -.01em; margin: 0 0 .75rem; }
|
||||||
|
h3 { font-size: .95rem; margin: 1.1rem 0 .3rem; }
|
||||||
|
.lead { color: var(--text-2); margin: 0 0 1.5rem; }
|
||||||
|
.muted { color: var(--text-3); }
|
||||||
|
.crumbs { margin: 0; font-size: .85rem; color: var(--text-3); }
|
||||||
|
.mono, pre, code { font-family: "Cascadia Code", "JetBrains Mono", Consolas, monospace; font-size: .82rem; }
|
||||||
|
|
||||||
|
/* Cards and sections */
|
||||||
|
.card, .panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 1.25rem 1.4rem; margin-bottom: 1.25rem; }
|
||||||
|
.card-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: .75rem; }
|
||||||
|
.card-head h2 { margin: 0; }
|
||||||
|
.card > table, .panel > table { margin: 0 -1.4rem -1.25rem; width: calc(100% + 2.8rem); border: 0; border-top: 1px solid var(--border); border-radius: 0 0 var(--radius) var(--radius); box-shadow: none; }
|
||||||
|
.section { margin-top: 1.75rem; }
|
||||||
|
.grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 1.25rem; }
|
||||||
|
.grid2 > .card, .grid2 > .panel { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* KPI tiles */
|
||||||
|
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||||
|
.kpi { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 1rem 1.2rem; display: flex; gap: .9rem; align-items: center; }
|
||||||
|
.kpi .kpi-icon { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 10px; background: var(--c-soft, var(--accent-soft)); color: var(--c, var(--accent)); }
|
||||||
|
.kpi strong { display: block; font-size: 1.55rem; line-height: 1.1; letter-spacing: -.02em; }
|
||||||
|
.kpi span { color: var(--text-2); font-size: .82rem; }
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
table { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
|
||||||
|
th, td { text-align: left; padding: .65rem 1rem; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||||
|
th { background: var(--surface-2); font-size: .74rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-2); font-weight: 600; }
|
||||||
|
tbody tr:hover td { background: color-mix(in srgb, var(--surface-2) 60%, transparent); }
|
||||||
|
tr:last-child td { border-bottom: 0; }
|
||||||
|
td .muted { font-size: .82rem; }
|
||||||
|
.num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||||
|
.bad { color: var(--critical); font-weight: 600; }
|
||||||
|
meter { width: 64px; height: 8px; margin-right: .4rem; vertical-align: middle; }
|
||||||
|
|
||||||
|
/* Severity */
|
||||||
|
.sev-critical { --c: var(--critical); --c-soft: var(--critical-soft); }
|
||||||
|
.sev-high { --c: var(--high); --c-soft: var(--high-soft); }
|
||||||
|
.sev-medium { --c: var(--medium); --c-soft: var(--medium-soft); }
|
||||||
|
.sev-low { --c: var(--low); --c-soft: var(--low-soft); }
|
||||||
|
.sev-info { --c: var(--info); --c-soft: var(--info-soft); }
|
||||||
|
.sev-ok { --c: var(--ok); --c-soft: var(--ok-soft); }
|
||||||
|
.badge { display: inline-flex; align-items: center; gap: .35rem; padding: .12rem .6rem; border-radius: 999px; font-size: .75rem; font-weight: 600; white-space: nowrap; color: var(--c, var(--info)); background: var(--c-soft, var(--info-soft)); }
|
||||||
|
.badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||||
|
.counts { display: flex; gap: .3rem; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Charts */
|
||||||
|
.chart { margin: 0; }
|
||||||
|
.chart svg { width: 100%; height: 90px; display: block; }
|
||||||
|
.chart .line { fill: none; stroke: var(--accent); stroke-width: 2; vector-effect: non-scaling-stroke; }
|
||||||
|
.chart .grid { stroke: var(--border); stroke-width: 1; vector-effect: non-scaling-stroke; stroke-dasharray: 3 4; }
|
||||||
|
.chart figcaption { display: flex; justify-content: space-between; gap: 1rem; font-size: .82rem; color: var(--text-2); margin-top: .5rem; }
|
||||||
|
.chart figcaption span:first-child { font-weight: 600; color: var(--text); }
|
||||||
|
|
||||||
|
/* Alerts */
|
||||||
|
.alert { border: 1px solid var(--c, var(--critical)); background: var(--c-soft, var(--critical-soft)); border-radius: var(--radius); padding: .85rem 1rem; margin: 0 0 1.25rem; }
|
||||||
|
.alert.warn { --c: var(--medium); --c-soft: var(--medium-soft); }
|
||||||
|
.alert pre { white-space: pre-wrap; margin: .5rem 0 0; }
|
||||||
|
|
||||||
|
/* Finding detail */
|
||||||
|
.evidence { box-shadow: none; }
|
||||||
|
.evidence th { width: 38%; text-transform: none; letter-spacing: 0; font-size: .85rem; background: transparent; color: var(--text-2); font-weight: 500; }
|
||||||
|
.recommendation { border-left: 4px solid var(--ok); }
|
||||||
|
.script { position: relative; }
|
||||||
|
.script pre { background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; overflow-x: auto; margin: .5rem 0 0; }
|
||||||
|
.copy { position: absolute; top: .5rem; right: .5rem; border: 1px solid var(--border); background: var(--surface); color: var(--text); border-radius: 6px; padding: .2rem .65rem; font: inherit; font-size: .8rem; cursor: pointer; }
|
||||||
|
.empty { background: var(--surface); border: 1px dashed var(--border); border-radius: var(--radius); padding: 1.25rem; color: var(--text-2); }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.shell { grid-template-columns: 1fr; }
|
||||||
|
.shell { background: none; }
|
||||||
|
.sidebar { position: static; height: auto; background: var(--surface); border-bottom: 1px solid var(--border); }
|
||||||
|
.support-card { display: none; }
|
||||||
|
.page, .topbar { padding-left: 1rem; padding-right: 1rem; }
|
||||||
|
.grid2 { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms (portal) */
|
||||||
|
.login { min-height: 100vh; display: grid; place-items: center; padding: 1rem; }
|
||||||
|
.login form { width: min(380px, 100%); display: flex; flex-direction: column; gap: .9rem; }
|
||||||
|
.login h1 { font-size: 1.3rem; }
|
||||||
|
label { display: flex; flex-direction: column; gap: .3rem; font-weight: 500; font-size: .9rem; }
|
||||||
|
input, select { font: inherit; padding: .5rem .65rem; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); }
|
||||||
|
.primary { font: inherit; font-weight: 600; padding: .55rem 1rem; border: 0; border-radius: 8px; background: var(--accent); color: #fff; cursor: pointer; }
|
||||||
|
.primary:hover { filter: brightness(1.08); }
|
||||||
|
.inline-form { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin: .5rem 0; }
|
||||||
|
.nowrap { white-space: nowrap; }
|
||||||
|
td .inline-form { flex-wrap: nowrap; margin: 0; }
|
||||||
|
td .inline-form input { width: 8rem; min-width: 0; }
|
||||||
1
src/SQLInsight.Hub/wwwroot/favicon.svg
Normal file
1
src/SQLInsight.Hub/wwwroot/favicon.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0e7490"/><stop offset="1" stop-color="#155e75"/></linearGradient></defs><rect width="32" height="32" rx="8" fill="url(#g)"/><text x="16" y="21" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif" font-size="13" font-weight="700" fill="#fff">SI</text></svg>
|
||||||
|
After Width: | Height: | Size: 415 B |
93
src/SQLInsight.Hub/wwwroot/fonts/Inter-OFL.txt
Normal file
93
src/SQLInsight.Hub/wwwroot/fonts/Inter-OFL.txt
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
BIN
src/SQLInsight.Hub/wwwroot/fonts/inter-latin-wght-normal.woff2
Normal file
BIN
src/SQLInsight.Hub/wwwroot/fonts/inter-latin-wght-normal.woff2
Normal file
Binary file not shown.
3
src/SQLInsight.Hub/wwwroot/theme.js
Normal file
3
src/SQLInsight.Hub/wwwroot/theme.js
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem('sqlinsight-theme') === 'dark') document.documentElement.dataset.theme = 'dark';
|
||||||
|
} catch { /* storage blocked: stay light */ }
|
||||||
|
|
@ -12,7 +12,7 @@ namespace SQLInsight.IntegrationTests;
|
||||||
[Trait("Category", "Integration")]
|
[Trait("Category", "Integration")]
|
||||||
public class HubApiTests(HubFixture hub) : IClassFixture<HubFixture>
|
public class HubApiTests(HubFixture hub) : IClassFixture<HubFixture>
|
||||||
{
|
{
|
||||||
private static FindingsSubmission Submission(string instance, DateTimeOffset collectedAt, params (string Rule, string Subject, string Severity)[] findings) => new()
|
internal static FindingsSubmission Submission(string instance, DateTimeOffset collectedAt, params (string Rule, string Subject, string Severity)[] findings) => new()
|
||||||
{
|
{
|
||||||
SubmissionId = Guid.NewGuid(),
|
SubmissionId = Guid.NewGuid(),
|
||||||
AgentVersion = "1.0.0",
|
AgentVersion = "1.0.0",
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ public sealed class HubFixture : IAsyncLifetime
|
||||||
b.UseEnvironment("Development");
|
b.UseEnvironment("Development");
|
||||||
b.UseSetting("ConnectionStrings:Hub", ConnectionString);
|
b.UseSetting("ConnectionStrings:Hub", ConnectionString);
|
||||||
b.UseSetting($"SQLInsight:Hub:TrustedKeys:{KeyId}", PublicKey);
|
b.UseSetting($"SQLInsight:Hub:TrustedKeys:{KeyId}", PublicKey);
|
||||||
|
b.UseSetting("SQLInsight:Hub:LoginAttemptsPerMinute", "1000");
|
||||||
});
|
});
|
||||||
await HubProgram.MigrateAsync(Factory.Services);
|
await HubProgram.MigrateAsync(Factory.Services);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
238
tests/SQLInsight.IntegrationTests/HubPortalTests.cs
Normal file
238
tests/SQLInsight.IntegrationTests/HubPortalTests.cs
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SQLInsight.Hub;
|
||||||
|
using SQLInsight.Hub.Data;
|
||||||
|
using SQLInsight.Hub.Portal;
|
||||||
|
|
||||||
|
namespace SQLInsight.IntegrationTests;
|
||||||
|
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public partial class HubPortalTests(HubFixture hub) : IClassFixture<HubFixture>
|
||||||
|
{
|
||||||
|
private const string Password = "korrekt-hest-batteri";
|
||||||
|
|
||||||
|
[GeneratedRegex("name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"")]
|
||||||
|
private static partial Regex AntiforgeryField();
|
||||||
|
|
||||||
|
private static HttpClient Client(WebApplicationFactory<HubServerOptions> factory) =>
|
||||||
|
factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||||
|
|
||||||
|
private async Task<string> StaffAsync()
|
||||||
|
{
|
||||||
|
var userName = $"staff{Guid.NewGuid():N}"[..20];
|
||||||
|
await using var scope = hub.Factory.Services.CreateAsyncScope();
|
||||||
|
await scope.ServiceProvider.GetRequiredService<StaffAccounts>().CreateAsync(userName, "Support Person", Password);
|
||||||
|
return userName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> TokenAsync(HttpClient client, string page)
|
||||||
|
{
|
||||||
|
var html = await client.GetStringAsync(page);
|
||||||
|
return WebUtility.HtmlDecode(AntiforgeryField().Match(html).Groups[1].Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<HttpResponseMessage> PostFormAsync(HttpClient client, string page, string action, params (string Key, string Value)[] fields)
|
||||||
|
{
|
||||||
|
var form = fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value)).Append(new("__RequestVerificationToken", await TokenAsync(client, page)));
|
||||||
|
using var content = new FormUrlEncodedContent(form);
|
||||||
|
return await client.PostAsync(action, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<HttpResponseMessage> LoginAsync(HttpClient client, string userName, string password, string returnUrl = "") =>
|
||||||
|
PostFormAsync(client, "/portal/login", "/portal/login", ("userName", userName), ("password", password), ("returnUrl", returnUrl));
|
||||||
|
|
||||||
|
private async Task<HttpClient> SignedInAsync()
|
||||||
|
{
|
||||||
|
var client = Client(hub.Factory);
|
||||||
|
var response = await LoginAsync(client, await StaffAsync(), Password);
|
||||||
|
Assert.Equal("/portal", response.Headers.Location?.OriginalString);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<long> SeedAsync(string customer, string license)
|
||||||
|
{
|
||||||
|
var client = hub.Factory.CreateClient();
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/findings")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(HubApiTests.Submission("sql01", DateTimeOffset.UtcNow, ("SQLI-DB-007", "database: Sales", "Critical"))),
|
||||||
|
};
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license);
|
||||||
|
(await client.SendAsync(request)).EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
await using var scope = hub.Factory.Services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<HubDbContext>();
|
||||||
|
return await db.Findings.Where(f => f.Instance!.Customer!.Name == customer).Select(f => f.Id).SingleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Pages_require_login()
|
||||||
|
{
|
||||||
|
var client = Client(hub.Factory);
|
||||||
|
|
||||||
|
var response = await client.GetAsync("/portal/fund");
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||||
|
Assert.StartsWith("/portal/login", response.Headers.Location!.OriginalString.Replace("http://localhost", "", StringComparison.Ordinal));
|
||||||
|
var action = await client.PostAsync("/portal/actions/cases/1/close", null);
|
||||||
|
Assert.Equal(HttpStatusCode.Redirect, action.StatusCode);
|
||||||
|
Assert.Contains("/portal/login", action.Headers.Location!.OriginalString, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Wrong_password_is_rejected()
|
||||||
|
{
|
||||||
|
var client = Client(hub.Factory);
|
||||||
|
var userName = await StaffAsync();
|
||||||
|
|
||||||
|
var wrong = await LoginAsync(client, userName, "forkert-adgangskode");
|
||||||
|
var unknown = await LoginAsync(client, "findes-ikke", Password);
|
||||||
|
|
||||||
|
Assert.Equal("/portal/login?fejl=1", wrong.Headers.Location?.OriginalString);
|
||||||
|
Assert.Equal("/portal/login?fejl=1", unknown.Headers.Location?.OriginalString);
|
||||||
|
Assert.Equal(HttpStatusCode.Redirect, (await client.GetAsync("/portal")).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_without_antiforgery_token_is_refused()
|
||||||
|
{
|
||||||
|
var client = Client(hub.Factory);
|
||||||
|
using var content = new FormUrlEncodedContent([new("userName", await StaffAsync()), new("password", Password)]);
|
||||||
|
|
||||||
|
var response = await client.PostAsync("/portal/login", content);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/portal/sager", "/portal/sager")]
|
||||||
|
[InlineData("https://evil.example/portal", "/portal")]
|
||||||
|
[InlineData("//evil.example/portal", "/portal")]
|
||||||
|
[InlineData("/\\evil.example", "/portal")]
|
||||||
|
public async Task Return_url_is_only_followed_inside_the_portal(string returnUrl, string expected)
|
||||||
|
{
|
||||||
|
var client = Client(hub.Factory);
|
||||||
|
|
||||||
|
var response = await LoginAsync(client, await StaffAsync(), Password, returnUrl);
|
||||||
|
|
||||||
|
Assert.Equal(expected, response.Headers.Location?.OriginalString);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_is_rate_limited()
|
||||||
|
{
|
||||||
|
await using var isolated = hub.Factory.WithWebHostBuilder(b => b.UseSetting("SQLInsight:Hub:LoginAttemptsPerMinute", "10"));
|
||||||
|
var client = Client(isolated);
|
||||||
|
var statuses = new List<HttpStatusCode>();
|
||||||
|
for (var i = 0; i < 12; i++)
|
||||||
|
{
|
||||||
|
statuses.Add((await LoginAsync(client, "findes-ikke", "forkert-adgangskode")).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(10, statuses.Count(s => s == HttpStatusCode.Redirect));
|
||||||
|
Assert.Equal(HttpStatusCode.TooManyRequests, statuses[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Signed_in_staff_sees_customers_findings_cases_and_licenses()
|
||||||
|
{
|
||||||
|
var customer = $"Portal {Guid.NewGuid():N}"[..20];
|
||||||
|
var id = await SeedAsync(customer, hub.License(customer));
|
||||||
|
var client = await SignedInAsync();
|
||||||
|
|
||||||
|
var overview = await client.GetStringAsync("/portal");
|
||||||
|
var findings = await client.GetStringAsync("/portal/fund");
|
||||||
|
var detail = await client.GetStringAsync($"/portal/fund/{id}");
|
||||||
|
var licenses = await client.GetStringAsync("/portal/licenser");
|
||||||
|
var cases = await client.GetAsync("/portal/sager");
|
||||||
|
|
||||||
|
Assert.Contains(customer, overview, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("SQLI-DB-007", findings, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("database: Sales", detail, StringComparison.Ordinal);
|
||||||
|
Assert.Contains(customer, licenses, StringComparison.Ordinal);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, cases.StatusCode);
|
||||||
|
Assert.Contains("default-src 'self'", cases.Headers.GetValues("Content-Security-Policy").Single(), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Finding_can_be_linked_to_a_new_support_case_and_the_case_closed()
|
||||||
|
{
|
||||||
|
var customer = $"Sag {Guid.NewGuid():N}"[..20];
|
||||||
|
var id = await SeedAsync(customer, hub.License(customer));
|
||||||
|
var client = await SignedInAsync();
|
||||||
|
|
||||||
|
var linked = await PostFormAsync(client, $"/portal/fund/{id}", $"/portal/actions/findings/{id}/case", ("reference", "SUP-1234"), ("title", ""));
|
||||||
|
Assert.Equal($"/portal/fund/{id}", linked.Headers.Location?.OriginalString);
|
||||||
|
Assert.Contains("SUP-1234", await client.GetStringAsync("/portal/sager"), StringComparison.Ordinal);
|
||||||
|
|
||||||
|
int caseId;
|
||||||
|
await using (var scope = hub.Factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<HubDbContext>();
|
||||||
|
var finding = await db.Findings.Include(f => f.SupportCase).SingleAsync(f => f.Id == id);
|
||||||
|
caseId = finding.SupportCase!.Id;
|
||||||
|
Assert.Equal("SUP-1234", finding.SupportCase.Reference);
|
||||||
|
Assert.Equal(finding.Title, finding.SupportCase.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
var closed = await PostFormAsync(client, "/portal/sager", $"/portal/actions/cases/{caseId}/close");
|
||||||
|
Assert.Equal("/portal/sager", closed.Headers.Location?.OriginalString);
|
||||||
|
await using (var scope = hub.Factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
Assert.False((await scope.ServiceProvider.GetRequiredService<HubDbContext>().SupportCases.SingleAsync(c => c.Id == caseId)).IsOpen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Finding_cannot_be_linked_to_another_customers_case()
|
||||||
|
{
|
||||||
|
var a = $"A {Guid.NewGuid():N}"[..20];
|
||||||
|
var b = $"B {Guid.NewGuid():N}"[..20];
|
||||||
|
var findingA = await SeedAsync(a, hub.License(a));
|
||||||
|
var findingB = await SeedAsync(b, hub.License(b));
|
||||||
|
var client = await SignedInAsync();
|
||||||
|
await PostFormAsync(client, $"/portal/fund/{findingB}", $"/portal/actions/findings/{findingB}/case", ("reference", "SUP-B"));
|
||||||
|
int caseB;
|
||||||
|
await using (var scope = hub.Factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
caseB = (await scope.ServiceProvider.GetRequiredService<HubDbContext>().Findings.SingleAsync(f => f.Id == findingB)).SupportCaseId!.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await PostFormAsync(client, $"/portal/fund/{findingA}", $"/portal/actions/findings/{findingA}/case", ("caseId", caseB.ToString(System.Globalization.CultureInfo.InvariantCulture)));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Revoking_a_license_in_the_portal_blocks_the_agent()
|
||||||
|
{
|
||||||
|
var customer = $"Spær {Guid.NewGuid():N}"[..20];
|
||||||
|
var licenseId = Guid.NewGuid().ToString("N");
|
||||||
|
var license = hub.License(customer, id: licenseId);
|
||||||
|
await SeedAsync(customer, license);
|
||||||
|
var client = await SignedInAsync();
|
||||||
|
|
||||||
|
var revoked = await PostFormAsync(client, "/portal/licenser", $"/portal/actions/licenses/{licenseId}/revoke", ("reason", "Ikke betalt"));
|
||||||
|
|
||||||
|
Assert.Equal("/portal/licenser", revoked.Headers.Location?.OriginalString);
|
||||||
|
var api = hub.Factory.CreateClient();
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/findings")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(HubApiTests.Submission("sql01", DateTimeOffset.UtcNow)),
|
||||||
|
};
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license);
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, (await api.SendAsync(request)).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Short_passwords_are_refused()
|
||||||
|
{
|
||||||
|
await using var scope = hub.Factory.Services.CreateAsyncScope();
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>(() => scope.ServiceProvider.GetRequiredService<StaffAccounts>().CreateAsync("kort", "Kort", "123"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue