920 lines
44 KiB
C#
920 lines
44 KiB
C#
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). The Stripe session is re-validated before any
|
|
/// local company or user is created, and the pending session is only left marked completed once
|
|
/// registration succeeds. On recoverable failures the completion flag is released so the same
|
|
/// success URL can be retried instead of forcing manual support intervention.
|
|
/// </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);
|
|
|
|
if (pendingSession == null)
|
|
{
|
|
TempData["Error"] = "Your registration session was not found. Please fill in your details again.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
var pending = new PendingRegistration(
|
|
pendingSession.CompanyName, pendingSession.CompanyPhone,
|
|
pendingSession.FirstName, pendingSession.LastName,
|
|
pendingSession.Email, pendingSession.Plan, pendingSession.IsAnnual);
|
|
|
|
var existingUser = await _userManager.FindByEmailAsync(pending.Email);
|
|
|
|
if (pendingSession.IsCompleted)
|
|
{
|
|
if (existingUser != null)
|
|
{
|
|
await SignInExistingRegistrationUserAsync(existingUser);
|
|
return RedirectToAction(nameof(Welcome));
|
|
}
|
|
|
|
TempData["Error"] = "Your registration was already submitted, but we couldn't finish signing you in. Please contact support with reference: " + session_id;
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
if (!await _stripeService.IsRegistrationCheckoutPaidAsync(session_id))
|
|
{
|
|
TempData["Error"] = "We couldn't verify a completed payment for this registration session yet. Please try the link again in a moment, or contact support if the issue persists.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
var keepSessionCompleted = false;
|
|
pendingSession.IsCompleted = true;
|
|
await _db.SaveChangesAsync();
|
|
|
|
try
|
|
{
|
|
// 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));
|
|
}
|
|
|
|
// Recover gracefully if a prior attempt already created the user.
|
|
if (existingUser != null)
|
|
{
|
|
keepSessionCompleted = true;
|
|
await SignInExistingRegistrationUserAsync(existingUser);
|
|
return RedirectToAction(nameof(Welcome));
|
|
}
|
|
|
|
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 couldn't finish creating your account. Please try the success link again, or 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}");
|
|
|
|
keepSessionCompleted = true;
|
|
return RedirectToAction(nameof(Welcome));
|
|
}
|
|
finally
|
|
{
|
|
if (!keepSessionCompleted)
|
|
{
|
|
pendingSession.IsCompleted = false;
|
|
await _db.SaveChangesAsync();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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>
|
|
/// Signs in an already-created registration user on idempotent success-link retries.
|
|
/// This is intentionally narrower than <see cref="FinalizeRegistrationAsync"/> because all
|
|
/// registration side effects should already have happened on the original successful run.
|
|
/// </summary>
|
|
private async Task SignInExistingRegistrationUserAsync(ApplicationUser user)
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
}
|