Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,106 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Web.Filters;
/// <summary>
/// Action filter that injects active, non-dismissed platform announcements into
/// <c>ViewBag.ActiveAnnouncements</c> before every authenticated page render.
/// The shared layout (<c>_Layout.cshtml</c>) reads this bag property to render
/// dismissible alert banners at the top of the page.
/// <para>
/// Design decisions:
/// <list type="bullet">
/// <item>
/// The filter queries <see cref="ApplicationDbContext"/> directly (rather than
/// via <c>IUnitOfWork</c>) because the announcement and dismissal tables are
/// platform-level — they do not go through the tenant-scoped unit of work.
/// </item>
/// <item>
/// Announcements are filtered in SQL (not in memory) to avoid loading the
/// full announcement set on every request. The <c>StartsAt</c>/<c>ExpiresAt</c>
/// window and the <c>Target</c>/<c>TargetCompanyId</c>/<c>TargetPlan</c>
/// predicates are all pushed down to the database.
/// </item>
/// <item>
/// Dismissed announcement IDs are loaded in a second query (rather than a
/// JOIN) to keep the EF query straightforward and avoid a potential
/// Cartesian explosion when a user has many dismissals.
/// </item>
/// </list>
/// </para>
/// </summary>
public class AnnouncementFilter : IAsyncActionFilter
{
private readonly ApplicationDbContext _db;
private readonly IHttpContextAccessor _httpContextAccessor;
/// <summary>
/// Initialises the filter with the database context and HTTP context accessor.
/// </summary>
public AnnouncementFilter(ApplicationDbContext db, IHttpContextAccessor httpContextAccessor)
{
_db = db;
_httpContextAccessor = httpContextAccessor;
}
/// <summary>
/// Queries active announcements targeting the current user's company and
/// subscription plan, removes any that the user has already dismissed, and
/// stores the remainder in <c>ViewBag.ActiveAnnouncements</c> before
/// invoking the next action delegate.
/// <para>
/// The filter is a no-op for unauthenticated users and for non-controller
/// action contexts (e.g. page filters, view components) — the
/// <c>context.Controller is Controller</c> guard ensures we only set
/// ViewBag when we have an MVC controller that will render a view.
/// </para>
/// </summary>
/// <param name="context">The action-executing context for the current request.</param>
/// <param name="next">The delegate to invoke to execute the action.</param>
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
// Only inject for authenticated users making page requests
if (context.Controller is Controller controller &&
context.HttpContext.User.Identity?.IsAuthenticated == true)
{
var userId = context.HttpContext.User.FindFirst(
System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
var companyIdStr = context.HttpContext.User.FindFirst("CompanyId")?.Value;
int.TryParse(companyIdStr, out var companyId);
// Plan comes from middleware-injected Context.Items (set per-request from Company record)
var plan = context.HttpContext.Items["SubscriptionPlan"] as int? ?? 0;
var now = DateTime.UtcNow;
// Load active announcements targeting this user's company/plan
var announcements = await _db.Announcements
.Where(a => a.IsActive
&& a.StartsAt <= now
&& (a.ExpiresAt == null || a.ExpiresAt > now)
&& (a.Target == "All"
|| (a.Target == "Plan" && a.TargetPlan == plan)
|| (a.Target == "Company" && a.TargetCompanyId == companyId)))
.ToListAsync();
if (announcements.Count > 0 && userId != null)
{
// Filter out dismissed ones
var dismissedIds = await _db.AnnouncementDismissals
.Where(d => d.UserId == userId && announcements.Select(a => a.Id).Contains(d.AnnouncementId))
.Select(d => d.AnnouncementId)
.ToListAsync();
announcements = announcements.Where(a => !dismissedIds.Contains(a.Id)).ToList();
}
controller.ViewBag.ActiveAnnouncements = announcements;
}
await next();
}
}
@@ -0,0 +1,131 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using PowderCoating.Core.Entities;
namespace PowderCoating.Web.Filters;
/// <summary>
/// Redirects SuperAdmin users to the 2FA setup page if they have not yet
/// configured two-factor authentication. Exempt routes are allowed through.
/// Disabled entirely when IHostEnvironment.IsProduction() is false, or when
/// AppSettings:Disable2FAEnforcement is true in appsettings (dev/staging only).
/// </summary>
public class EnforceSuperAdmin2FAFilter : IAsyncActionFilter
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly IHostEnvironment _env;
private readonly IConfiguration _config;
/// <summary>
/// Controllers and areas excluded from 2FA enforcement.
/// <c>TwoFactorSetup</c> must be exempt so users can reach the setup page
/// itself. <c>Home</c> is exempt to avoid an infinite redirect if the
/// home page is the post-login landing. The entire <c>Identity</c> area
/// is exempt so that login, logout, and 2FA challenge flows remain reachable.
/// <see cref="HashSet{T}"/> with <see cref="StringComparer.OrdinalIgnoreCase"/>
/// is used for O(1) lookup with case-insensitive matching (route values are
/// not guaranteed to be consistently cased).
/// </summary>
private static readonly HashSet<string> ExemptControllers = new(StringComparer.OrdinalIgnoreCase)
{
"TwoFactorSetup", "Home"
};
/// <summary>Areas excluded from 2FA enforcement (currently only <c>Identity</c>).</summary>
private static readonly HashSet<string> ExemptAreas = new(StringComparer.OrdinalIgnoreCase)
{
"Identity"
};
/// <summary>
/// Initialises the filter with its required dependencies.
/// </summary>
/// <param name="userManager">Used to load the ASP.NET Identity user record and
/// check whether two-factor authentication is enabled.</param>
/// <param name="env">Hosting environment; enforcement is skipped in Development.</param>
/// <param name="config">Application configuration; allows disabling enforcement
/// via <c>AppSettings:Disable2FAEnforcement</c> for staging environments.</param>
public EnforceSuperAdmin2FAFilter(
UserManager<ApplicationUser> userManager,
IHostEnvironment env,
IConfiguration config)
{
_userManager = userManager;
_env = env;
_config = config;
}
/// <summary>
/// Checks whether the current SuperAdmin user has 2FA configured and, if not,
/// redirects them to the setup page before the action executes.
/// <para>
/// AJAX/fetch requests (identified by the <c>Sec-Fetch-Mode</c> header being
/// present and not equal to <c>navigate</c>) receive a <c>401 Unauthorized</c>
/// response instead of a redirect. This is intentional: a redirect followed
/// by a fetch call would silently hit the 2FA setup GET endpoint, which calls
/// <c>ResetAuthenticatorKeyAsync</c> and would invalidate any in-progress
/// TOTP setup the user is performing in another tab.
/// </para>
/// </summary>
/// <param name="context">The action-executing context for the current request.</param>
/// <param name="next">The delegate to invoke to proceed with the action.</param>
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
// Skip enforcement in Development only, or when explicitly disabled via config.
// Staging and Production both enforce 2FA.
if (_env.IsDevelopment() ||
_config.GetValue<bool>("AppSettings:Disable2FAEnforcement"))
{
await next();
return;
}
var user = context.HttpContext.User;
// Only enforce for authenticated SuperAdmins
if (!user.Identity?.IsAuthenticated ?? true)
{
await next();
return;
}
if (!user.IsInRole("SuperAdmin"))
{
await next();
return;
}
// Skip exempt areas/controllers
var routeData = context.RouteData;
var area = routeData.Values["area"]?.ToString() ?? string.Empty;
var controller = routeData.Values["controller"]?.ToString() ?? string.Empty;
if (ExemptAreas.Contains(area) || ExemptControllers.Contains(controller))
{
await next();
return;
}
// Check if 2FA is enabled
var appUser = await _userManager.GetUserAsync(user);
if (appUser != null && !appUser.TwoFactorEnabled)
{
// For AJAX/fetch requests (e.g. notification polling, SignalR negotiate),
// return 401 instead of redirecting — fetch follows redirects and would
// silently call Setup GET, resetting the authenticator key mid-setup.
var fetchMode = context.HttpContext.Request.Headers["Sec-Fetch-Mode"].ToString();
var isNonNavigation = !string.IsNullOrEmpty(fetchMode) && fetchMode != "navigate";
if (isNonNavigation)
{
context.Result = new UnauthorizedResult();
return;
}
context.Result = new RedirectToActionResult("Setup", "TwoFactorSetup", null);
return;
}
await next();
}
}
@@ -0,0 +1,73 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Web.Filters;
/// <summary>
/// Action filter that injects platform-level feature flags and the current tenant's display
/// timezone into ViewBag before every controller action renders. Views read these to
/// conditionally show features and convert UTC timestamps to local time.
/// </summary>
public class PlatformFeaturesFilter : IAsyncActionFilter
{
private readonly IPlatformSettingsService _settings;
private readonly ITenantContext _tenantContext;
private readonly ApplicationDbContext _db;
private readonly UserManager<ApplicationUser> _userManager;
/// <summary>Initialises the filter.</summary>
public PlatformFeaturesFilter(
IPlatformSettingsService settings,
ITenantContext tenantContext,
ApplicationDbContext db,
UserManager<ApplicationUser> userManager)
{
_settings = settings;
_tenantContext = tenantContext;
_db = db;
_userManager = userManager;
}
/// <summary>
/// Reads platform feature flags and resolves the display timezone, storing both in ViewBag.
/// Timezone resolution order: company timezone → user profile timezone → null (UTC).
/// </summary>
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (context.Controller is Controller controller)
{
var rawSms = await _settings.GetAsync(PlatformSettingKeys.SmsEnabled);
controller.ViewBag.SmsEnabled =
string.Equals(rawSms, "true", StringComparison.OrdinalIgnoreCase);
string? displayTz = null;
// Company users: use their company's configured timezone
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId is > 1)
{
displayTz = await _db.Companies
.Where(c => c.Id == companyId.Value)
.Select(c => c.TimeZone)
.FirstOrDefaultAsync();
}
// SuperAdmins or no company timezone set: fall back to the user's personal timezone
if (string.IsNullOrWhiteSpace(displayTz))
{
var appUser = await _userManager.GetUserAsync(context.HttpContext.User);
displayTz = appUser?.TimeZone;
}
controller.ViewBag.CompanyTimeZone = displayTz;
}
await next();
}
}