- POST/GET /api/v1/support-cases[/{id}] and POST .../{id}/close in
SQLInsight Web, sharing the /support form's logic: same checks, finding
prefill without sensitive evidence, Idempotency-Key as the request id,
202 Accepted with Location, problem+json 403 without the support:write
scope or a support license. RequestedBy is the Windows user or
token:<name>.
- Closing: "Luk sag" on /support and the API record CloseRequested
(migration 0012, column-level UPDATE for the read-only web login); the
agent sends it to the hub with backoff and sets Closed when confirmed;
a pending case is closed once it has a number; an older hub list never
turns CloseRequested back to Open.
- Hub: POST /api/v1/cases/{reference}/close for the license's own cases
(others 404), idempotent, ClosedBy/ClosedComment (EF migration
CaseClosedBy). Portal closing records "SEOSoft: <user>"; the customer
page shows who closed and why; the support report and CSV get "Lukket af".
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
165 lines
7.1 KiB
C#
165 lines
7.1 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using SQLInsight.Contracts;
|
|
using SQLInsight.Hub.Data;
|
|
using SQLInsight.Hub.Support;
|
|
|
|
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("/customers/{id:int}/cases", CreateCaseAsync);
|
|
portal.MapGet("/reports/support.csv", SupportReportCsvAsync);
|
|
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, HttpContext http, HubDbContext db, TimeProvider time, CancellationToken ct) =>
|
|
{
|
|
var supportCase = await db.SupportCases.FindAsync([id], ct);
|
|
if (supportCase is null)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
if (supportCase.IsOpen)
|
|
{
|
|
supportCase.IsOpen = false;
|
|
supportCase.ClosedAt = time.GetUtcNow().UtcDateTime;
|
|
supportCase.ClosedBy = "SEOSoft: " + (http.User.Identity?.Name ?? "ukendt");
|
|
}
|
|
|
|
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
|
|
{
|
|
// An empty case number gets the customer's next SUP-number.
|
|
var (supportCase, error) = await SupportCaseStore.AddAsync(db, new SupportCase
|
|
{
|
|
CustomerId = finding.Instance!.CustomerId,
|
|
Reference = reference ?? string.Empty,
|
|
Title = string.IsNullOrWhiteSpace(title) ? finding.Title : title.Trim(),
|
|
CreatedAt = time.GetUtcNow().UtcDateTime,
|
|
}, ct);
|
|
if (supportCase is null)
|
|
{
|
|
return Results.BadRequest(error);
|
|
}
|
|
|
|
finding.SupportCaseId = supportCase.Id;
|
|
}
|
|
|
|
await db.SaveChangesAsync(ct);
|
|
return Results.Redirect($"/portal/fund/{id}");
|
|
}
|
|
|
|
private static async Task<IResult> CreateCaseAsync(int id, [FromForm] string? reference, [FromForm] string title, [FromForm] string? description,
|
|
HubDbContext db, TimeProvider time, CancellationToken ct)
|
|
{
|
|
if (!await db.Customers.AnyAsync(c => c.Id == id, ct))
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(title) || title.Length > SupportCaseRequest.MaxTitle)
|
|
{
|
|
return Results.BadRequest($"Titel skal udfyldes (højst {SupportCaseRequest.MaxTitle} tegn).");
|
|
}
|
|
|
|
var (_, error) = await SupportCaseStore.AddAsync(db, new SupportCase
|
|
{
|
|
CustomerId = id,
|
|
Reference = reference ?? string.Empty,
|
|
Title = title.Trim(),
|
|
Description = string.IsNullOrWhiteSpace(description) ? null : description.Trim()[..Math.Min(description.Trim().Length, SupportCaseRequest.MaxDescription)],
|
|
CreatedAt = time.GetUtcNow().UtcDateTime,
|
|
}, ct);
|
|
return error is null ? Results.Redirect($"/portal/kunder/{id}") : Results.BadRequest(error);
|
|
}
|
|
|
|
private static async Task<IResult> SupportReportCsvAsync(DateTime? fra, DateTime? til, int? kunde, PortalQueries queries, CancellationToken ct)
|
|
{
|
|
var (from, to) = SupportReportPeriod.Resolve(fra, til, queries.Now);
|
|
var report = await queries.SupportReportAsync(from, to, kunde, ct);
|
|
return Results.File(SupportReport.Csv(report), "text/csv; charset=utf-8", $"supportsager-{from:yyyy-MM-dd}-{to.AddDays(-1):yyyy-MM-dd}.csv");
|
|
}
|
|
}
|