Security hardening
- Remove OwnerUserId from BudgetDto: OIDC sub of the budget owner was
being returned to all collaborators (including View-only users)
- Remove SharedWithUserId from ShareDto: other users' internal OIDC subs
were visible to anyone with read access to a budget
- Delete MeController: scaffolding endpoint that returned sub to the
browser; no legitimate frontend use case
- Restrict /healthz to require authorization: prevents unauthenticated
probing of database connectivity
- Add input validation annotations to all request DTOs: [Required],
[MaxLength], [Range(0,0.9999)] on EffectiveTaxRate, [EmailAddress] on
share email — [ApiController] now returns 400 instead of 500 for
invalid input hitting DB constraints
- Replace User.FindFirst("sub")!.Value with GetUserId() extension across
all controllers: returns 401 instead of NullReferenceException (500)
if a token lacks a sub claim
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,63 +13,72 @@ namespace Budget.Api.Controllers;
|
||||
[Authorize]
|
||||
public class BudgetsController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
|
||||
{
|
||||
private string UserId => User.FindFirst("sub")!.Value;
|
||||
private IActionResult? TryGetUserId(out string userId)
|
||||
{
|
||||
var id = User.GetUserId();
|
||||
if (id is null) { userId = string.Empty; return Unauthorized(); }
|
||||
userId = id;
|
||||
return null;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var userId = UserId;
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
var budgets = await db.Budgets
|
||||
.Where(b => b.OwnerUserId == userId ||
|
||||
b.Shares.Any(s => s.SharedWithUserId == userId && !s.IsPending))
|
||||
.Select(b => new BudgetDto(b.Id, b.Name, b.OwnerUserId, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt))
|
||||
.Select(b => new BudgetDto(b.Id, b.Name, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt))
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(budgets);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateBudgetRequest req)
|
||||
{
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
var budget = new Models.Budget
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = req.Name,
|
||||
OwnerUserId = UserId,
|
||||
OwnerUserId = userId,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.Budgets.Add(budget);
|
||||
await db.SaveChangesAsync();
|
||||
return CreatedAtAction(nameof(Get), new { id = budget.Id },
|
||||
new BudgetDto(budget.Id, budget.Name, budget.OwnerUserId, budget.EffectiveTaxRate, budget.CreatedAt, budget.UpdatedAt));
|
||||
new BudgetDto(budget.Id, budget.Name, budget.EffectiveTaxRate, budget.CreatedAt, budget.UpdatedAt));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
{
|
||||
if (!await authz.CanReadAsync(id, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanReadAsync(id, userId)) return Forbid();
|
||||
var b = await db.Budgets.FindAsync(id);
|
||||
if (b is null) return NotFound();
|
||||
return Ok(new BudgetDto(b.Id, b.Name, b.OwnerUserId, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt));
|
||||
return Ok(new BudgetDto(b.Id, b.Name, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateBudgetRequest req)
|
||||
{
|
||||
if (!await authz.CanWriteAsync(id, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanWriteAsync(id, userId)) return Forbid();
|
||||
var b = await db.Budgets.FindAsync(id);
|
||||
if (b is null) return NotFound();
|
||||
b.Name = req.Name;
|
||||
b.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new BudgetDto(b.Id, b.Name, b.OwnerUserId, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt));
|
||||
return Ok(new BudgetDto(b.Id, b.Name, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
if (!await authz.IsOwnerAsync(id, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.IsOwnerAsync(id, userId)) return Forbid();
|
||||
var b = await db.Budgets.FindAsync(id);
|
||||
if (b is null) return NotFound();
|
||||
db.Budgets.Remove(b);
|
||||
|
||||
@@ -13,7 +13,13 @@ namespace Budget.Api.Controllers;
|
||||
[Authorize]
|
||||
public class IncomesController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
|
||||
{
|
||||
private string UserId => User.FindFirst("sub")!.Value;
|
||||
private IActionResult? TryGetUserId(out string userId)
|
||||
{
|
||||
var id = User.GetUserId();
|
||||
if (id is null) { userId = string.Empty; return Unauthorized(); }
|
||||
userId = id;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IncomeDto ToDto(Income i) => new(
|
||||
i.Id, i.BudgetId, i.Name, i.Frequency, i.Amount,
|
||||
@@ -24,7 +30,8 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List(Guid budgetId)
|
||||
{
|
||||
if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
|
||||
var incomes = await db.Incomes
|
||||
.Where(i => i.BudgetId == budgetId)
|
||||
.OrderBy(i => i.SortOrder)
|
||||
@@ -35,7 +42,8 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(Guid budgetId, [FromBody] CreateIncomeRequest req)
|
||||
{
|
||||
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
|
||||
var maxOrder = await db.Incomes.Where(i => i.BudgetId == budgetId).MaxAsync(i => (int?)i.SortOrder) ?? -1;
|
||||
var income = new Income
|
||||
{
|
||||
@@ -54,7 +62,8 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz
|
||||
[HttpPut("{incomeId:guid}")]
|
||||
public async Task<IActionResult> Update(Guid budgetId, Guid incomeId, [FromBody] UpdateIncomeRequest req)
|
||||
{
|
||||
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
|
||||
var income = await db.Incomes.FirstOrDefaultAsync(i => i.Id == incomeId && i.BudgetId == budgetId);
|
||||
if (income is null) return NotFound();
|
||||
income.Name = req.Name;
|
||||
@@ -67,7 +76,8 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz
|
||||
[HttpDelete("{incomeId:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid budgetId, Guid incomeId)
|
||||
{
|
||||
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
|
||||
var income = await db.Incomes.FirstOrDefaultAsync(i => i.Id == incomeId && i.BudgetId == budgetId);
|
||||
if (income is null) return NotFound();
|
||||
db.Incomes.Remove(income);
|
||||
@@ -78,7 +88,8 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz
|
||||
[HttpPut("order")]
|
||||
public async Task<IActionResult> Reorder(Guid budgetId, [FromBody] ReorderIncomesRequest req)
|
||||
{
|
||||
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
|
||||
var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).ToListAsync();
|
||||
var lookup = incomes.ToDictionary(i => i.Id);
|
||||
for (int idx = 0; idx < req.OrderedIds.Count; idx++)
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Budget.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class MeController : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public IActionResult Get()
|
||||
{
|
||||
var sub = User.FindFirst("sub")?.Value;
|
||||
var email = User.FindFirst("email")?.Value;
|
||||
return Ok(new { sub, email });
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,13 @@ namespace Budget.Api.Controllers;
|
||||
[Authorize]
|
||||
public class OutgosController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
|
||||
{
|
||||
private string UserId => User.FindFirst("sub")!.Value;
|
||||
private IActionResult? TryGetUserId(out string userId)
|
||||
{
|
||||
var id = User.GetUserId();
|
||||
if (id is null) { userId = string.Empty; return Unauthorized(); }
|
||||
userId = id;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static OutgoDto ToDto(Outgo o, decimal totalMonthlyIncome) => new(
|
||||
o.Id, o.BudgetId, o.Name, o.Category, o.Type, o.Frequency, o.Amount,
|
||||
@@ -34,7 +40,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List(Guid budgetId)
|
||||
{
|
||||
if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
|
||||
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).OrderBy(o => o.SortOrder).ToListAsync();
|
||||
var monthlyIncome = await GetMonthlyIncomeAsync(budgetId);
|
||||
return Ok(outgos.Select(o => ToDto(o, monthlyIncome)));
|
||||
@@ -43,7 +50,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(Guid budgetId, [FromBody] CreateOutgoRequest req)
|
||||
{
|
||||
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
|
||||
var maxOrder = await db.Outgos.Where(o => o.BudgetId == budgetId).MaxAsync(o => (int?)o.SortOrder) ?? -1;
|
||||
var outgo = new Outgo
|
||||
{
|
||||
@@ -67,7 +75,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
|
||||
[HttpPut("{outgoId:guid}")]
|
||||
public async Task<IActionResult> Update(Guid budgetId, Guid outgoId, [FromBody] UpdateOutgoRequest req)
|
||||
{
|
||||
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
|
||||
var outgo = await db.Outgos.FirstOrDefaultAsync(o => o.Id == outgoId && o.BudgetId == budgetId);
|
||||
if (outgo is null) return NotFound();
|
||||
outgo.Name = req.Name;
|
||||
@@ -85,7 +94,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
|
||||
[HttpDelete("{outgoId:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid budgetId, Guid outgoId)
|
||||
{
|
||||
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
|
||||
var outgo = await db.Outgos.FirstOrDefaultAsync(o => o.Id == outgoId && o.BudgetId == budgetId);
|
||||
if (outgo is null) return NotFound();
|
||||
db.Outgos.Remove(outgo);
|
||||
@@ -96,7 +106,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
|
||||
[HttpPut("order")]
|
||||
public async Task<IActionResult> Reorder(Guid budgetId, [FromBody] ReorderOutgosRequest req)
|
||||
{
|
||||
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
|
||||
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).ToListAsync();
|
||||
var lookup = outgos.ToDictionary(o => o.Id);
|
||||
for (int idx = 0; idx < req.OrderedIds.Count; idx++)
|
||||
@@ -111,7 +122,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
|
||||
[HttpGet("categories")]
|
||||
public async Task<IActionResult> Categories(Guid budgetId)
|
||||
{
|
||||
if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
|
||||
var cats = await db.Outgos
|
||||
.Where(o => o.BudgetId == budgetId && o.Category != null)
|
||||
.Select(o => o.Category!)
|
||||
@@ -124,7 +136,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
|
||||
[HttpGet("payment-sources")]
|
||||
public async Task<IActionResult> PaymentSources(Guid budgetId)
|
||||
{
|
||||
if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
|
||||
var sources = await db.Outgos
|
||||
.Where(o => o.BudgetId == budgetId && o.PaymentSource != null)
|
||||
.Select(o => o.PaymentSource!)
|
||||
|
||||
@@ -13,15 +13,22 @@ namespace Budget.Api.Controllers;
|
||||
[Authorize]
|
||||
public class SharesController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
|
||||
{
|
||||
private string UserId => User.FindFirst("sub")!.Value;
|
||||
private IActionResult? TryGetUserId(out string userId)
|
||||
{
|
||||
var id = User.GetUserId();
|
||||
if (id is null) { userId = string.Empty; return Unauthorized(); }
|
||||
userId = id;
|
||||
return null;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List(Guid budgetId)
|
||||
{
|
||||
if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
|
||||
var shares = await db.BudgetShares
|
||||
.Where(s => s.BudgetId == budgetId)
|
||||
.Select(s => new ShareDto(s.Id, s.SharedWithUserId, s.SharedWithEmail, s.Permission, s.IsPending, s.CreatedAt))
|
||||
.Select(s => new ShareDto(s.Id, s.SharedWithEmail, s.Permission, s.IsPending, s.CreatedAt))
|
||||
.ToListAsync();
|
||||
return Ok(shares);
|
||||
}
|
||||
@@ -29,7 +36,8 @@ public class SharesController(AppDbContext db, BudgetAuthorizationService authz)
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Add(Guid budgetId, [FromBody] CreateShareRequest req)
|
||||
{
|
||||
if (!await authz.IsOwnerAsync(budgetId, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.IsOwnerAsync(budgetId, userId)) return Forbid();
|
||||
|
||||
var existing = await db.BudgetShares
|
||||
.FirstOrDefaultAsync(s => s.BudgetId == budgetId && s.SharedWithEmail == req.Email);
|
||||
@@ -48,24 +56,26 @@ public class SharesController(AppDbContext db, BudgetAuthorizationService authz)
|
||||
};
|
||||
db.BudgetShares.Add(share);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new ShareDto(share.Id, share.SharedWithUserId, share.SharedWithEmail, share.Permission, share.IsPending, share.CreatedAt));
|
||||
return Ok(new ShareDto(share.Id, share.SharedWithEmail, share.Permission, share.IsPending, share.CreatedAt));
|
||||
}
|
||||
|
||||
[HttpPut("{shareId:guid}")]
|
||||
public async Task<IActionResult> Update(Guid budgetId, Guid shareId, [FromBody] UpdateShareRequest req)
|
||||
{
|
||||
if (!await authz.IsOwnerAsync(budgetId, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.IsOwnerAsync(budgetId, userId)) return Forbid();
|
||||
var share = await db.BudgetShares.FirstOrDefaultAsync(s => s.Id == shareId && s.BudgetId == budgetId);
|
||||
if (share is null) return NotFound();
|
||||
share.Permission = req.Permission;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new ShareDto(share.Id, share.SharedWithUserId, share.SharedWithEmail, share.Permission, share.IsPending, share.CreatedAt));
|
||||
return Ok(new ShareDto(share.Id, share.SharedWithEmail, share.Permission, share.IsPending, share.CreatedAt));
|
||||
}
|
||||
|
||||
[HttpDelete("{shareId:guid}")]
|
||||
public async Task<IActionResult> Revoke(Guid budgetId, Guid shareId)
|
||||
{
|
||||
if (!await authz.IsOwnerAsync(budgetId, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.IsOwnerAsync(budgetId, userId)) return Forbid();
|
||||
var share = await db.BudgetShares.FirstOrDefaultAsync(s => s.Id == shareId && s.BudgetId == budgetId);
|
||||
if (share is null) return NotFound();
|
||||
db.BudgetShares.Remove(share);
|
||||
|
||||
@@ -13,12 +13,19 @@ namespace Budget.Api.Controllers;
|
||||
[Authorize]
|
||||
public class SummaryController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
|
||||
{
|
||||
private string UserId => User.FindFirst("sub")!.Value;
|
||||
private IActionResult? TryGetUserId(out string userId)
|
||||
{
|
||||
var id = User.GetUserId();
|
||||
if (id is null) { userId = string.Empty; return Unauthorized(); }
|
||||
userId = id;
|
||||
return null;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get(Guid budgetId)
|
||||
{
|
||||
if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
|
||||
|
||||
var budget = await db.Budgets.FindAsync(budgetId);
|
||||
if (budget is null) return NotFound();
|
||||
@@ -70,7 +77,8 @@ public class SummaryController(AppDbContext db, BudgetAuthorizationService authz
|
||||
[HttpPut("tax-rate")]
|
||||
public async Task<IActionResult> UpdateTaxRate(Guid budgetId, [FromBody] UpdateTaxRateRequest req)
|
||||
{
|
||||
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid();
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
|
||||
var budget = await db.Budgets.FindAsync(budgetId);
|
||||
if (budget is null) return NotFound();
|
||||
budget.EffectiveTaxRate = req.EffectiveTaxRate;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Budget.Api.DTOs;
|
||||
|
||||
public record BudgetDto(Guid Id, string Name, string OwnerUserId, decimal EffectiveTaxRate, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);
|
||||
public record BudgetDto(Guid Id, string Name, decimal EffectiveTaxRate, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);
|
||||
|
||||
public record CreateBudgetRequest(string Name);
|
||||
public record CreateBudgetRequest([Required][MaxLength(200)] string Name);
|
||||
|
||||
public record UpdateBudgetRequest(string Name);
|
||||
public record UpdateBudgetRequest([Required][MaxLength(200)] string Name);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Budget.Api.Models;
|
||||
|
||||
namespace Budget.Api.DTOs;
|
||||
@@ -12,8 +13,14 @@ public record IncomeDto(
|
||||
decimal Annually,
|
||||
int SortOrder);
|
||||
|
||||
public record CreateIncomeRequest(string Name, Frequency Frequency, decimal Amount);
|
||||
public record CreateIncomeRequest(
|
||||
[Required][MaxLength(200)] string Name,
|
||||
Frequency Frequency,
|
||||
[Range(0, 9_999_999_999_999_999.99)] decimal Amount);
|
||||
|
||||
public record UpdateIncomeRequest(string Name, Frequency Frequency, decimal Amount);
|
||||
public record UpdateIncomeRequest(
|
||||
[Required][MaxLength(200)] string Name,
|
||||
Frequency Frequency,
|
||||
[Range(0, 9_999_999_999_999_999.99)] decimal Amount);
|
||||
|
||||
public record ReorderIncomesRequest(List<Guid> OrderedIds);
|
||||
public record ReorderIncomesRequest([Required] List<Guid> OrderedIds);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Budget.Api.Models;
|
||||
|
||||
namespace Budget.Api.DTOs;
|
||||
@@ -18,21 +19,21 @@ public record OutgoDto(
|
||||
int SortOrder);
|
||||
|
||||
public record CreateOutgoRequest(
|
||||
string Name,
|
||||
string? Category,
|
||||
[Required][MaxLength(200)] string Name,
|
||||
[MaxLength(100)] string? Category,
|
||||
OutgoType Type,
|
||||
Frequency Frequency,
|
||||
decimal Amount,
|
||||
string? PaymentSource,
|
||||
string? Notes);
|
||||
[Range(0, 9_999_999_999_999_999.99)] decimal Amount,
|
||||
[MaxLength(100)] string? PaymentSource,
|
||||
[MaxLength(1000)] string? Notes);
|
||||
|
||||
public record UpdateOutgoRequest(
|
||||
string Name,
|
||||
string? Category,
|
||||
[Required][MaxLength(200)] string Name,
|
||||
[MaxLength(100)] string? Category,
|
||||
OutgoType Type,
|
||||
Frequency Frequency,
|
||||
decimal Amount,
|
||||
string? PaymentSource,
|
||||
string? Notes);
|
||||
[Range(0, 9_999_999_999_999_999.99)] decimal Amount,
|
||||
[MaxLength(100)] string? PaymentSource,
|
||||
[MaxLength(1000)] string? Notes);
|
||||
|
||||
public record ReorderOutgosRequest(List<Guid> OrderedIds);
|
||||
public record ReorderOutgosRequest([Required] List<Guid> OrderedIds);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Budget.Api.Models;
|
||||
|
||||
namespace Budget.Api.DTOs;
|
||||
|
||||
public record ShareDto(Guid Id, string? SharedWithUserId, string SharedWithEmail, SharePermission Permission, bool IsPending, DateTimeOffset CreatedAt);
|
||||
public record ShareDto(Guid Id, string SharedWithEmail, SharePermission Permission, bool IsPending, DateTimeOffset CreatedAt);
|
||||
|
||||
public record CreateShareRequest(string Email, SharePermission Permission);
|
||||
public record CreateShareRequest([Required][MaxLength(200)][EmailAddress] string Email, SharePermission Permission);
|
||||
|
||||
public record UpdateShareRequest(SharePermission Permission);
|
||||
|
||||
@@ -17,4 +17,6 @@ public record SummaryDto(
|
||||
List<SummaryBreakdownItem> Breakdown,
|
||||
PreTaxIncomeDto PreTaxIncome);
|
||||
|
||||
public record UpdateTaxRateRequest(decimal EffectiveTaxRate);
|
||||
public record UpdateTaxRateRequest(
|
||||
[System.ComponentModel.DataAnnotations.Range(0.0, 0.9999, ErrorMessage = "Effective tax rate must be between 0 and 0.9999.")]
|
||||
decimal EffectiveTaxRate);
|
||||
|
||||
@@ -62,7 +62,7 @@ app.MapHealthChecks("/healthz", new HealthCheckOptions
|
||||
[HealthStatus.Degraded] = StatusCodes.Status200OK,
|
||||
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable,
|
||||
}
|
||||
});
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapFallbackToFile("index.html");
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Budget.Api.Services;
|
||||
|
||||
public static class ClaimsPrincipalExtensions
|
||||
{
|
||||
/// <summary>Returns the sub claim, or null if absent.</summary>
|
||||
public static string? GetUserId(this ClaimsPrincipal user) =>
|
||||
user.FindFirst("sub")?.Value;
|
||||
}
|
||||
@@ -43,7 +43,6 @@ export interface OutgoDto {
|
||||
export interface BudgetDto {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
effectiveTaxRate: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -51,7 +50,6 @@ export interface BudgetDto {
|
||||
|
||||
export interface ShareDto {
|
||||
id: string;
|
||||
sharedWithUserId: string | null;
|
||||
sharedWithEmail: string;
|
||||
permission: SharePermission;
|
||||
isPending: boolean;
|
||||
|
||||
Reference in New Issue
Block a user