322 lines
11 KiB
C#
322 lines
11 KiB
C#
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<ManageAccountController> logger) : Controller
|
|
{
|
|
[HttpGet]
|
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IReadOnlyList<string>>(json) ?? Array.Empty<string>()
|
|
: Array.Empty<string>();
|
|
|
|
return View(new RecoveryCodesViewModel { RecoveryCodes = codes });
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> 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<PlotLine.Models.AppUser?> GetCurrentUserAsync()
|
|
{
|
|
return currentUser.UserId is int userId ? await users.GetByIdAsync(userId) : null;
|
|
}
|
|
}
|