292 lines
10 KiB
Plaintext
292 lines
10 KiB
Plaintext
@page "/settings"
|
|
@attribute [Authorize]
|
|
@using Microsoft.AspNetCore.Identity
|
|
@using Microsoft.EntityFrameworkCore
|
|
@inject UserManager<ApplicationUser> UserManager
|
|
@inject SignInManager<ApplicationUser> SignInManager
|
|
@inject AuthenticationStateProvider AuthStateProvider
|
|
@inject ApplicationDbContext DbContext
|
|
@inject NavigationManager Navigation
|
|
@inject AppState AppState
|
|
@rendermode InteractiveServer
|
|
|
|
<PageTitle>Settings</PageTitle>
|
|
|
|
<h3 class="mb-4">
|
|
Settings
|
|
<span class="badge bg-warning text-dark ms-2" style="font-size: 0.4em; vertical-align: middle;">beta</span>
|
|
</h3>
|
|
|
|
@* ── Change Password ── *@
|
|
<div class="card mb-4">
|
|
<div class="card-header">
|
|
<h5 class="mb-0">Change Password</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
@if (passwordSuccess)
|
|
{
|
|
<div class="alert alert-success">Password changed successfully.</div>
|
|
}
|
|
|
|
@if (!string.IsNullOrEmpty(passwordError))
|
|
{
|
|
<div class="alert alert-danger">@passwordError</div>
|
|
}
|
|
|
|
<EditForm Model="passwordForm" OnValidSubmit="ChangePassword" FormName="changePassword">
|
|
<ToastValidator />
|
|
<div class="row g-3">
|
|
<div class="col-md-4">
|
|
<label class="form-label">Current Password</label>
|
|
<InputText @bind-Value="passwordForm.CurrentPassword" type="password" class="form-control" />
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label">New Password</label>
|
|
<InputText @bind-Value="passwordForm.NewPassword" type="password" class="form-control" />
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label">Confirm New Password</label>
|
|
<InputText @bind-Value="passwordForm.ConfirmNewPassword" type="password" class="form-control" />
|
|
</div>
|
|
<div class="col-12">
|
|
<button type="submit" class="btn btn-primary">Update Password</button>
|
|
</div>
|
|
</div>
|
|
</EditForm>
|
|
</div>
|
|
</div>
|
|
|
|
@* ── Reset Data ── *@
|
|
<div class="card mb-4 border-warning">
|
|
<div class="card-header bg-warning">
|
|
<h5 class="mb-0">Reset Data</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="fw-semibold">
|
|
This will permanently delete all your accounts, transactions and categories, giving you a clean slate. Your user account and password will be kept.
|
|
</p>
|
|
|
|
@if (resetSuccess)
|
|
{
|
|
<div class="alert alert-success">All data has been reset successfully.</div>
|
|
}
|
|
|
|
@if (!string.IsNullOrEmpty(resetError))
|
|
{
|
|
<div class="alert alert-danger">@resetError</div>
|
|
}
|
|
|
|
@if (!confirmingReset)
|
|
{
|
|
<button class="btn btn-outline-warning" @onclick="() => confirmingReset = true">
|
|
Reset All Data
|
|
</button>
|
|
}
|
|
else
|
|
{
|
|
<div class="alert alert-warning">
|
|
<strong>Are you sure?</strong> This cannot be undone. Type <code>I UNDERSTAND</code> and enter your password to confirm.
|
|
</div>
|
|
<div class="mb-3" style="max-width: 400px;">
|
|
<input type="text" class="form-control mb-2" placeholder="Type I UNDERSTAND to confirm" @bind="resetConfirmation" />
|
|
<input type="password" class="form-control mb-2" placeholder="Enter your password" @bind="resetPassword" />
|
|
<div class="d-flex gap-2">
|
|
<button class="btn btn-warning" @onclick="ResetData">Confirm Reset</button>
|
|
<button class="btn btn-secondary" @onclick="CancelReset">Cancel</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
@* ── Delete Account ── *@
|
|
<div class="card border-danger">
|
|
<div class="card-header bg-danger text-white">
|
|
<h5 class="mb-0">Delete Account</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="text-danger fw-semibold">
|
|
This will permanently delete your account and all associated data (accounts, transactions, and categories). This action cannot be undone.
|
|
</p>
|
|
|
|
@if (!string.IsNullOrEmpty(deleteError))
|
|
{
|
|
<div class="alert alert-danger">@deleteError</div>
|
|
}
|
|
|
|
@if (!confirmingDelete)
|
|
{
|
|
<button class="btn btn-outline-danger" @onclick="() => confirmingDelete = true">
|
|
Delete My Account
|
|
</button>
|
|
}
|
|
else
|
|
{
|
|
<div class="alert alert-warning">
|
|
<strong>Are you sure?</strong> Type your password to confirm.
|
|
</div>
|
|
<div class="input-group mb-3" style="max-width: 400px;">
|
|
<input type="password" class="form-control" placeholder="Enter your password" @bind="deletePassword" />
|
|
<button class="btn btn-danger" @onclick="DeleteAccount">Confirm Delete</button>
|
|
<button class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
@code {
|
|
private static readonly string appVersion =
|
|
typeof(Program).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
|
|
|
|
private ChangePasswordForm passwordForm = new();
|
|
private bool passwordSuccess;
|
|
private string? passwordError;
|
|
|
|
private bool confirmingDelete;
|
|
private string deletePassword = string.Empty;
|
|
private string? deleteError;
|
|
|
|
private bool confirmingReset;
|
|
private string resetConfirmation = string.Empty;
|
|
private string resetPassword = string.Empty;
|
|
private string? resetError;
|
|
private bool resetSuccess;
|
|
|
|
private async Task ChangePassword()
|
|
{
|
|
passwordSuccess = false;
|
|
passwordError = null;
|
|
|
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
var userIdClaim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
if (userIdClaim is null) return;
|
|
|
|
var user = await UserManager.FindByIdAsync(userIdClaim);
|
|
if (user is null) return;
|
|
|
|
var result = await UserManager.ChangePasswordAsync(user, passwordForm.CurrentPassword, passwordForm.NewPassword);
|
|
|
|
if (result.Succeeded)
|
|
{
|
|
passwordSuccess = true;
|
|
passwordForm = new ChangePasswordForm();
|
|
}
|
|
else
|
|
{
|
|
passwordError = string.Join(" ", result.Errors.Select(e => e.Description));
|
|
}
|
|
}
|
|
|
|
private async Task DeleteAccount()
|
|
{
|
|
deleteError = null;
|
|
|
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
var userIdClaim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
if (userIdClaim is null) return;
|
|
|
|
var user = await UserManager.FindByIdAsync(userIdClaim);
|
|
if (user is null) return;
|
|
|
|
var passwordValid = await UserManager.CheckPasswordAsync(user, deletePassword);
|
|
if (!passwordValid)
|
|
{
|
|
deleteError = "Incorrect password.";
|
|
return;
|
|
}
|
|
|
|
// Delete all user data explicitly in correct dependency order
|
|
await DbContext.Transactions
|
|
.Where(t => t.Account.UserId == userIdClaim)
|
|
.ExecuteDeleteAsync();
|
|
|
|
await DbContext.Accounts
|
|
.Where(a => a.UserId == userIdClaim)
|
|
.ExecuteDeleteAsync();
|
|
|
|
await DbContext.Categories
|
|
.Where(c => c.UserId == userIdClaim)
|
|
.ExecuteDeleteAsync();
|
|
|
|
// Delete identity user
|
|
await UserManager.DeleteAsync(user);
|
|
await SignInManager.SignOutAsync();
|
|
|
|
Navigation.NavigateTo("/login", forceLoad: true);
|
|
}
|
|
|
|
private void CancelDelete()
|
|
{
|
|
confirmingDelete = false;
|
|
deletePassword = string.Empty;
|
|
deleteError = null;
|
|
}
|
|
|
|
private async Task ResetData()
|
|
{
|
|
resetError = null;
|
|
resetSuccess = false;
|
|
|
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
var userIdClaim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
if (userIdClaim is null) return;
|
|
|
|
var user = await UserManager.FindByIdAsync(userIdClaim);
|
|
if (user is null) return;
|
|
|
|
if (!string.Equals(resetConfirmation?.Trim(), "I UNDERSTAND", StringComparison.Ordinal))
|
|
{
|
|
resetError = "Please type 'I UNDERSTAND' exactly to confirm.";
|
|
return;
|
|
}
|
|
|
|
var passwordValid = await UserManager.CheckPasswordAsync(user, resetPassword);
|
|
if (!passwordValid)
|
|
{
|
|
resetError = "Incorrect password.";
|
|
return;
|
|
}
|
|
|
|
await DbContext.Transactions
|
|
.Where(t => t.Account.UserId == userIdClaim)
|
|
.ExecuteDeleteAsync();
|
|
|
|
await DbContext.Accounts
|
|
.Where(a => a.UserId == userIdClaim)
|
|
.ExecuteDeleteAsync();
|
|
|
|
await DbContext.Categories
|
|
.Where(c => c.UserId == userIdClaim)
|
|
.ExecuteDeleteAsync();
|
|
|
|
confirmingReset = false;
|
|
resetConfirmation = string.Empty;
|
|
resetPassword = string.Empty;
|
|
resetSuccess = true;
|
|
|
|
AppState.NotifyAccountsChanged();
|
|
}
|
|
|
|
private void CancelReset()
|
|
{
|
|
confirmingReset = false;
|
|
resetConfirmation = string.Empty;
|
|
resetPassword = string.Empty;
|
|
resetError = null;
|
|
}
|
|
|
|
private sealed class ChangePasswordForm
|
|
{
|
|
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Current password is required")]
|
|
public string CurrentPassword { get; set; } = string.Empty;
|
|
|
|
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "New password is required")]
|
|
[System.ComponentModel.DataAnnotations.MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
|
|
public string NewPassword { get; set; } = string.Empty;
|
|
|
|
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your new password")]
|
|
[System.ComponentModel.DataAnnotations.Compare(nameof(NewPassword), ErrorMessage = "Passwords do not match")]
|
|
public string ConfirmNewPassword { get; set; } = string.Empty;
|
|
}
|
|
}
|