This commit is contained in:
rossmill76 committed 2026-03-02 11:50:08 +00:00
1 parent 2cf6128a85
commit 45a5e3fc5e
11 files changed
+418 -30

No files matched your search

+2
View File
@@ -12,6 +12,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="10.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+7 -1
View File
@@ -1,6 +1,7 @@
@inject AccountService AccountService
@inject AuthenticationStateProvider AuthStateProvider
@inject AppState AppState
@inject NavigationManager Navigation
@implements IDisposable
@rendermode InteractiveServer
@@ -57,7 +58,7 @@
<div class="balances-header">Balances</div>
@foreach (var account in accounts)
{
<div class="balance-item">
<div class="balance-item" style="cursor: pointer;" @onclick="NavigateToAccounts">
<div class="balance-color" style="background-color: @account.Color;"></div>
<div class="balance-details">
<span class="balance-name">@account.Name</span>
@@ -103,5 +104,10 @@
{
AppState.OnAccountsChanged -= HandleAccountsChanged;
}
private void NavigateToAccounts()
{
Navigation.NavigateTo("accounts");
}
}
@@ -141,6 +141,12 @@
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0;
border-radius: 4px;
transition: background-color 0.15s;
}
.balance-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.balance-color {
+57
View File
@@ -0,0 +1,57 @@
@page "/about"
@layout AuthLayout
@inject IConfiguration Configuration
<PageTitle>Budgeteer — About</PageTitle>
<h3 class="text-center mb-4">💰 Budgeteer</h3>
<p class="text-center text-muted mb-4">
A personal finance dashboard built with Blazor Server.
</p>
@if (links.Count > 0)
{
<div class="d-flex justify-content-center gap-3 mb-4">
@foreach (var link in links)
{
<a href="@link.Url" target="_blank" rel="noopener noreferrer" class="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1" title="@link.Label">
@((MarkupString)link.Icon) @link.Label
</a>
}
</div>
}
<p class="text-center">
<a href="/login">Sign in</a> or <a href="/register">Register</a>
</p>
@code {
private record ContactLink(string Label, string Url, string Icon);
private List<ContactLink> links = [];
protected override void OnInitialized()
{
var about = Configuration.GetSection("About");
var email = about["Email"];
if (!string.IsNullOrWhiteSpace(email))
links.Add(new("Email", $"mailto:{email}", "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'><path d='M.05 3.555A2 2 0 0 1 2 2h12a2 2 0 0 1 1.95 1.555L8 8.414.05 3.555ZM0 4.697v7.104l5.803-3.558L0 4.697ZM6.761 8.83l-6.57 4.027A2 2 0 0 0 2 14h12a2 2 0 0 0 1.808-1.144l-6.57-4.027L8 9.586l-1.239-.757ZM16 11.801V4.697l-5.803 3.546L16 11.801Z'/></svg>"));
var discord = about["Discord"];
if (!string.IsNullOrWhiteSpace(discord))
links.Add(new("Discord", discord, "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'><path d='M13.545 2.907a13.227 13.227 0 0 0-3.257-1.011.05.05 0 0 0-.052.025c-.141.25-.297.577-.406.833a12.19 12.19 0 0 0-3.658 0 8.258 8.258 0 0 0-.412-.833.051.051 0 0 0-.052-.025c-1.125.194-2.22.534-3.257 1.011a.041.041 0 0 0-.021.018C.356 6.024-.213 9.047.066 12.032c.001.014.01.028.021.037a13.276 13.276 0 0 0 3.995 2.02.05.05 0 0 0 .056-.019c.308-.42.582-.863.818-1.329a.05.05 0 0 0-.01-.059.051.051 0 0 0-.018-.011 8.875 8.875 0 0 1-1.248-.595.05.05 0 0 1-.02-.066.051.051 0 0 1 .015-.019c.084-.063.168-.129.248-.195a.05.05 0 0 1 .051-.007c2.619 1.196 5.454 1.196 8.041 0a.052.052 0 0 1 .053.007c.08.066.164.132.248.195a.051.051 0 0 1-.004.085 8.254 8.254 0 0 1-1.249.594.05.05 0 0 0-.03.03.052.052 0 0 0 .003.041c.24.465.515.909.817 1.329a.05.05 0 0 0 .056.019 13.235 13.235 0 0 0 4.001-2.02.049.049 0 0 0 .021-.037c.334-3.451-.559-6.449-2.366-9.106a.034.034 0 0 0-.02-.019Zm-8.198 7.307c-.789 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612Zm5.316 0c-.788 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612Z'/></svg>"));
var github = about["GitHub"];
if (!string.IsNullOrWhiteSpace(github))
links.Add(new("GitHub", github, "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'><path d='M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z'/></svg>"));
var twitter = about["Twitter"];
if (!string.IsNullOrWhiteSpace(twitter))
links.Add(new("Twitter", twitter, "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'><path d='M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865l8.875 11.633Z'/></svg>"));
var mastodon = about["Mastodon"];
if (!string.IsNullOrWhiteSpace(mastodon))
links.Add(new("Mastodon", mastodon, "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'><path d='M11.19 12.195c2.016-.24 3.77-1.475 3.99-2.603.348-1.778.32-4.339.32-4.339 0-3.47-2.286-4.488-2.286-4.488C12.062.238 10.083.017 8.027 0h-.05C5.92.017 3.942.238 2.79.765 2.79.765.504 1.783.504 5.253c-.005.76-.01 1.666.013 2.63.073 3.225.552 6.405 3.335 7.196 1.282.364 2.387.44 3.275.388 1.61-.094 2.515-.613 2.515-.613l-.053-1.154s-1.152.362-2.445.318c-1.28-.044-2.631-.139-2.838-1.723a3.2 3.2 0 0 1-.028-.462s1.255.309 2.846.382c.973.044 1.883-.058 2.808-.174Zm1.563-2.2v-4.14c0-.846-.215-1.518-.648-2.011-.447-.493-1.031-.745-1.758-.745-.842 0-1.48.324-1.9.974L8 4.66l-.447-.586c-.42-.65-1.058-.974-1.9-.974-.727 0-1.311.252-1.758.745-.434.493-.648 1.165-.648 2.012v4.139h1.686V5.12c0-.846.358-1.277 1.073-1.277.79 0 1.187.51 1.187 1.519V7.77h1.686V5.362c0-1.008.396-1.519 1.187-1.519.715 0 1.073.431 1.073 1.277v4.875h1.614Z'/></svg>"));
}
}
+50 -16
View File
@@ -137,8 +137,8 @@ else
}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<ul class="nav nav-tabs card-header-tabs">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<ul class="nav nav-tabs card-header-tabs" style="flex-grow: 0;">
<li class="nav-item">
<button class="nav-link @(activeTab == "recent" ? "active" : "")" @onclick='() => { activeTab = "recent"; recentPage = 1; }'>
Recent <span class="badge bg-secondary ms-1">@filteredRecent.Count</span>
@@ -150,8 +150,12 @@ else
</button>
</li>
</ul>
<div class="d-flex align-items-center gap-2">
<input type="search" class="form-control form-control-sm" style="max-width: 180px;"
placeholder="Search..." @bind="dashboardSearchQuery" @bind:event="oninput" @bind:after="OnDashboardSearchChanged" />
<button class="btn btn-sm btn-primary" @onclick="OpenAddModal">+ Add</button>
</div>
</div>
@if (data.Accounts.Count > 1)
{
<div class="px-3 py-2 border-bottom d-flex flex-wrap align-items-center gap-2">
@@ -303,19 +307,31 @@ else
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete Recurring Transaction</h5>
<h5 class="modal-title">@(pendingDeleteIsRecurring ? "Delete Recurring Transaction" : "Delete Transaction")</h5>
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
</div>
<div class="modal-body">
<p>
<strong>@pendingDeleteTransaction.Description</strong> is part of a recurring series.
</p>
<p>Would you like to delete just this transaction, or all future transactions in the series?</p>
<p>Are you sure you want to delete <strong>@pendingDeleteTransaction.Description</strong>?</p>
@if (pendingDeleteIsRecurring)
{
<p>This transaction is part of a recurring series. Would you like to delete just this one, or all future transactions in the series?</p>
}
else
{
<p class="text-danger mb-0">This action cannot be undone.</p>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
@if (pendingDeleteIsRecurring)
{
<button type="button" class="btn btn-outline-danger" @onclick="DeleteSingle">This One Only</button>
<button type="button" class="btn btn-danger" @onclick="DeleteSeries">All Future in Series</button>
}
else
{
<button type="button" class="btn btn-danger" @onclick="DeleteSingle">Delete</button>
}
</div>
</div>
</div>
@@ -347,12 +363,29 @@ else
// Account filter
private HashSet<int> hiddenAccountIds = [];
private string dashboardSearchQuery = string.Empty;
private List<Transaction> filteredRecent => data is null ? []
: data.RecentTransactions.Where(t => !hiddenAccountIds.Contains(t.AccountId)).ToList();
: data.RecentTransactions
.Where(t => !hiddenAccountIds.Contains(t.AccountId))
.Where(t => MatchesSearch(t))
.ToList();
private List<Transaction> filteredUpcoming => data is null ? []
: data.UpcomingTransactions.Where(t => !hiddenAccountIds.Contains(t.AccountId)).ToList();
: data.UpcomingTransactions
.Where(t => !hiddenAccountIds.Contains(t.AccountId))
.Where(t => MatchesSearch(t))
.ToList();
private bool MatchesSearch(Transaction t)
{
if (string.IsNullOrWhiteSpace(dashboardSearchQuery)) return true;
var q = dashboardSearchQuery.Trim();
return t.Description.Contains(q, StringComparison.OrdinalIgnoreCase)
|| t.Account.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
|| (t.Category?.Name?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false)
|| t.Amount.ToString("F2").Contains(q, StringComparison.OrdinalIgnoreCase);
}
private void ToggleAccountFilter(int accountId)
{
@@ -362,6 +395,12 @@ else
upcomingPage = 1;
}
private void OnDashboardSearchChanged()
{
recentPage = 1;
upcomingPage = 1;
}
// Pagination
private int recentPage = 1;
private int upcomingPage = 1;
@@ -509,19 +548,14 @@ else
// Delete confirmation state
private bool showDeleteModal;
private Transaction? pendingDeleteTransaction;
private bool pendingDeleteIsRecurring;
private void ConfirmDelete(Transaction t)
{
pendingDeleteTransaction = t;
if (t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null)
{
pendingDeleteIsRecurring = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
showDeleteModal = true;
}
else
{
_ = DeleteSingle();
}
}
private async Task DeleteSingle()
{
+55
View File
@@ -1,8 +1,10 @@
@page "/login"
@layout AuthLayout
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Identity
@inject SignInManager<ApplicationUser> SignInManager
@inject NavigationManager Navigation
@inject IAuthenticationSchemeProvider SchemeProvider
<PageTitle>Budgeteer — Sign In</PageTitle>
@@ -31,6 +33,26 @@
<button type="submit" class="btn btn-primary w-100">Sign In</button>
</EditForm>
@if (externalProviders.Count > 0)
{
<hr class="my-4" />
<p class="text-center text-muted mb-3">Or sign in with</p>
<div class="d-flex flex-column gap-2">
@foreach (var provider in externalProviders)
{
<form method="post" action="/api/auth/external-login">
<input type="hidden" name="provider" value="@provider.Name" />
<input type="hidden" name="returnUrl" value="/" />
<input type="hidden" name="__RequestVerificationToken" value="@antiforgeryToken" />
<button type="submit" class="btn btn-outline-secondary w-100 d-flex align-items-center justify-content-center gap-2">
@GetProviderIcon(provider.DisplayName ?? provider.Name)
<span>@(provider.DisplayName ?? provider.Name)</span>
</button>
</form>
}
</div>
}
<p class="text-center mt-3">
Don't have an account? <a href="/register">Register</a>
</p>
@@ -40,6 +62,31 @@
private LoginModel Model { get; set; } = new();
private string? errorMessage;
private List<AuthenticationScheme> externalProviders = [];
private string antiforgeryToken = string.Empty;
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
protected override async Task OnInitializedAsync()
{
var schemes = await SchemeProvider.GetAllSchemesAsync();
externalProviders = schemes
.Where(s => !string.IsNullOrEmpty(s.DisplayName)
&& s.Name != IdentityConstants.ApplicationScheme
&& s.Name != IdentityConstants.ExternalScheme
&& s.Name != IdentityConstants.TwoFactorUserIdScheme
&& s.Name != IdentityConstants.TwoFactorRememberMeScheme)
.ToList();
if (HttpContext is not null)
{
var tokens = HttpContext.RequestServices
.GetRequiredService<Microsoft.AspNetCore.Antiforgery.IAntiforgery>()
.GetAndStoreTokens(HttpContext);
antiforgeryToken = tokens.RequestToken ?? string.Empty;
}
}
private async Task HandleLogin()
{
@@ -58,6 +105,14 @@
}
}
private static MarkupString GetProviderIcon(string provider) => provider switch
{
"Google" => new MarkupString("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 24 24\"><path fill=\"#4285F4\" d=\"M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.27-4.74 3.27-8.1z\"/><path fill=\"#34A853\" d=\"M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z\"/><path fill=\"#FBBC05\" d=\"M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z\"/><path fill=\"#EA4335\" d=\"M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z\"/></svg>"),
"Microsoft" => new MarkupString("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 23 23\"><rect fill=\"#f25022\" x=\"1\" y=\"1\" width=\"10\" height=\"10\"/><rect fill=\"#00a4ef\" x=\"1\" y=\"12\" width=\"10\" height=\"10\"/><rect fill=\"#7fba00\" x=\"12\" y=\"1\" width=\"10\" height=\"10\"/><rect fill=\"#ffb900\" x=\"12\" y=\"12\" width=\"10\" height=\"10\"/></svg>"),
"GitHub" => new MarkupString("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 24 24\"><path d=\"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\"/></svg>"),
_ => new MarkupString("")
};
private sealed class LoginModel
{
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Email is required")]
+119 -12
View File
@@ -119,6 +119,8 @@ else
}
}
</select>
<input type="search" class="form-control form-control-sm" style="max-width: 220px;"
placeholder="Search..." @bind="searchQuery" @bind:event="oninput" @bind:after="OnSearchChanged" />
<div class="ms-auto d-flex align-items-center gap-2">
<label class="form-label mb-0 text-nowrap">Show:</label>
<select class="form-select form-select-sm" style="max-width: 90px;" @onchange="OnPageSizeChanged">
@@ -133,6 +135,9 @@ else
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 2rem;">
<input type="checkbox" class="form-check-input" checked="@IsAllPageSelected()" @onchange="ToggleSelectAll" />
</th>
<th role="button" class="user-select-none" @onclick='() => SetSort("date")'>Date @SortIndicator("date")</th>
<th role="button" class="user-select-none" @onclick='() => SetSort("description")'>Description @SortIndicator("description")</th>
<th>Account</th>
@@ -146,6 +151,9 @@ else
@foreach (var t in pagedTransactions)
{
<tr>
<td>
<input type="checkbox" class="form-check-input" checked="@selectedIds.Contains(t.Id)" @onchange="() => ToggleSelect(t.Id)" />
</td>
<td>@t.Date.ToString("MMM dd, yyyy")</td>
<td>@t.Description</td>
<td>
@@ -197,37 +205,75 @@ else
{
<div class="card-footer d-flex justify-content-between align-items-center">
<button class="btn btn-sm btn-outline-secondary" disabled="@(currentPage <= 1 ? true : (bool?)null)" @onclick="() => currentPage--">← Prev</button>
<div class="d-flex align-items-center gap-3">
@if (selectedIds.Count > 0)
{
<button class="btn btn-sm btn-danger" @onclick="ConfirmBulkDelete">
Delete @selectedIds.Count selected
</button>
}
<small class="text-muted">Page @currentPage of @totalPages (@filteredTransactions.Count transactions)</small>
</div>
<button class="btn btn-sm btn-outline-secondary" disabled="@(currentPage >= totalPages ? true : (bool?)null)" @onclick="() => currentPage++">Next →</button>
</div>
}
else
{
<div class="card-footer">
<div class="card-footer d-flex justify-content-between align-items-center">
<small class="text-muted">@filteredTransactions.Count transactions</small>
@if (selectedIds.Count > 0)
{
<button class="btn btn-sm btn-danger" @onclick="ConfirmBulkDelete">
Delete @selectedIds.Count selected
</button>
}
</div>
}
</div>
@if (showDeleteModal && pendingDeleteTransaction is not null)
@if (showDeleteModal)
{
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete Recurring Transaction</h5>
<h5 class="modal-title">@deleteModalTitle</h5>
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
</div>
<div class="modal-body">
<p>
<strong>@pendingDeleteTransaction.Description</strong> is part of a recurring series.
</p>
<p>Would you like to delete just this transaction, or all future transactions in the series?</p>
@if (bulkDeleteCount > 0)
{
<p>Are you sure you want to delete <strong>@bulkDeleteCount</strong> selected transactions?</p>
<p class="text-danger mb-0">This action cannot be undone.</p>
}
else if (pendingDeleteTransaction is not null)
{
<p>Are you sure you want to delete <strong>@pendingDeleteTransaction.Description</strong>?</p>
@if (pendingDeleteIsRecurring)
{
<p>This transaction is part of a recurring series. Would you like to delete just this one, or all future transactions in the series?</p>
}
else
{
<p class="text-danger mb-0">This action cannot be undone.</p>
}
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
@if (bulkDeleteCount > 0)
{
<button type="button" class="btn btn-danger" @onclick="ExecuteBulkDelete">Delete All Selected</button>
}
else if (pendingDeleteIsRecurring)
{
<button type="button" class="btn btn-outline-danger" @onclick="DeleteSingle">This One Only</button>
<button type="button" class="btn btn-danger" @onclick="DeleteSeries">All Future in Series</button>
}
else
{
<button type="button" class="btn btn-danger" @onclick="DeleteSingle">Delete</button>
}
</div>
</div>
</div>
@@ -315,6 +361,7 @@ else
private TransactionForm formModel = new();
private string? userId;
private int? filterAccountId;
private string searchQuery = string.Empty;
// Pagination state
private int currentPage = 1;
@@ -338,6 +385,16 @@ else
if (filterAccountId is not null)
list = list.Where(t => t.AccountId == filterAccountId);
if (!string.IsNullOrWhiteSpace(searchQuery))
{
var q = searchQuery.Trim();
list = list.Where(t =>
t.Description.Contains(q, StringComparison.OrdinalIgnoreCase)
|| t.Account.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
|| (t.Category?.Name?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false)
|| t.Amount.ToString("F2").Contains(q, StringComparison.OrdinalIgnoreCase));
}
list = sortColumn switch
{
"description" => sortAscending
@@ -361,6 +418,8 @@ else
currentPage = 1;
}
private void OnSearchChanged() => currentPage = 1;
private void OnPageSizeChanged(ChangeEventArgs e)
{
if (int.TryParse(e.Value?.ToString(), out var size))
@@ -433,9 +492,37 @@ else
await LoadData();
}
// Selection state
private HashSet<int> selectedIds = [];
private void ToggleSelect(int id)
{
if (!selectedIds.Remove(id))
selectedIds.Add(id);
}
private bool IsAllPageSelected() => pagedTransactions.Count > 0 && pagedTransactions.All(t => selectedIds.Contains(t.Id));
private void ToggleSelectAll(ChangeEventArgs e)
{
if (e.Value is true)
{
foreach (var t in pagedTransactions)
selectedIds.Add(t.Id);
}
else
{
foreach (var t in pagedTransactions)
selectedIds.Remove(t.Id);
}
}
// Delete confirmation state
private bool showDeleteModal;
private Transaction? pendingDeleteTransaction;
private bool pendingDeleteIsRecurring;
private int bulkDeleteCount;
private string deleteModalTitle = "Delete Transaction";
// Edit modal state
private bool showEditModal;
@@ -481,14 +568,19 @@ else
private void ConfirmDelete(Transaction t)
{
pendingDeleteTransaction = t;
if (t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null)
{
pendingDeleteIsRecurring = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
bulkDeleteCount = 0;
deleteModalTitle = pendingDeleteIsRecurring ? "Delete Recurring Transaction" : "Delete Transaction";
showDeleteModal = true;
}
else
private void ConfirmBulkDelete()
{
_ = DeleteSingle();
}
pendingDeleteTransaction = null;
pendingDeleteIsRecurring = false;
bulkDeleteCount = selectedIds.Count;
deleteModalTitle = $"Delete {bulkDeleteCount} Transactions";
showDeleteModal = true;
}
private async Task DeleteSingle()
@@ -496,6 +588,7 @@ else
if (userId is null || pendingDeleteTransaction is null) return;
showDeleteModal = false;
await TransactionService.DeleteTransactionAsync(pendingDeleteTransaction.Id, userId);
selectedIds.Remove(pendingDeleteTransaction.Id);
pendingDeleteTransaction = null;
await LoadData();
}
@@ -505,14 +598,28 @@ else
if (userId is null || pendingDeleteTransaction is null) return;
showDeleteModal = false;
await TransactionService.DeleteTransactionSeriesAsync(pendingDeleteTransaction.Id, userId);
selectedIds.Remove(pendingDeleteTransaction.Id);
pendingDeleteTransaction = null;
await LoadData();
}
private async Task ExecuteBulkDelete()
{
if (userId is null) return;
showDeleteModal = false;
foreach (var id in selectedIds)
{
await TransactionService.DeleteTransactionAsync(id, userId);
}
selectedIds.Clear();
await LoadData();
}
private void CancelDelete()
{
showDeleteModal = false;
pendingDeleteTransaction = null;
bulkDeleteCount = 0;
}
private sealed class TransactionForm : System.ComponentModel.DataAnnotations.IValidatableObject
+100
View File
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using BudgetApp.Components;
@@ -34,6 +35,47 @@ builder.Services.ConfigureApplicationCookie(options =>
options.LogoutPath = "/logout";
});
// External authentication providers (only registered when configured)
var authBuilder = builder.Services.AddAuthentication();
var googleSection = builder.Configuration.GetSection("Authentication:Google");
if (!string.IsNullOrEmpty(googleSection["ClientId"]) && !string.IsNullOrEmpty(googleSection["ClientSecret"]))
{
authBuilder.AddGoogle(options =>
{
options.ClientId = googleSection["ClientId"]!;
options.ClientSecret = googleSection["ClientSecret"]!;
});
}
var microsoftSection = builder.Configuration.GetSection("Authentication:Microsoft");
if (!string.IsNullOrEmpty(microsoftSection["ClientId"]) && !string.IsNullOrEmpty(microsoftSection["ClientSecret"]))
{
authBuilder.AddMicrosoftAccount(options =>
{
options.ClientId = microsoftSection["ClientId"]!;
options.ClientSecret = microsoftSection["ClientSecret"]!;
});
}
var githubSection = builder.Configuration.GetSection("Authentication:GitHub");
if (!string.IsNullOrEmpty(githubSection["ClientId"]) && !string.IsNullOrEmpty(githubSection["ClientSecret"]))
{
authBuilder.AddOAuth("GitHub", "GitHub", options =>
{
options.ClientId = githubSection["ClientId"]!;
options.ClientSecret = githubSection["ClientSecret"]!;
options.AuthorizationEndpoint = "https://github.com/login/oauth/authorize";
options.TokenEndpoint = "https://github.com/login/oauth/access_token";
options.UserInformationEndpoint = "https://api.github.com/user";
options.CallbackPath = "/signin-github";
options.Scope.Add("user:email");
options.ClaimActions.MapJsonKey(System.Security.Claims.ClaimTypes.NameIdentifier, "id");
options.ClaimActions.MapJsonKey(System.Security.Claims.ClaimTypes.Name, "login");
options.ClaimActions.MapJsonKey(System.Security.Claims.ClaimTypes.Email, "email");
});
}
// Application services
builder.Services.AddScoped<AccountService>();
builder.Services.AddScoped<TransactionService>();
@@ -75,4 +117,62 @@ app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
// External authentication endpoints
app.MapPost("/api/auth/external-login", (HttpContext context) =>
{
var provider = context.Request.Form["provider"].ToString();
var returnUrl = context.Request.Form["returnUrl"].ToString();
if (string.IsNullOrEmpty(returnUrl)) returnUrl = "/";
var redirectUrl = $"/api/auth/external-callback?returnUrl={Uri.EscapeDataString(returnUrl)}";
var properties = new Microsoft.AspNetCore.Authentication.AuthenticationProperties { RedirectUri = redirectUrl };
return Results.Challenge(properties, [provider]);
});
app.MapGet("/api/auth/external-callback", async (
HttpContext context,
SignInManager<ApplicationUser> signInManager,
UserManager<ApplicationUser> userManager,
string? returnUrl) =>
{
returnUrl ??= "/";
var info = await signInManager.GetExternalLoginInfoAsync();
if (info is null)
return Results.Redirect("/login");
var signInResult = await signInManager.ExternalLoginSignInAsync(
info.LoginProvider, info.ProviderKey, isPersistent: false);
if (signInResult.Succeeded)
return Results.Redirect(returnUrl);
// Auto-register: create user from external claims
var email = info.Principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value;
var userName = email ?? info.Principal.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value
?? $"{info.LoginProvider}_{info.ProviderKey}";
var user = new ApplicationUser { UserName = userName, Email = email };
var createResult = await userManager.CreateAsync(user);
if (createResult.Succeeded)
{
await userManager.AddLoginAsync(user, info);
await signInManager.SignInAsync(user, isPersistent: false);
return Results.Redirect(returnUrl);
}
// If the email is already taken, try linking
if (email is not null)
{
var existingUser = await userManager.FindByEmailAsync(email);
if (existingUser is not null)
{
await userManager.AddLoginAsync(existingUser, info);
await signInManager.SignInAsync(existingUser, isPersistent: false);
return Results.Redirect(returnUrl);
}
}
return Results.Redirect("/login");
});
app.Run();
+22 -1
View File
@@ -8,5 +8,26 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"About": {
"Email": "rossajmiller76@gmail.com",
"Discord": "",
"GitHub": "",
"Twitter": "",
"Mastodon": ""
},
"Authentication": {
"Google": {
"ClientId": "",
"ClientSecret": ""
},
"Microsoft": {
"ClientId": "",
"ClientSecret": ""
},
"GitHub": {
"ClientId": "",
"ClientSecret": ""
}
}
}
Binary file not shown.
Binary file not shown.