Initial commit
This commit is contained in:
@@ -0,0 +1,884 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.Registration;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Handles new tenant company sign-up via two paths: a free-trial path (immediate account creation)
|
||||
/// and a paid path (Stripe Checkout session → payment → account creation on return). A
|
||||
/// <c>PendingRegistrationSession</c> persisted to the database bridges the Stripe redirect gap so
|
||||
/// registration data survives even if the user's browser session is lost between leaving for
|
||||
/// Stripe and returning.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Registration)]
|
||||
public class RegistrationController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||
private readonly ISeedDataService _seedDataService;
|
||||
private readonly IAdminNotificationService _adminNotification;
|
||||
private readonly IInAppNotificationService _inApp;
|
||||
private readonly IPlatformSettingsService _platformSettings;
|
||||
private readonly IStripeService _stripeService;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly ILogger<RegistrationController> _logger;
|
||||
|
||||
public RegistrationController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ApplicationDbContext db,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
SignInManager<ApplicationUser> signInManager,
|
||||
ISeedDataService seedDataService,
|
||||
IAdminNotificationService adminNotification,
|
||||
IInAppNotificationService inApp,
|
||||
IPlatformSettingsService platformSettings,
|
||||
IStripeService stripeService,
|
||||
IEmailService emailService,
|
||||
ILogger<RegistrationController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_db = db;
|
||||
_userManager = userManager;
|
||||
_signInManager = signInManager;
|
||||
_seedDataService = seedDataService;
|
||||
_adminNotification = adminNotification;
|
||||
_inApp = inApp;
|
||||
_platformSettings = platformSettings;
|
||||
_stripeService = stripeService;
|
||||
_emailService = emailService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the public registration page. Redirects already-authenticated users to the dashboard.
|
||||
/// Reads platform settings at render time to gate whether registration is open (max-tenant cap)
|
||||
/// and whether trials are enabled (controls which CTA the view shows). Re-populates the form
|
||||
/// from <c>TempData["PendingRegistrationJson"]</c> if the user is returning after cancelling a
|
||||
/// Stripe Checkout session so they don't have to retype their details.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
if (User.Identity?.IsAuthenticated == true)
|
||||
return RedirectToAction("Index", "Dashboard");
|
||||
|
||||
ViewBag.RegistrationOpen = await IsRegistrationOpenAsync();
|
||||
await PopulateRegistrationViewBagAsync();
|
||||
|
||||
var defaultPlan = (ViewBag.PlanConfigs as List<SubscriptionPlanConfig>)
|
||||
?.OrderBy(c => c.SortOrder).FirstOrDefault()?.Plan ?? 0;
|
||||
|
||||
// Re-populate form if returning from a cancelled payment
|
||||
var prefillJson = TempData["PendingRegistrationJson"] as string;
|
||||
var model = prefillJson != null
|
||||
? JsonSerializer.Deserialize<RegisterCompanyDto>(prefillJson) ?? new RegisterCompanyDto { Plan = defaultPlan }
|
||||
: new RegisterCompanyDto { Plan = defaultPlan };
|
||||
|
||||
return View(model);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes the registration form submission. Validates the model, checks that registration is
|
||||
/// still open (in case the tenant cap was hit after the page was loaded), and verifies the email
|
||||
/// is not already in use. Then branches to <see cref="CreateWithTrialAsync"/> (trials enabled) or
|
||||
/// <see cref="RedirectToStripeCheckoutAsync"/> (trials disabled / credit card required).
|
||||
/// Duplicate email is checked here rather than relying on a DB unique constraint so we can show
|
||||
/// a friendly field-level error message instead of a 500.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(RegisterCompanyDto model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
ViewBag.RegistrationOpen = await IsRegistrationOpenAsync();
|
||||
await PopulateRegistrationViewBagAsync();
|
||||
return View("Index", model);
|
||||
}
|
||||
|
||||
if (!await IsRegistrationOpenAsync())
|
||||
{
|
||||
TempData["Error"] = "Registration is currently closed. Please contact us for more information.";
|
||||
ViewBag.RegistrationOpen = false;
|
||||
await PopulateRegistrationViewBagAsync();
|
||||
return View("Index", model);
|
||||
}
|
||||
|
||||
var existing = await _userManager.FindByEmailAsync(model.Email);
|
||||
if (existing != null)
|
||||
{
|
||||
ModelState.AddModelError("Email", "An account with this email address already exists.");
|
||||
ViewBag.RegistrationOpen = true;
|
||||
await PopulateRegistrationViewBagAsync();
|
||||
return View("Index", model);
|
||||
}
|
||||
|
||||
var trialsEnabled = await IsTrialsEnabledAsync();
|
||||
|
||||
if (trialsEnabled)
|
||||
{
|
||||
return await CreateWithTrialAsync(model);
|
||||
}
|
||||
else
|
||||
{
|
||||
return await RedirectToStripeCheckoutAsync(model);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Trial path ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates the company and user immediately (no payment required). The trial length is read from
|
||||
/// the <c>TrialPeriodDays</c> platform setting (default 7) so SuperAdmins can adjust it without
|
||||
/// a deploy. A temporary password is generated and emailed; the user is flagged with
|
||||
/// <c>MustChangePassword</c> claim so the first login redirects to the change-password flow.
|
||||
/// Company and user creation are NOT in a DB transaction — if user creation fails, the company
|
||||
/// row is manually deleted to avoid orphaned tenant records.
|
||||
/// </summary>
|
||||
private async Task<IActionResult> CreateWithTrialAsync(RegisterCompanyDto model)
|
||||
{
|
||||
var rawTrialDays = await _platformSettings.GetAsync(PlatformSettingKeys.TrialPeriodDays);
|
||||
var trialPeriodDays = int.TryParse(rawTrialDays, out var td) ? td : 7;
|
||||
|
||||
var companyCode = await GenerateUniqueCompanyCodeAsync(model.CompanyName);
|
||||
var company = new Company
|
||||
{
|
||||
CompanyName = model.CompanyName,
|
||||
CompanyCode = companyCode,
|
||||
Phone = model.CompanyPhone,
|
||||
PrimaryContactEmail = model.Email,
|
||||
PrimaryContactName = $"{model.FirstName} {model.LastName}",
|
||||
SubscriptionPlan = model.Plan,
|
||||
SubscriptionStatus = SubscriptionStatus.Active,
|
||||
SubscriptionStartDate = DateTime.UtcNow,
|
||||
SubscriptionEndDate = DateTime.UtcNow.AddDays(trialPeriodDays),
|
||||
IsAnnualBilling = model.IsAnnual,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _unitOfWork.Companies.AddAsync(company);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var tempPassword = GenerateTemporaryPassword();
|
||||
var user = BuildUser(model.Email, model.FirstName, model.LastName, company.Id);
|
||||
|
||||
var createResult = await _userManager.CreateAsync(user, tempPassword);
|
||||
if (!createResult.Succeeded)
|
||||
{
|
||||
await _unitOfWork.Companies.DeleteAsync(company);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
foreach (var error in createResult.Errors)
|
||||
ModelState.AddModelError(string.Empty, error.Description);
|
||||
|
||||
await PopulateRegistrationViewBagAsync();
|
||||
return View("Index", model);
|
||||
}
|
||||
|
||||
await _userManager.AddClaimAsync(user, new Claim("MustChangePassword", "true"));
|
||||
await FinalizeRegistrationAsync(user, company, model.Plan);
|
||||
_ = SendWelcomeEmailAsync(model.Email, model.FirstName, tempPassword, model.Plan,
|
||||
DateTime.UtcNow.AddDays(trialPeriodDays), $"{Request.Scheme}://{Request.Host}");
|
||||
|
||||
return RedirectToAction(nameof(Welcome));
|
||||
}
|
||||
|
||||
// ─── Credit-card-required path ────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Starts the paid-path registration by creating a Stripe Checkout session and redirecting the
|
||||
/// user to Stripe's hosted payment page. A URL-safe GUID token is generated and embedded in both
|
||||
/// the success and cancel return URLs. The <c>PendingRegistrationSession</c> is persisted to the
|
||||
/// database AFTER Stripe confirms the session is valid — this prevents orphaned DB records when
|
||||
/// Stripe rejects the request (e.g. invalid plan price ID). Account creation does not happen
|
||||
/// here; it happens in <see cref="PaymentSuccess"/> after Stripe redirects back with the
|
||||
/// Checkout session ID.
|
||||
/// </summary>
|
||||
private async Task<IActionResult> RedirectToStripeCheckoutAsync(RegisterCompanyDto model)
|
||||
{
|
||||
// Generate a token that travels in the URL — no session cookie needed to survive the Stripe redirect.
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
|
||||
var successUrl = Url.Action(nameof(PaymentSuccess), "Registration", new { reg_token = token }, Request.Scheme)!;
|
||||
var cancelUrl = Url.Action(nameof(PaymentCancelled), "Registration", new { reg_token = token }, Request.Scheme)!;
|
||||
|
||||
try
|
||||
{
|
||||
var checkoutUrl = await _stripeService.CreateRegistrationCheckoutSessionAsync(
|
||||
model.Plan, model.IsAnnual, model.Email, model.CompanyName, successUrl, cancelUrl);
|
||||
|
||||
// Persist pending data AFTER confirming Stripe accepted the session.
|
||||
_db.PendingRegistrationSessions.Add(new PendingRegistrationSession
|
||||
{
|
||||
Token = token,
|
||||
CompanyName = model.CompanyName,
|
||||
CompanyPhone = model.CompanyPhone,
|
||||
FirstName = model.FirstName,
|
||||
LastName = model.LastName,
|
||||
Email = model.Email,
|
||||
Plan = model.Plan,
|
||||
IsAnnual = model.IsAnnual,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return Redirect(checkoutUrl);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Stripe config error during registration checkout for plan {Plan}", model.Plan);
|
||||
ModelState.AddModelError(string.Empty, ex.Message);
|
||||
await PopulateRegistrationViewBagAsync();
|
||||
return View("Index", model);
|
||||
}
|
||||
catch (Stripe.StripeException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Stripe API error during registration checkout");
|
||||
ModelState.AddModelError(string.Empty,
|
||||
"A payment processor error occurred. Please try again or contact support.");
|
||||
await PopulateRegistrationViewBagAsync();
|
||||
return View("Index", model);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stripe return URL handler called after the customer completes payment in Stripe Checkout.
|
||||
/// Looks up the <c>PendingRegistrationSession</c> by the opaque <c>reg_token</c> to recover the
|
||||
/// registration data that could not be stored in a session cookie (which may not survive the
|
||||
/// Stripe redirect on some browsers/devices). Marks the session as completed before creating the
|
||||
/// account to prevent duplicate submissions if the user hits reload. Contains a second open-cap
|
||||
/// check in case the tenant limit was reached between the user starting and completing checkout.
|
||||
/// Calls <c>FulfillRegistrationCheckoutAsync</c> to link the Stripe subscription (sets the real
|
||||
/// <c>SubscriptionEndDate</c>); failure is non-fatal — dates can be corrected manually.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> PaymentSuccess(string? session_id, string? reg_token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(session_id))
|
||||
return RedirectToAction(nameof(Index));
|
||||
|
||||
if (string.IsNullOrEmpty(reg_token))
|
||||
{
|
||||
TempData["Error"] = "Your registration session has expired. Please fill in your details again.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
var pendingSession = await _db.PendingRegistrationSessions
|
||||
.FirstOrDefaultAsync(p => p.Token == reg_token && !p.IsCompleted);
|
||||
|
||||
if (pendingSession == null)
|
||||
{
|
||||
TempData["Error"] = "Your registration session was not found or has already been completed. Please fill in your details again.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// Map DB record to the internal record used below
|
||||
var pending = new PendingRegistration(
|
||||
pendingSession.CompanyName, pendingSession.CompanyPhone,
|
||||
pendingSession.FirstName, pendingSession.LastName,
|
||||
pendingSession.Email, pendingSession.Plan, pendingSession.IsAnnual);
|
||||
|
||||
// Mark completed to prevent duplicate submissions
|
||||
pendingSession.IsCompleted = true;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Guard against race condition: re-check capacity after Stripe redirect
|
||||
if (!await IsRegistrationOpenAsync())
|
||||
{
|
||||
TempData["Error"] = "Registration is currently closed. Your payment has been received but no account was created. Please contact support.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// Guard against race condition (duplicate submission)
|
||||
if (await _userManager.FindByEmailAsync(pending.Email) != null)
|
||||
{
|
||||
// Account already exists — just sign them in and go to dashboard
|
||||
var existingUser = await _userManager.FindByEmailAsync(pending.Email);
|
||||
if (existingUser != null)
|
||||
{
|
||||
existingUser.LastLoginDate = DateTime.UtcNow;
|
||||
await _userManager.UpdateAsync(existingUser);
|
||||
await _signInManager.SignInAsync(existingUser, isPersistent: false);
|
||||
}
|
||||
return RedirectToAction("Index", "Dashboard");
|
||||
}
|
||||
|
||||
var companyCode = await GenerateUniqueCompanyCodeAsync(pending.CompanyName);
|
||||
var company = new Company
|
||||
{
|
||||
CompanyName = pending.CompanyName,
|
||||
CompanyCode = companyCode,
|
||||
Phone = pending.CompanyPhone,
|
||||
PrimaryContactEmail = pending.Email,
|
||||
PrimaryContactName = $"{pending.FirstName} {pending.LastName}",
|
||||
SubscriptionPlan = pending.Plan,
|
||||
SubscriptionStatus = SubscriptionStatus.Active,
|
||||
SubscriptionStartDate = DateTime.UtcNow,
|
||||
SubscriptionEndDate = DateTime.UtcNow.AddDays(1), // Stripe fulfillment sets real date below
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _unitOfWork.Companies.AddAsync(company);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var tempPassword = GenerateTemporaryPassword();
|
||||
var user = BuildUser(pending.Email, pending.FirstName, pending.LastName, company.Id);
|
||||
|
||||
var createResult = await _userManager.CreateAsync(user, tempPassword);
|
||||
if (!createResult.Succeeded)
|
||||
{
|
||||
await _unitOfWork.Companies.DeleteAsync(company);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogError("Failed to create user after payment for {Email}: {Errors}",
|
||||
pending.Email, string.Join(", ", createResult.Errors.Select(e => e.Description)));
|
||||
|
||||
TempData["Error"] = "Your payment was received but we encountered an error creating your account. " +
|
||||
"Please contact support with reference: " + session_id;
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// Link the Stripe subscription (sets real SubscriptionEndDate)
|
||||
try
|
||||
{
|
||||
await _stripeService.FulfillRegistrationCheckoutAsync(session_id, company.Id, pending.Plan);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fulfill registration checkout {SessionId} for company {CompanyId}",
|
||||
session_id, company.Id);
|
||||
// Non-fatal — subscription dates can be synced manually later
|
||||
}
|
||||
|
||||
await _userManager.AddClaimAsync(user, new Claim("MustChangePassword", "true"));
|
||||
await FinalizeRegistrationAsync(user, company, pending.Plan);
|
||||
_ = SendWelcomeEmailAsync(pending.Email, pending.FirstName, tempPassword, pending.Plan,
|
||||
null, $"{Request.Scheme}://{Request.Host}");
|
||||
|
||||
return RedirectToAction(nameof(Welcome));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stripe cancel URL handler called when the user clicks "Back" or otherwise abandons the Stripe
|
||||
/// Checkout page. Recovers the pending registration data from the DB and serialises it into
|
||||
/// <c>TempData</c> so the registration form is pre-populated when <see cref="Index"/> renders,
|
||||
/// then removes the <c>PendingRegistrationSession</c> record — a new one is created if the user
|
||||
/// tries again. No account is created at this point; the user is shown a friendly error message.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> PaymentCancelled(string? reg_token)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(reg_token))
|
||||
{
|
||||
var pendingSession = await _db.PendingRegistrationSessions
|
||||
.FirstOrDefaultAsync(p => p.Token == reg_token && !p.IsCompleted);
|
||||
|
||||
if (pendingSession != null)
|
||||
{
|
||||
// Pre-populate the form so the user doesn't have to retype everything
|
||||
TempData["PendingRegistrationJson"] = JsonSerializer.Serialize(new RegisterCompanyDto
|
||||
{
|
||||
CompanyName = pendingSession.CompanyName,
|
||||
CompanyPhone = pendingSession.CompanyPhone,
|
||||
FirstName = pendingSession.FirstName,
|
||||
LastName = pendingSession.LastName,
|
||||
Email = pendingSession.Email,
|
||||
Plan = pendingSession.Plan,
|
||||
IsAnnual = pendingSession.IsAnnual
|
||||
});
|
||||
|
||||
_db.PendingRegistrationSessions.Remove(pendingSession);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
TempData["Error"] = "Payment was cancelled — your account has not been created. Please try again whenever you're ready.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// ─── Shared post-creation steps ───────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Shared post-creation step executed for both the trial and paid paths. Assigns the
|
||||
/// Administrator role, seeds company lookup tables, records Terms-of-Service acceptance, sends
|
||||
/// admin notifications, and finally signs the user in.
|
||||
/// Sign-in is intentionally the LAST step — once <c>SignInAsync</c> runs the HTTP context user
|
||||
/// becomes the new company admin, which changes the tenant context for any subsequent
|
||||
/// <c>_db</c> writes. Notifications are therefore awaited synchronously (not fire-and-forget)
|
||||
/// before sign-in to prevent two specific bugs: (1) in-app notifications written for SuperAdmins
|
||||
/// would have their <c>CompanyId=0</c> sentinel overwritten by the tenant context after sign-in;
|
||||
/// (2) <c>IPlatformSettingsService</c> reads use a scoped <c>DbContext</c> that can be disposed
|
||||
/// if the caller fire-and-forgets.
|
||||
/// </summary>
|
||||
private async Task FinalizeRegistrationAsync(ApplicationUser user, Company company, int plan)
|
||||
{
|
||||
await _userManager.AddToRoleAsync(user, "Administrator");
|
||||
|
||||
try { await _seedDataService.SeedCompanyLookupsAsync(company.Id); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to seed lookups for company {CompanyId}", company.Id);
|
||||
}
|
||||
|
||||
// Record ToS acceptance while still anonymous — CompanyId is set explicitly on the entity
|
||||
// so UpdateTimestampsAndTenancy won't overwrite it.
|
||||
_db.TermsAcceptances.Add(new TermsAcceptance
|
||||
{
|
||||
UserId = user.Id,
|
||||
CompanyId = company.Id,
|
||||
TosVersion = AppConstants.Legal.CurrentTosVersion,
|
||||
AcceptedAt = DateTime.UtcNow,
|
||||
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
UserAgent = Request.Headers["User-Agent"].FirstOrDefault()
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("New company registered: {CompanyName} (ID {CompanyId}) by {Email}",
|
||||
company.CompanyName, company.Id, user.Email);
|
||||
|
||||
// Await both notifications BEFORE SignInAsync for two reasons:
|
||||
// 1. InAppNotification: CreateForSuperAdminsAsync writes CompanyId=0 (SuperAdmin sentinel).
|
||||
// After SignIn, UpdateTimestampsAndTenancy would overwrite that 0 with the new company's ID,
|
||||
// routing the bell notification to the wrong recipient.
|
||||
// 2. AdminEmail: NotifyNewCompanyRegisteredAsync uses IPlatformSettingsService (→ DbContext).
|
||||
// Fire-and-forget tasks can outlive the scoped DbContext, causing silent failures where
|
||||
// GetAdminEmailsAsync() throws ObjectDisposedException and no email is sent at all.
|
||||
var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(
|
||||
c => c.Plan == plan && c.IsActive, ignoreQueryFilters: true);
|
||||
var planName = planConfig?.DisplayName ?? plan.ToString();
|
||||
await _adminNotification.NotifyNewCompanyRegisteredAsync(
|
||||
company.Id, company.CompanyName, planName,
|
||||
$"{user.FirstName} {user.LastName}", user.Email!);
|
||||
await _inApp.CreateForSuperAdminsAsync(
|
||||
"New Company Registered",
|
||||
$"{company.CompanyName} signed up on the {planName} plan.",
|
||||
"NewCompany",
|
||||
$"/Companies/Details/{company.Id}");
|
||||
|
||||
// Sign in last — after this point the HTTP context user becomes the new company admin,
|
||||
// which would change the tenant context for any subsequent _db writes.
|
||||
user.LastLoginDate = DateTime.UtcNow;
|
||||
await _userManager.UpdateAsync(user);
|
||||
await _signInManager.SignInAsync(user, isPersistent: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the post-registration welcome page. Requires authentication (the user was just signed
|
||||
/// in by <see cref="FinalizeRegistrationAsync"/>). Determines whether the account is on a free
|
||||
/// trial by checking for the absence of a <c>StripeSubscriptionId</c> — trial accounts are
|
||||
/// created without a subscription ID, which the welcome view uses to display an upgrade prompt
|
||||
/// or hide the trial countdown pill.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Welcome()
|
||||
{
|
||||
ViewData["Title"] = "Welcome to Powder Coating Logix";
|
||||
var user = await _userManager.GetUserAsync(User);
|
||||
ViewBag.FirstName = user?.FirstName ?? User.Identity?.Name?.Split('@')[0] ?? "there";
|
||||
|
||||
// Determine if this is a trial or a paid subscription so the view can hide the trial pill accordingly
|
||||
if (user != null && user.CompanyId > 0)
|
||||
{
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(user.CompanyId, ignoreQueryFilters: true);
|
||||
var isOnTrial = company != null && string.IsNullOrEmpty(company.StripeSubscriptionId);
|
||||
ViewBag.IsOnTrial = isOnTrial;
|
||||
ViewBag.TrialEndDate = company?.SubscriptionEndDate;
|
||||
}
|
||||
else
|
||||
{
|
||||
ViewBag.IsOnTrial = false;
|
||||
ViewBag.TrialEndDate = null;
|
||||
}
|
||||
|
||||
return View();
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether the free-trial sign-up path is active. Reads the <c>TrialsEnabled</c>
|
||||
/// platform setting; a missing or null value is treated as enabled (safe default so a freshly
|
||||
/// deployed instance with no platform settings can still onboard users).
|
||||
/// </summary>
|
||||
private async Task<bool> IsTrialsEnabledAsync()
|
||||
{
|
||||
var raw = await _platformSettings.GetAsync(PlatformSettingKeys.TrialsEnabled);
|
||||
// Null/missing = treat as enabled (safe default)
|
||||
return raw == null || !string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the platform has capacity for a new tenant. Reads the <c>MaxTenants</c>
|
||||
/// platform setting; a blank value or 0 means unlimited. Uses <c>IgnoreQueryFilters</c> on the
|
||||
/// company count so soft-deleted companies still count against the cap (preventing circumvention
|
||||
/// by deleting and re-registering). Marked <c>internal</c> to allow unit-testing without
|
||||
/// mocking the full HTTP pipeline.
|
||||
/// </summary>
|
||||
internal async Task<bool> IsRegistrationOpenAsync()
|
||||
{
|
||||
var raw = await _platformSettings.GetAsync(PlatformSettingKeys.MaxTenants);
|
||||
// Blank or 0 = unlimited
|
||||
if (!int.TryParse(raw, out var max) || max <= 0)
|
||||
return true;
|
||||
|
||||
var count = await _unitOfWork.Companies.CountAsync(ignoreQueryFilters: true);
|
||||
return count < max;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates shared ViewBag properties required by the registration view: active subscription
|
||||
/// plan configs (sorted by <c>SortOrder</c>), the trials-enabled flag, and the trial period
|
||||
/// length in days. Called by both the GET and failed POST paths so the view always has the data
|
||||
/// it needs without the caller repeating these lookups.
|
||||
/// </summary>
|
||||
private async Task PopulateRegistrationViewBagAsync()
|
||||
{
|
||||
var planConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
|
||||
c => c.IsActive, ignoreQueryFilters: true))
|
||||
.OrderBy(c => c.SortOrder)
|
||||
.ToList();
|
||||
ViewBag.PlanConfigs = planConfigs;
|
||||
|
||||
ViewBag.TrialsEnabled = await IsTrialsEnabledAsync();
|
||||
|
||||
var rawTrialDays = await _platformSettings.GetAsync(PlatformSettingKeys.TrialPeriodDays);
|
||||
ViewBag.TrialPeriodDays = int.TryParse(rawTrialDays, out var td) ? td : 7;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constructs the <c>ApplicationUser</c> for a new company administrator. All permissions are
|
||||
/// granted by default because the first user of a new company must be able to do everything.
|
||||
/// <c>EmailConfirmed</c> is set to <c>true</c> immediately — we skip the email-verification
|
||||
/// step for registrations because the welcome email with a temp password serves as implicit
|
||||
/// confirmation that the address is reachable.
|
||||
/// </summary>
|
||||
private static ApplicationUser BuildUser(
|
||||
string email, string firstName, string lastName, int companyId)
|
||||
{
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = email,
|
||||
Email = email,
|
||||
FirstName = firstName,
|
||||
LastName = lastName,
|
||||
CompanyId = companyId,
|
||||
CompanyRole = "CompanyAdmin",
|
||||
HireDate = DateTime.UtcNow.Date,
|
||||
IsActive = true,
|
||||
EmailConfirmed = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
user.GrantAllPermissions();
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives a short, unique company code from the company name (e.g. "Acme Powder Coating" →
|
||||
/// "APC"). Stop words (the, and, inc, llc, etc.) are stripped first. One-, two-, or three-word
|
||||
/// names each use a different initialism strategy so the code is always 3 characters. A numeric
|
||||
/// suffix (2, 3, …) is appended if the derived code is already taken. The collision check loads
|
||||
/// all existing codes into memory — acceptable for small tenant counts but should be replaced
|
||||
/// with a DB-level unique check if the platform ever scales to thousands of tenants.
|
||||
/// </summary>
|
||||
private async Task<string> GenerateUniqueCompanyCodeAsync(string companyName)
|
||||
{
|
||||
var stopWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"the", "and", "of", "a", "an", "for", "inc", "llc", "ltd", "co", "corp"
|
||||
};
|
||||
|
||||
var words = companyName
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(w => !stopWords.Contains(w))
|
||||
.ToList();
|
||||
|
||||
string baseCode;
|
||||
if (words.Count >= 3)
|
||||
baseCode = new string(words.Take(3).Select(w => char.ToUpper(w[0])).ToArray());
|
||||
else if (words.Count == 2)
|
||||
baseCode = char.ToUpper(words[0][0]).ToString() + char.ToUpper(words[1][0]).ToString() +
|
||||
char.ToUpper(words[1].Length > 1 ? words[1][1] : words[0][1]);
|
||||
else if (words.Count == 1)
|
||||
baseCode = words[0].Length >= 4
|
||||
? words[0][..4].ToUpper()
|
||||
: words[0].ToUpper().PadRight(3, 'X');
|
||||
else
|
||||
baseCode = "CO";
|
||||
|
||||
var code = baseCode;
|
||||
var allCodes = (await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true))
|
||||
.Select(c => c.CompanyCode)
|
||||
.Where(c => c != null)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
int suffix = 2;
|
||||
while (allCodes.Contains(code))
|
||||
{
|
||||
code = baseCode + suffix;
|
||||
suffix++;
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
// ─── Session model ────────────────────────────────────────────────────────
|
||||
|
||||
private sealed record PendingRegistration(
|
||||
string CompanyName,
|
||||
string? CompanyPhone,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string Email,
|
||||
int Plan,
|
||||
bool IsAnnual = false);
|
||||
|
||||
// ─── Temporary password generation ────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Generates a cryptographically random 12-character temporary password that satisfies ASP.NET
|
||||
/// Core Identity's default complexity rules (uppercase, lowercase, digit, special character).
|
||||
/// Ambiguous characters (I, O, 0, 1, l) are excluded from the alphabet to reduce transcription
|
||||
/// errors in case the user reads the password from the email rather than copying it.
|
||||
/// A Fisher-Yates shuffle with a second batch of random bytes ensures no character class is
|
||||
/// predictably in a fixed position (e.g. the guaranteed uppercase is not always at index 0).
|
||||
/// </summary>
|
||||
private static string GenerateTemporaryPassword()
|
||||
{
|
||||
const string upper = "ABCDEFGHJKLMNPQRSTUVWXYZ"; // no I, O
|
||||
const string lower = "abcdefghjkmnpqrstuvwxyz"; // no i, l, o
|
||||
const string digits = "23456789"; // no 0, 1
|
||||
const string special = "!@#$%&*";
|
||||
const string all = upper + lower + digits + special;
|
||||
|
||||
var bytes = new byte[16];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
|
||||
var pw = new char[12];
|
||||
// Guarantee one character of each required class
|
||||
pw[0] = upper [bytes[0] % upper.Length];
|
||||
pw[1] = lower [bytes[1] % lower.Length];
|
||||
pw[2] = digits [bytes[2] % digits.Length];
|
||||
pw[3] = special[bytes[3] % special.Length];
|
||||
for (int i = 4; i < 12; i++)
|
||||
pw[i] = all[bytes[i] % all.Length];
|
||||
|
||||
// Fisher-Yates shuffle with fresh random bytes
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
for (int i = 11; i > 0; i--)
|
||||
{
|
||||
int j = bytes[i] % (i + 1);
|
||||
(pw[i], pw[j]) = (pw[j], pw[i]);
|
||||
}
|
||||
|
||||
return new string(pw);
|
||||
}
|
||||
|
||||
// ─── Welcome email ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Sends the welcome email with temporary password, plan-specific feature highlights, and an
|
||||
/// optional trial-expiry blurb. Called fire-and-forget so a transient email failure never blocks
|
||||
/// the registration response. Logged at Error because a missing welcome email means the user
|
||||
/// cannot retrieve their temporary password and must contact support.
|
||||
/// </summary>
|
||||
private async Task SendWelcomeEmailAsync(
|
||||
string email, string firstName, string tempPassword,
|
||||
int plan, DateTime? trialEndDate, string baseUrl)
|
||||
{
|
||||
try
|
||||
{
|
||||
var planName = plan switch
|
||||
{
|
||||
3 => "Starter",
|
||||
0 => "Basic",
|
||||
1 => "Pro",
|
||||
2 => "Enterprise",
|
||||
_ => "Starter"
|
||||
};
|
||||
|
||||
// Plan-specific feature bullets (plain + html pairs)
|
||||
var (plainFeatures, htmlFeatures) = plan switch
|
||||
{
|
||||
// Starter
|
||||
3 => (
|
||||
"• Job & quote management for up to 1 active user\r\n" +
|
||||
"• Customer records and job history\r\n" +
|
||||
"• Inventory tracking and low-stock alerts\r\n" +
|
||||
"• PDF quotes and invoices\r\n" +
|
||||
"• Basic financial reports",
|
||||
|
||||
"<li>Job & quote management for up to 1 active user</li>" +
|
||||
"<li>Customer records and job history</li>" +
|
||||
"<li>Inventory tracking and low-stock alerts</li>" +
|
||||
"<li>PDF quotes and invoices</li>" +
|
||||
"<li>Basic financial reports</li>"
|
||||
),
|
||||
// Basic
|
||||
0 => (
|
||||
"• Job & quote management for up to 3 users\r\n" +
|
||||
"• Customer records and job history\r\n" +
|
||||
"• Inventory tracking, purchase orders, and vendor management\r\n" +
|
||||
"• PDF quotes, invoices, and deposits\r\n" +
|
||||
"• Oven scheduler for batch planning\r\n" +
|
||||
"• Equipment and maintenance tracking\r\n" +
|
||||
"• Financial reports and AR aging",
|
||||
|
||||
"<li>Job & quote management for up to 3 users</li>" +
|
||||
"<li>Customer records and job history</li>" +
|
||||
"<li>Inventory tracking, purchase orders, and vendor management</li>" +
|
||||
"<li>PDF quotes, invoices, and deposits</li>" +
|
||||
"<li>Oven scheduler for batch planning</li>" +
|
||||
"<li>Equipment and maintenance tracking</li>" +
|
||||
"<li>Financial reports and AR aging</li>"
|
||||
),
|
||||
// Pro
|
||||
1 => (
|
||||
"• Everything in Basic, plus up to 10 users\r\n" +
|
||||
"• AI Photo Quoting — upload a photo, get an instant estimate\r\n" +
|
||||
"• AI Accounting Assistant — receipt scanning, cash flow forecasting, anomaly detection\r\n" +
|
||||
"• Online customer approval portal with Stripe payments\r\n" +
|
||||
"• Advanced analytics and custom reports\r\n" +
|
||||
"• Priority support",
|
||||
|
||||
"<li>Everything in Basic, plus up to 10 users</li>" +
|
||||
"<li><strong>AI Photo Quoting</strong> — upload a photo, get an instant estimate</li>" +
|
||||
"<li><strong>AI Accounting Assistant</strong> — receipt scanning, cash flow forecasting, anomaly detection</li>" +
|
||||
"<li>Online customer approval portal with Stripe payments</li>" +
|
||||
"<li>Advanced analytics and custom reports</li>" +
|
||||
"<li>Priority support</li>"
|
||||
),
|
||||
// Enterprise
|
||||
_ => (
|
||||
"• Everything in Pro, with unlimited users and jobs\r\n" +
|
||||
"• AI Photo Quoting and AI Accounting Assistant\r\n" +
|
||||
"• Online customer approval portal with Stripe payments\r\n" +
|
||||
"• Full analytics suite and all report exports\r\n" +
|
||||
"• Dedicated onboarding and priority support\r\n" +
|
||||
"• Custom integrations available on request",
|
||||
|
||||
"<li>Everything in Pro, with unlimited users and jobs</li>" +
|
||||
"<li><strong>AI Photo Quoting</strong> and <strong>AI Accounting Assistant</strong></li>" +
|
||||
"<li>Online customer approval portal with Stripe payments</li>" +
|
||||
"<li>Full analytics suite and all report exports</li>" +
|
||||
"<li>Dedicated onboarding and priority support</li>" +
|
||||
"<li>Custom integrations available on request</li>"
|
||||
)
|
||||
};
|
||||
|
||||
var trialPlainBlurb = trialEndDate.HasValue
|
||||
? $"Your {planName} trial is active through {trialEndDate.Value:MMMM d, yyyy}. No credit card is required during the trial — we'll remind you before it ends.\r\n\r\n"
|
||||
: string.Empty;
|
||||
|
||||
var trialHtmlBlurb = trialEndDate.HasValue
|
||||
? $"<p style='background:#fff8e1;border-left:4px solid #f59e0b;padding:10px 14px;margin:20px 0;'>" +
|
||||
$"Your <strong>{planName} trial</strong> is active through <strong>{trialEndDate.Value:MMMM d, yyyy}</strong>. " +
|
||||
$"No credit card is required during the trial — we'll remind you before it ends.</p>"
|
||||
: string.Empty;
|
||||
|
||||
var subject = $"Welcome to Powder Coating Logix — Your {planName} Account Is Ready";
|
||||
|
||||
var plain =
|
||||
$"Hi {firstName},\r\n\r\n" +
|
||||
$"Your Powder Coating Logix {planName} account is ready. Here's your temporary password to log in:\r\n\r\n" +
|
||||
$" Temporary password: {tempPassword}\r\n\r\n" +
|
||||
$"You'll be prompted to set a new password the first time you log in.\r\n\r\n" +
|
||||
trialPlainBlurb +
|
||||
$"Your {planName} plan includes:\r\n" +
|
||||
plainFeatures + "\r\n\r\n" +
|
||||
$"Get started quickly:\r\n" +
|
||||
$" 1. Log in at {baseUrl}\r\n" +
|
||||
$" 2. Complete the Setup Wizard — it takes about 5 minutes and configures your rates, inventory, and shop profile so quotes price correctly from day one.\r\n\r\n" +
|
||||
$"Need help? Our AI assistant is built right into the app — just click the chat icon. You can also reach us at support@powdercoatinglogix.com.\r\n\r\n" +
|
||||
$"Welcome aboard,\r\n" +
|
||||
$"The Powder Coating Logix Team";
|
||||
|
||||
var html =
|
||||
$@"<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset='utf-8'></head>
|
||||
<body style='margin:0;padding:0;background:#f4f4f4;font-family:Arial,sans-serif;'>
|
||||
<table width='100%' cellpadding='0' cellspacing='0' style='background:#f4f4f4;padding:30px 0;'>
|
||||
<tr><td align='center'>
|
||||
<table width='600' cellpadding='0' cellspacing='0' style='background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);'>
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style='background:#1a3a5c;padding:28px 40px;'>
|
||||
<p style='margin:0;color:#ffffff;font-size:22px;font-weight:bold;'>Powder Coating Logix</p>
|
||||
<p style='margin:6px 0 0;color:#a8c4e0;font-size:13px;'>Shop management software built for powder coaters</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr><td style='padding:36px 40px;color:#333333;font-size:15px;line-height:1.6;'>
|
||||
<p style='margin:0 0 16px;'>Hi {firstName},</p>
|
||||
<p style='margin:0 0 24px;'>Your <strong>Powder Coating Logix {planName}</strong> account is ready. Here's your temporary password to get started:</p>
|
||||
|
||||
<div style='background:#f0f4f8;border:1px solid #d0dce8;border-radius:6px;padding:16px 24px;margin:0 0 24px;text-align:center;'>
|
||||
<p style='margin:0 0 4px;font-size:12px;color:#666;text-transform:uppercase;letter-spacing:0.05em;'>Temporary Password</p>
|
||||
<p style='margin:0;font-family:monospace;font-size:22px;font-weight:bold;letter-spacing:0.1em;color:#1a3a5c;'>{tempPassword}</p>
|
||||
</div>
|
||||
|
||||
<p style='margin:0 0 24px;color:#555;font-size:14px;'>You'll be prompted to set a new password the first time you log in.</p>
|
||||
|
||||
{trialHtmlBlurb}
|
||||
|
||||
<p style='margin:0 0 10px;font-weight:bold;'>Your {planName} plan includes:</p>
|
||||
<ul style='margin:0 0 24px;padding-left:20px;color:#444;'>
|
||||
{htmlFeatures}
|
||||
</ul>
|
||||
|
||||
<p style='margin:0 0 10px;font-weight:bold;'>Get started quickly:</p>
|
||||
<ol style='margin:0 0 24px;padding-left:20px;color:#444;'>
|
||||
<li style='margin-bottom:8px;'><a href='{baseUrl}' style='color:#1a3a5c;'>Log in to your account</a></li>
|
||||
<li>Complete the <strong>Setup Wizard</strong> — it takes about 5 minutes and configures your rates, inventory, and shop profile so quotes price correctly from day one.</li>
|
||||
</ol>
|
||||
|
||||
<p style='margin:0 0 24px;'>Need help? Our <strong>AI assistant</strong> is built right into the app — just click the chat icon. You can also reach us any time at <a href='mailto:support@powdercoatinglogix.com' style='color:#1a3a5c;'>support@powdercoatinglogix.com</a>.</p>
|
||||
|
||||
<p style='margin:0;'>Welcome aboard,<br><strong>The Powder Coating Logix Team</strong></p>
|
||||
</td></tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style='background:#f8f9fa;border-top:1px solid #e9ecef;padding:20px 40px;text-align:center;'>
|
||||
<p style='margin:0;font-size:12px;color:#888;'>
|
||||
© {DateTime.UtcNow.Year} Powder Coating Logix •
|
||||
<a href='mailto:support@powdercoatinglogix.com' style='color:#888;'>support@powdercoatinglogix.com</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>";
|
||||
|
||||
await _emailService.SendEmailAsync(email, firstName, subject, plain, html);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send welcome email to {Email}", email);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user