version-0.1 (#1)

Squash and merge of version-0.1

Co-authored-by: James Wampler <james@wamp.dev>
Co-committed-by: James Wampler <james@wamp.dev>
This commit was merged in pull request #1.
This commit is contained in:
2026-07-01 13:30:07 -07:00
committed by wamplerj
parent 8ff071c69b
commit 6887d09f9c
989 changed files with 76379 additions and 18042 deletions

View File

@@ -0,0 +1,3 @@
namespace MicCheck.Api.Users;
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);

0
src/api/MicCheck.Api/Users/RefreshToken.cs Normal file → Executable file
View File

View File

@@ -0,0 +1,3 @@
namespace MicCheck.Api.Users;
public record UpdateProfileRequest(string FirstName, string LastName, string Email);

0
src/api/MicCheck.Api/Users/User.cs Normal file → Executable file
View File

View File

@@ -0,0 +1,13 @@
namespace MicCheck.Api.Users;
public record UserResponse(
int Id,
string Email,
string FirstName,
string LastName,
DateTimeOffset CreatedAt,
DateTimeOffset? LastLoginAt)
{
public static UserResponse From(User user) =>
new(user.Id, user.Email, user.FirstName, user.LastName, user.CreatedAt, user.LastLoginAt);
}

View File

@@ -0,0 +1,44 @@
using MicCheck.Api.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Users;
public class UserService(MicCheckDbContext db, IPasswordHasher<User> passwordHasher)
{
public async Task<User?> FindByIdAsync(int userId, CancellationToken ct = default) =>
await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct);
public async Task<(User? User, bool EmailConflict)> UpdateProfileAsync(
int userId, string firstName, string lastName, string email, CancellationToken ct = default)
{
var user = await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct);
if (user is null) return (null, false);
if (!string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase))
{
var emailTaken = await db.Users.AnyAsync(u => u.Email == email && u.Id != userId, ct);
if (emailTaken) return (null, true);
}
user.FirstName = firstName;
user.LastName = lastName;
user.Email = email;
await db.SaveChangesAsync(ct);
return (user, false);
}
public async Task<bool> ChangePasswordAsync(
int userId, string currentPassword, string newPassword, CancellationToken ct = default)
{
var user = await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct);
if (user is null) return false;
var result = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, currentPassword);
if (result == PasswordVerificationResult.Failed) return false;
user.PasswordHash = passwordHasher.HashPassword(user, newPassword);
await db.SaveChangesAsync(ct);
return true;
}
}

View File

@@ -0,0 +1,67 @@
using MicCheck.Api.Common.Security.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Users;
[ApiController]
[Route("api/v1/user")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
public class UsersController(UserService userService) : ControllerBase
{
[HttpGet("{id:int}")]
public async Task<ActionResult<UserResponse>> GetById(int id, CancellationToken ct)
{
var user = await userService.FindByIdAsync(id, ct);
return user is null ? NotFound() : Ok(UserResponse.From(user));
}
[HttpGet("me")]
public async Task<ActionResult<UserResponse>> GetMe(CancellationToken ct)
{
var userId = GetCurrentUserId();
if (userId is null) return Unauthorized();
var user = await userService.FindByIdAsync(userId.Value, ct);
return user is null ? NotFound() : Ok(UserResponse.From(user));
}
[HttpPut("me")]
public async Task<ActionResult<UserResponse>> UpdateMe(UpdateProfileRequest request, CancellationToken ct)
{
var userId = GetCurrentUserId();
if (userId is null) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.FirstName) ||
string.IsNullOrWhiteSpace(request.LastName) ||
string.IsNullOrWhiteSpace(request.Email))
return BadRequest("First name, last name, and email are required.");
var (user, emailConflict) = await userService.UpdateProfileAsync(
userId.Value, request.FirstName.Trim(), request.LastName.Trim(), request.Email.Trim(), ct);
if (emailConflict) return Conflict("Email is already in use.");
return user is null ? NotFound() : Ok(UserResponse.From(user));
}
[HttpPost("me/change-password")]
public async Task<IActionResult> ChangePassword(ChangePasswordRequest request, CancellationToken ct)
{
var userId = GetCurrentUserId();
if (userId is null) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.CurrentPassword) || string.IsNullOrWhiteSpace(request.NewPassword))
return BadRequest("Current and new passwords are required.");
var success = await userService.ChangePasswordAsync(userId.Value, request.CurrentPassword, request.NewPassword, ct);
return success ? NoContent() : BadRequest("Current password is incorrect.");
}
private int? GetCurrentUserId()
{
var claim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return int.TryParse(claim, out var id) ? id : null;
}
}