diff --git a/src/SQLInsight.Hub/HubOptions.cs b/src/SQLInsight.Hub/HubOptions.cs
index 33f9a29..d0960ea 100644
--- a/src/SQLInsight.Hub/HubOptions.cs
+++ b/src/SQLInsight.Hub/HubOptions.cs
@@ -12,4 +12,7 @@ public sealed class HubServerOptions
/// Most findings accepted per submission.
public int MaxFindingsPerSubmission { get; set; } = 2000;
+
+ /// Portal login attempts allowed per client IP per minute.
+ public int LoginAttemptsPerMinute { get; set; } = 10;
}
diff --git a/src/SQLInsight.Hub/Portal/Pages/Cases.razor b/src/SQLInsight.Hub/Portal/Pages/Cases.razor
new file mode 100644
index 0000000..55837f8
--- /dev/null
+++ b/src/SQLInsight.Hub/Portal/Pages/Cases.razor
@@ -0,0 +1,37 @@
+@page "/portal/sager"
+@attribute [Authorize]
+@inject PortalQueries Queries
+
+Supportsager · SQLInsight Hub
+
Åbne supportsager
+
+ @if (_cases.Count == 0)
+ {
+ Ingen åbne sager.
+ }
+ else
+ {
+
+ | Sag | Kunde | Titel | Fund | Aktive | Oprettet | |
+
+ @foreach (var c in _cases)
+ {
+
+ | @c.Reference |
+ @c.Customer!.Name |
+ @c.Title |
+ @c.Findings.Count |
+ @c.Findings.Count(f => f.ResolvedAt == null) |
+ @c.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) |
+ |
+
+ }
+
+
+ }
+
+
+@code {
+ private IReadOnlyList _cases = [];
+ protected override async Task OnInitializedAsync() => _cases = await Queries.OpenCasesAsync();
+}
diff --git a/src/SQLInsight.Hub/Portal/Pages/CustomerDetail.razor b/src/SQLInsight.Hub/Portal/Pages/CustomerDetail.razor
new file mode 100644
index 0000000..3062f9d
--- /dev/null
+++ b/src/SQLInsight.Hub/Portal/Pages/CustomerDetail.razor
@@ -0,0 +1,70 @@
+@page "/portal/kunder/{Id:int}"
+@attribute [Authorize]
+@inject PortalQueries Queries
+
+@if (_customer is null)
+{
+ Ukendt kunde
+ return;
+}
+
+@_customer.Name · SQLInsight Hub
+Kunder / @_customer.Name
+@_customer.Name
+@{ var license = _customer.Licenses.Where(l => l.RevokedAt == null).OrderByDescending(l => l.ExpiresAt).FirstOrDefault(); }
+@(license is null ? "Ingen gyldig licens" : $"{license.Plan} til {license.ExpiresAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}") · @_customer.Instances.Count instanser
+
+
+ Instanser
+
+ | Instans | Version | Agent | Seneste data |
+
+ @foreach (var i in _customer.Instances.OrderBy(i => i.InstanceKey))
+ {
+
+ | @i.InstanceKey @i.ServerName |
+ @i.Release @i.ProductVersion @i.ProductLevel @i.ProductUpdateLevel · @i.Edition |
+ @i.AgentVersion |
+ @(i.LastSubmissionAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)) UTC |
+
+ }
+
+
+
+
+
+
+
+ Supportsager
+ @if (_customer.SupportCases.Count == 0)
+ {
+ Ingen.
+ }
+ else
+ {
+
+ | Sag | Titel | Status | Oprettet |
+
+ @foreach (var c in _customer.SupportCases.OrderByDescending(c => c.CreatedAt))
+ {
+ | @c.Reference | @c.Title | @(c.IsOpen ? "Åben" : "Lukket") | @c.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) |
+ }
+
+
+ }
+
+
+@code {
+ [Parameter] public int Id { get; set; }
+ private Customer? _customer;
+ private IReadOnlyList _findings = [];
+
+ protected override async Task OnInitializedAsync()
+ {
+ _customer = await Queries.CustomerAsync(Id);
+ _findings = _customer is null ? [] : await Queries.FindingsAsync(Id);
+ }
+}
diff --git a/src/SQLInsight.Hub/Portal/Pages/FindingDetail.razor b/src/SQLInsight.Hub/Portal/Pages/FindingDetail.razor
new file mode 100644
index 0000000..535d31f
--- /dev/null
+++ b/src/SQLInsight.Hub/Portal/Pages/FindingDetail.razor
@@ -0,0 +1,68 @@
+@page "/portal/fund/{Id:long}"
+@attribute [Authorize]
+@inject PortalQueries Queries
+
+@if (_finding is null)
+{
+ Ukendt fund
+ return;
+}
+
+@{ var customer = _finding.Instance!.Customer!; }
+@_finding.Title · SQLInsight Hub
+Kunder / @customer.Name / @_finding.Instance.InstanceKey / @_finding.RuleId
+ @_finding.Title
+
+ @(_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
+
+
+Forklaring
@_finding.Explanation
+
+
+ Målinger
+
+ @foreach (var e in Evidence)
+ {
+ | @e.Name | @e.Value |
+ }
+
+
+
+Anbefaling til kunden
@_finding.Recommendation
+
+
+ Supportsag
+ @if (_finding.SupportCase is { } linked)
+ {
+ Knyttet til @linked.Reference: @linked.Title (@(linked.IsOpen ? "åben" : "lukket"))
+
+ }
+ else
+ {
+ var open = customer.SupportCases.Where(c => c.IsOpen).ToList();
+ @if (open.Count > 0)
+ {
+
+ }
+
+ }
+
+
+@code {
+ [Parameter] public long Id { get; set; }
+ private HubFinding? _finding;
+
+ private IReadOnlyList Evidence =>
+ System.Text.Json.JsonSerializer.Deserialize>(_finding!.EvidenceJson, System.Text.Json.JsonSerializerOptions.Web) ?? [];
+
+ protected override async Task OnInitializedAsync() => _finding = await Queries.FindingAsync(Id);
+}
diff --git a/src/SQLInsight.Hub/Portal/Pages/Findings.razor b/src/SQLInsight.Hub/Portal/Pages/Findings.razor
new file mode 100644
index 0000000..2d28379
--- /dev/null
+++ b/src/SQLInsight.Hub/Portal/Pages/Findings.razor
@@ -0,0 +1,13 @@
+@page "/portal/fund"
+@attribute [Authorize]
+@inject PortalQueries Queries
+
+Fund · SQLInsight Hub
+Aktive fund på tværs af kunder
+Middel og derover, sorteret efter alvor.
+
+
+@code {
+ private IReadOnlyList _rows = [];
+ protected override async Task OnInitializedAsync() => _rows = await Queries.FindingsAsync(minSeverity: 2);
+}
diff --git a/src/SQLInsight.Hub/Portal/Pages/Licenses.razor b/src/SQLInsight.Hub/Portal/Pages/Licenses.razor
new file mode 100644
index 0000000..274d6d4
--- /dev/null
+++ b/src/SQLInsight.Hub/Portal/Pages/Licenses.razor
@@ -0,0 +1,43 @@
+@page "/portal/licenser"
+@attribute [Authorize]
+@inject PortalQueries Queries
+
+Licenser · SQLInsight Hub
+Licenser
+Licenser, som agenter har brugt. En spærret licens kan ikke længere sende data, selvom den ikke er udløbet.
+
+
+ | Kunde | Aftale | Udløber | Maks. instanser | Senest brugt | Status |
+
+ @foreach (var l in _licenses)
+ {
+
+ | @l.Customer!.Name @l.LicenseId |
+ @l.Plan |
+ @l.ExpiresAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) |
+ @(l.MaxInstances == 0 ? "ubegrænset" : l.MaxInstances) |
+ @l.LastSeenAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) UTC |
+
+ @if (l.RevokedAt is { } revoked)
+ {
+ Spærret @revoked.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) @l.RevokedReason
+ }
+ else
+ {
+
+ }
+ |
+
+ }
+
+
+
+
+@code {
+ private IReadOnlyList _licenses = [];
+ protected override async Task OnInitializedAsync() => _licenses = await Queries.LicensesAsync();
+}
diff --git a/src/SQLInsight.Hub/Portal/Pages/Login.razor b/src/SQLInsight.Hub/Portal/Pages/Login.razor
new file mode 100644
index 0000000..1ded7b6
--- /dev/null
+++ b/src/SQLInsight.Hub/Portal/Pages/Login.razor
@@ -0,0 +1,25 @@
+@page "/portal/login"
+@attribute [AllowAnonymous]
+@layout EmptyLayout
+
+Log ind · SQLInsight Hub
+
+
+
+@code {
+ [SupplyParameterFromQuery] public string? Fejl { get; set; }
+ [SupplyParameterFromQuery] public string? ReturnUrl { get; set; }
+}
diff --git a/src/SQLInsight.Hub/Portal/Pages/Overview.razor b/src/SQLInsight.Hub/Portal/Pages/Overview.razor
new file mode 100644
index 0000000..7dd6c13
--- /dev/null
+++ b/src/SQLInsight.Hub/Portal/Pages/Overview.razor
@@ -0,0 +1,69 @@
+@page "/portal"
+@attribute [Authorize]
+@inject PortalQueries Queries
+
+Overblik · SQLInsight Hub
+
+Kundeoverblik
+Betalende kunders instanser og deres aktuelle problemer.
+
+
+
+
@_customers.Sum(c => c.Instances)instanser
+
@_customers.Sum(c => c.Critical)kritiske fund
+
@_customers.Sum(c => c.High)høje fund
+
+
@_silentinstanser uden data i 1 time
+
+
+
+ Kunder
sorteret efter alvor
+
+ | Kunde | Aftale | Licens udløber | Instanser | Fund | Seneste data |
+
+ @foreach (var c in _customers)
+ {
+
+ | @c.Name |
+ @(c.Plan ?? "–") |
+ @(c.LicenseExpires?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "–") |
+ @c.Instances |
+
+ @if (c.Critical > 0) { }
+ @if (c.High > 0) { }
+ @if (c.Critical == 0 && c.High == 0) { ingen kritiske/høje }
+ |
+ @(c.LastSubmission?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? "aldrig") UTC |
+
+ }
+
+
+
+
+
+ Mest udbredte problemer
hvor en vejledning eller en produktændring hjælper flest
+
+ | Regel | Eksempel | Kunder | Fund | Værst |
+
+ @foreach (var p in _common)
+ {
+ | @p.RuleId | @p.Title | @p.Customers | @p.Findings | |
+ }
+
+
+
+
+@code {
+ private IReadOnlyList _customers = [];
+ private IReadOnlyList _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();
+ }
+}
diff --git a/src/SQLInsight.Hub/Portal/PortalApp.razor b/src/SQLInsight.Hub/Portal/PortalApp.razor
new file mode 100644
index 0000000..f968173
--- /dev/null
+++ b/src/SQLInsight.Hub/Portal/PortalApp.razor
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SQLInsight.Hub/Portal/PortalEndpoints.cs b/src/SQLInsight.Hub/Portal/PortalEndpoints.cs
new file mode 100644
index 0000000..5848872
--- /dev/null
+++ b/src/SQLInsight.Hub/Portal/PortalEndpoints.cs
@@ -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;
+
+///
+/// Form posts from the portal (login and the few actions). Pages are server-rendered Razor components.
+///
+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 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 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}");
+ }
+}
diff --git a/src/SQLInsight.Hub/Portal/PortalQueries.cs b/src/SQLInsight.Hub/Portal/PortalQueries.cs
new file mode 100644
index 0000000..38bfb61
--- /dev/null
+++ b/src/SQLInsight.Hub/Portal/PortalQueries.cs
@@ -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);
+
+/// Read models for the portal pages.
+public sealed class PortalQueries(HubDbContext db, TimeProvider time)
+{
+ public DateTime Now => time.GetUtcNow().UtcDateTime;
+
+ public async Task> 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();
+ }
+
+ /// The problems affecting the most customers: where a knowledge base article or a product fix pays off.
+ public async Task> 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 NewFindingsSinceAsync(DateTime since, CancellationToken ct = default) =>
+ db.Findings.CountAsync(f => f.FirstSeenAt >= since, ct);
+
+ /// Instances that have not reported for an hour: agent stopped, network or license problem.
+ public Task SilentInstancesAsync(CancellationToken ct = default)
+ {
+ var limit = Now.AddHours(-1);
+ return db.Instances.CountAsync(i => i.LastSubmissionAt < limit, ct);
+ }
+
+ public async Task> 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 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 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> 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> LicensesAsync(CancellationToken ct = default) =>
+ await db.Licenses.Include(l => l.Customer).OrderBy(l => l.ExpiresAt).ToListAsync(ct).ConfigureAwait(false);
+}
diff --git a/src/SQLInsight.Hub/Portal/Shared/EmptyLayout.razor b/src/SQLInsight.Hub/Portal/Shared/EmptyLayout.razor
new file mode 100644
index 0000000..4f3f76d
--- /dev/null
+++ b/src/SQLInsight.Hub/Portal/Shared/EmptyLayout.razor
@@ -0,0 +1,3 @@
+@inherits LayoutComponentBase
+
+@Body
diff --git a/src/SQLInsight.Hub/Portal/Shared/FindingTable.razor b/src/SQLInsight.Hub/Portal/Shared/FindingTable.razor
new file mode 100644
index 0000000..6abb14b
--- /dev/null
+++ b/src/SQLInsight.Hub/Portal/Shared/FindingTable.razor
@@ -0,0 +1,29 @@
+@if (Rows.Count == 0)
+{
+ Ingen aktive fund.
+}
+else
+{
+
+ | Alvor | @if (ShowCustomer) { Kunde | }Instans | Fund | Regel | Sag | Siden |
+
+ @foreach (var f in Rows)
+ {
+
+ |
+ @if (ShowCustomer) { @f.Customer | }
+ @f.Instance |
+ @f.Title |
+ @f.RuleId |
+ @(f.CaseReference ?? "–") |
+ @f.FirstSeenAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) |
+
+ }
+
+
+}
+
+@code {
+ [Parameter, EditorRequired] public IReadOnlyList Rows { get; set; } = [];
+ [Parameter] public bool ShowCustomer { get; set; } = true;
+}
diff --git a/src/SQLInsight.Hub/Portal/Shared/PortalLayout.razor b/src/SQLInsight.Hub/Portal/Shared/PortalLayout.razor
new file mode 100644
index 0000000..48b0f36
--- /dev/null
+++ b/src/SQLInsight.Hub/Portal/Shared/PortalLayout.razor
@@ -0,0 +1,25 @@
+@inherits LayoutComponentBase
+
+
+
+
+
+ Logget ind som @context.User.Identity?.Name
+
+
+
@Body
+
+
diff --git a/src/SQLInsight.Hub/Portal/Shared/RedirectToLogin.razor b/src/SQLInsight.Hub/Portal/Shared/RedirectToLogin.razor
new file mode 100644
index 0000000..27f4c8a
--- /dev/null
+++ b/src/SQLInsight.Hub/Portal/Shared/RedirectToLogin.razor
@@ -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);
+}
diff --git a/src/SQLInsight.Hub/Portal/Shared/Sev.razor b/src/SQLInsight.Hub/Portal/Shared/Sev.razor
new file mode 100644
index 0000000..6469c9b
--- /dev/null
+++ b/src/SQLInsight.Hub/Portal/Shared/Sev.razor
@@ -0,0 +1,9 @@
+@Name@(Count is { } c ? $" {c}" : "")
+
+@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" };
+}
diff --git a/src/SQLInsight.Hub/Portal/StaffAccounts.cs b/src/SQLInsight.Hub/Portal/StaffAccounts.cs
new file mode 100644
index 0000000..74efeae
--- /dev/null
+++ b/src/SQLInsight.Hub/Portal/StaffAccounts.cs
@@ -0,0 +1,55 @@
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using SQLInsight.Hub.Data;
+
+namespace SQLInsight.Hub.Portal;
+
+///
+/// Vendor staff accounts for the portal. Passwords are hashed with ASP.NET Core Identity's PBKDF2 hasher.
+///
+public sealed class StaffAccounts(HubDbContext db, TimeProvider time)
+{
+ private static readonly PasswordHasher Hasher = new();
+
+ public const int MinPasswordLength = 12;
+
+ public async Task 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;
+ }
+
+ /// Returns the user if the password is right. Takes the same time whether or not the user exists.
+ public async Task 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());
+}
diff --git a/src/SQLInsight.Hub/Portal/_Imports.razor b/src/SQLInsight.Hub/Portal/_Imports.razor
new file mode 100644
index 0000000..c96b9bd
--- /dev/null
+++ b/src/SQLInsight.Hub/Portal/_Imports.razor
@@ -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
diff --git a/src/SQLInsight.Hub/Program.cs b/src/SQLInsight.Hub/Program.cs
index 670f182..0a67196 100644
--- a/src/SQLInsight.Hub/Program.cs
+++ b/src/SQLInsight.Hub/Program.cs
@@ -1,7 +1,10 @@
+using System.Threading.RateLimiting;
+using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.EntityFrameworkCore;
using SQLInsight.Hub;
using SQLInsight.Hub.Api;
using SQLInsight.Hub.Data;
+using SQLInsight.Hub.Portal;
namespace SQLInsight.Hub;
@@ -12,6 +15,12 @@ public static class HubProgram
{
public static async Task Main(string[] args)
{
+ if (args.FirstOrDefault() == "add-staff")
+ {
+ await AddStaffAsync(args).ConfigureAwait(false);
+ return;
+ }
+
var app = Build(args);
await MigrateAsync(app.Services).ConfigureAwait(false);
await app.RunAsync().ConfigureAwait(false);
@@ -29,12 +38,69 @@ public static class HubProgram
?? throw new InvalidOperationException("ConnectionStrings:Hub mangler (PostgreSQL).");
builder.Services.AddDbContext(o => o.UseNpgsql(connectionString).UseSnakeCaseNamingConvention());
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ 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()?.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();
+ 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" }));
FindingsEndpoint.Map(app);
+ PortalEndpoints.Map(app);
+ app.MapRazorComponents();
return app;
}
+ /// SQLInsight.Hub add-staff <brugernavn> <navn>, adgangskode fra SQLINSIGHT_STAFF_PASSWORD eller stdin.
+ 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().CreateAsync(args[1], args[2], password).ConfigureAwait(false);
+ Console.WriteLine($"Oprettet: {user.UserName}");
+ }
+
public static async Task MigrateAsync(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
diff --git a/src/SQLInsight.Hub/wwwroot/app.css b/src/SQLInsight.Hub/wwwroot/app.css
new file mode 100644
index 0000000..a80bbe1
--- /dev/null
+++ b/src/SQLInsight.Hub/wwwroot/app.css
@@ -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; }
diff --git a/src/SQLInsight.Hub/wwwroot/favicon.svg b/src/SQLInsight.Hub/wwwroot/favicon.svg
new file mode 100644
index 0000000..54199d9
--- /dev/null
+++ b/src/SQLInsight.Hub/wwwroot/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/src/SQLInsight.Hub/wwwroot/fonts/Inter-OFL.txt b/src/SQLInsight.Hub/wwwroot/fonts/Inter-OFL.txt
new file mode 100644
index 0000000..40589da
--- /dev/null
+++ b/src/SQLInsight.Hub/wwwroot/fonts/Inter-OFL.txt
@@ -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.
diff --git a/src/SQLInsight.Hub/wwwroot/fonts/inter-latin-ext-wght-normal.woff2 b/src/SQLInsight.Hub/wwwroot/fonts/inter-latin-ext-wght-normal.woff2
new file mode 100644
index 0000000..479d010
Binary files /dev/null and b/src/SQLInsight.Hub/wwwroot/fonts/inter-latin-ext-wght-normal.woff2 differ
diff --git a/src/SQLInsight.Hub/wwwroot/fonts/inter-latin-wght-normal.woff2 b/src/SQLInsight.Hub/wwwroot/fonts/inter-latin-wght-normal.woff2
new file mode 100644
index 0000000..d15208d
Binary files /dev/null and b/src/SQLInsight.Hub/wwwroot/fonts/inter-latin-wght-normal.woff2 differ
diff --git a/src/SQLInsight.Hub/wwwroot/theme.js b/src/SQLInsight.Hub/wwwroot/theme.js
new file mode 100644
index 0000000..eb022f8
--- /dev/null
+++ b/src/SQLInsight.Hub/wwwroot/theme.js
@@ -0,0 +1,3 @@
+try {
+ if (localStorage.getItem('sqlinsight-theme') === 'dark') document.documentElement.dataset.theme = 'dark';
+} catch { /* storage blocked: stay light */ }
diff --git a/tests/SQLInsight.IntegrationTests/HubApiTests.cs b/tests/SQLInsight.IntegrationTests/HubApiTests.cs
index 44804ab..afd1412 100644
--- a/tests/SQLInsight.IntegrationTests/HubApiTests.cs
+++ b/tests/SQLInsight.IntegrationTests/HubApiTests.cs
@@ -12,7 +12,7 @@ namespace SQLInsight.IntegrationTests;
[Trait("Category", "Integration")]
public class HubApiTests(HubFixture hub) : IClassFixture
{
- 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(),
AgentVersion = "1.0.0",
diff --git a/tests/SQLInsight.IntegrationTests/HubFixture.cs b/tests/SQLInsight.IntegrationTests/HubFixture.cs
index 20eb164..058d7c8 100644
--- a/tests/SQLInsight.IntegrationTests/HubFixture.cs
+++ b/tests/SQLInsight.IntegrationTests/HubFixture.cs
@@ -31,6 +31,7 @@ public sealed class HubFixture : IAsyncLifetime
b.UseEnvironment("Development");
b.UseSetting("ConnectionStrings:Hub", ConnectionString);
b.UseSetting($"SQLInsight:Hub:TrustedKeys:{KeyId}", PublicKey);
+ b.UseSetting("SQLInsight:Hub:LoginAttemptsPerMinute", "1000");
});
await HubProgram.MigrateAsync(Factory.Services);
}
diff --git a/tests/SQLInsight.IntegrationTests/HubPortalTests.cs b/tests/SQLInsight.IntegrationTests/HubPortalTests.cs
new file mode 100644
index 0000000..a0fc858
--- /dev/null
+++ b/tests/SQLInsight.IntegrationTests/HubPortalTests.cs
@@ -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
+{
+ private const string Password = "korrekt-hest-batteri";
+
+ [GeneratedRegex("name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"")]
+ private static partial Regex AntiforgeryField();
+
+ private static HttpClient Client(WebApplicationFactory factory) =>
+ factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
+
+ private async Task StaffAsync()
+ {
+ var userName = $"staff{Guid.NewGuid():N}"[..20];
+ await using var scope = hub.Factory.Services.CreateAsyncScope();
+ await scope.ServiceProvider.GetRequiredService().CreateAsync(userName, "Support Person", Password);
+ return userName;
+ }
+
+ private static async Task TokenAsync(HttpClient client, string page)
+ {
+ var html = await client.GetStringAsync(page);
+ return WebUtility.HtmlDecode(AntiforgeryField().Match(html).Groups[1].Value);
+ }
+
+ private static async Task PostFormAsync(HttpClient client, string page, string action, params (string Key, string Value)[] fields)
+ {
+ var form = fields.Select(f => new KeyValuePair(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 LoginAsync(HttpClient client, string userName, string password, string returnUrl = "") =>
+ PostFormAsync(client, "/portal/login", "/portal/login", ("userName", userName), ("password", password), ("returnUrl", returnUrl));
+
+ private async Task 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 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();
+ 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();
+ 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();
+ 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().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().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(() => scope.ServiceProvider.GetRequiredService().CreateAsync("kort", "Kort", "123"));
+ }
+}