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
121 lines
5 KiB
C#
121 lines
5 KiB
C#
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}");
|
|
}
|
|
}
|