using System.Text.Json; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using PlotLine.Data; using PlotLine.Models; using PlotLine.Services; using PlotLine.ViewModels; namespace PlotLine.Controllers; [Authorize] public sealed class ManageAccountController( ICurrentUserService currentUser, IUserRepository users, IAuthService authService, IAccountDeletionService accountDeletion, ITwoFactorService twoFactorService, ISubscriptionService subscriptions, IStripeBillingService stripeBilling, ILogger logger) : Controller { [HttpGet] public async Task Index() { var user = await GetCurrentUserAsync(); if (user is null) { return RedirectToAction("Login", "Account"); } var subscription = await subscriptions.GetCurrentSubscriptionAsync(user.UserID); return View(new ManageAccountViewModel { User = user, Subscription = subscription, Trial = TrialVisibilityViewModel.FromSubscription(subscription, DateTime.UtcNow), HasPaidBillingRisk = await subscriptions.HasPaidBillingRiskAsync(user.UserID) }); } [HttpPost] [ValidateAntiForgeryToken] public async Task HardDeleteAccount(string? confirmationEmail) { var userId = currentUser.UserId; if (!userId.HasValue) { return RedirectToAction("Login", "Account"); } var user = await users.GetByIdAsync(userId.Value); if (user is null) { TempData["AuthMessage"] = "Your account could not be found."; return RedirectToAction("Login", "Account"); } if (!string.Equals(confirmationEmail?.Trim(), user.Email, StringComparison.OrdinalIgnoreCase)) { TempData["AuthError"] = "The email address entered did not match your account email."; return RedirectToAction(nameof(Index)); } AccountDeletionResult result; try { result = await accountDeletion.HardDeleteAccountAsync(user.UserID); } catch (Exception ex) { logger.LogError(ex, "Account hard delete failed unexpectedly for user {UserID}.", user.UserID); TempData["AuthError"] = "Your account could not be deleted. Please try again or contact support."; return RedirectToAction(nameof(Index)); } if (result.BlockedByPaidSubscription) { TempData["AuthError"] = "You currently have an active paid subscription. Please cancel it before deleting your account."; return RedirectToAction(nameof(Index)); } if (!result.Succeeded) { TempData["AuthError"] = "Your account could not be deleted. Please try again or contact support."; return RedirectToAction(nameof(Index)); } await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); TempData["AuthMessage"] = "Your account has been permanently deleted."; return RedirectToAction("Login", "Account"); } [HttpGet] public async Task Subscription() { var user = await GetCurrentUserAsync(); if (user is null) { return RedirectToAction("Login", "Account"); } var subscription = await subscriptions.GetCurrentSubscriptionAsync(user.UserID); return View(new ManageSubscriptionViewModel { User = user, CurrentSubscription = subscription, Trial = TrialVisibilityViewModel.FromSubscription(subscription, DateTime.UtcNow), Usage = subscription is null ? null : await subscriptions.GetUsageSummaryAsync(user.UserID), AvailableLevels = await subscriptions.GetActiveSubscriptionLevelsAsync() }); } [HttpPost] [ValidateAntiForgeryToken] public IActionResult SubscriptionPlaceholder() { TempData["SubscriptionMessage"] = "Online subscription changes will be available soon."; return RedirectToAction(nameof(Subscription)); } [HttpPost] [ValidateAntiForgeryToken] public async Task SubscriptionCheckout(StripeCheckoutRequest request) { var user = await GetCurrentUserAsync(); if (user is null) { return RedirectToAction("Login", "Account"); } var successUrl = Url.Action(nameof(SubscriptionSuccess), "ManageAccount", null, Request.Scheme) ?? string.Empty; var cancelUrl = Url.Action(nameof(SubscriptionCancelled), "ManageAccount", null, Request.Scheme) ?? string.Empty; var result = await stripeBilling.StartCheckoutOrChangePlanAsync(user, request, successUrl, cancelUrl); if (!result.Succeeded) { TempData["SubscriptionMessage"] = result.Message; return RedirectToAction(nameof(Subscription)); } if (!string.IsNullOrWhiteSpace(result.RedirectUrl)) { return Redirect(result.RedirectUrl); } TempData["SubscriptionMessage"] = result.Message; return RedirectToAction(nameof(Subscription)); } [HttpPost] [ValidateAntiForgeryToken] public async Task CancelSubscription() { var user = await GetCurrentUserAsync(); if (user is null) { return RedirectToAction("Login", "Account"); } var result = await stripeBilling.CancelAtPeriodEndAsync(user); TempData["SubscriptionMessage"] = result.Message; return RedirectToAction(nameof(Subscription)); } [HttpPost] [ValidateAntiForgeryToken] public async Task ManageBilling() { var user = await GetCurrentUserAsync(); if (user is null) { return RedirectToAction("Login", "Account"); } var returnUrl = Url.Action(nameof(Subscription), "ManageAccount", null, Request.Scheme) ?? string.Empty; var result = await stripeBilling.CreateBillingPortalSessionAsync(user, returnUrl); if (result.Succeeded && !string.IsNullOrWhiteSpace(result.RedirectUrl)) { return Redirect(result.RedirectUrl); } TempData["SubscriptionMessage"] = result.Message; return RedirectToAction(nameof(Subscription)); } [HttpGet] public IActionResult SubscriptionSuccess() { TempData["SubscriptionMessage"] = "Thank you. Your subscription is being confirmed. This usually only takes a few moments."; return RedirectToAction(nameof(Subscription)); } [HttpGet] public IActionResult SubscriptionCancelled() { TempData["SubscriptionMessage"] = "Stripe Checkout was cancelled. No subscription changes were made."; return RedirectToAction(nameof(Subscription)); } [HttpGet] public IActionResult ChangePassword() { return View(new ChangePasswordViewModel()); } [HttpPost] [ValidateAntiForgeryToken] public async Task ChangePassword(ChangePasswordViewModel model) { if (!ModelState.IsValid) { return View(model); } var result = await authService.ChangePasswordAsync(model); if (!result.Success) { ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "Your password could not be changed."); return View(model); } TempData["AuthMessage"] = "Your password has been changed. Please sign in again."; return RedirectToAction("Login", "Account"); } [HttpGet] public async Task EnableTwoFactor() { var user = await GetCurrentUserAsync(); if (user is null) { return RedirectToAction("Login", "Account"); } var secret = twoFactorService.GenerateSecretKey(); var uri = twoFactorService.GenerateQrCodeUri(user.Email, secret); return View(new EnableTwoFactorViewModel { SecretKey = secret, QrCodeUri = uri, QrCodeImageDataUri = twoFactorService.GenerateQrCodeDataUri(uri) }); } [HttpPost] [ValidateAntiForgeryToken] public async Task EnableTwoFactor(EnableTwoFactorViewModel model) { if (!ModelState.IsValid) { model.QrCodeUri = twoFactorService.GenerateQrCodeUri(currentUser.Email ?? string.Empty, model.SecretKey); model.QrCodeImageDataUri = twoFactorService.GenerateQrCodeDataUri(model.QrCodeUri); return View(model); } var result = await authService.EnableTwoFactorAsync(currentUser.UserId!.Value, model.SecretKey, model.Code); if (!result.Success) { ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "Two-factor authentication could not be enabled."); model.QrCodeUri = twoFactorService.GenerateQrCodeUri(currentUser.Email ?? string.Empty, model.SecretKey); model.QrCodeImageDataUri = twoFactorService.GenerateQrCodeDataUri(model.QrCodeUri); return View(model); } TempData["RecoveryCodes"] = JsonSerializer.Serialize(result.RecoveryCodes); TempData["AuthMessage"] = "Two-factor authentication has been enabled."; return RedirectToAction(nameof(RecoveryCodes)); } [HttpGet] public IActionResult DisableTwoFactor() { return View(new DisableTwoFactorViewModel()); } [HttpPost] [ValidateAntiForgeryToken] public async Task DisableTwoFactor(DisableTwoFactorViewModel model) { if (!ModelState.IsValid) { return View(model); } var result = await authService.DisableTwoFactorAsync(currentUser.UserId!.Value, model.CurrentPassword); if (!result.Success) { ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "Two-factor authentication could not be disabled."); return View(model); } TempData["AuthMessage"] = "Two-factor authentication has been disabled."; return RedirectToAction(nameof(Index)); } [HttpGet] public IActionResult RecoveryCodes() { var codes = TempData["RecoveryCodes"] is string json ? JsonSerializer.Deserialize>(json) ?? Array.Empty() : Array.Empty(); return View(new RecoveryCodesViewModel { RecoveryCodes = codes }); } [HttpPost] [ValidateAntiForgeryToken] public async Task RegenerateRecoveryCodes() { var codes = await authService.GenerateRecoveryCodesAsync(currentUser.UserId!.Value); TempData["RecoveryCodes"] = JsonSerializer.Serialize(codes); TempData["AuthMessage"] = "New recovery codes have been generated."; return RedirectToAction(nameof(RecoveryCodes)); } private async Task GetCurrentUserAsync() { return currentUser.UserId is int userId ? await users.GetByIdAsync(userId) : null; } }