UX consistancy changes.
This commit is contained in:
@@ -5,28 +5,28 @@ namespace MicCheck.Api.Audit;
|
||||
|
||||
public class AuditLogQueryService(MicCheckDbContext db)
|
||||
{
|
||||
public async Task<(int Total, IReadOnlyList<AuditLog> Items)> ListByOrganizationAsync(
|
||||
public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByOrganizationAsync(
|
||||
int organizationId, AuditLogFilter filter, CancellationToken ct = default)
|
||||
{
|
||||
var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId);
|
||||
return await ApplyFilterAndPageAsync(query, filter, ct);
|
||||
}
|
||||
|
||||
public async Task<(int Total, IReadOnlyList<AuditLog> Items)> ListByProjectAsync(
|
||||
public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByProjectAsync(
|
||||
int projectId, AuditLogFilter filter, CancellationToken ct = default)
|
||||
{
|
||||
var query = db.AuditLogs.Where(l => l.ProjectId == projectId);
|
||||
return await ApplyFilterAndPageAsync(query, filter, ct);
|
||||
}
|
||||
|
||||
public async Task<(int Total, IReadOnlyList<AuditLog> Items)> ListByEnvironmentAsync(
|
||||
public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByEnvironmentAsync(
|
||||
int environmentId, AuditLogFilter filter, CancellationToken ct = default)
|
||||
{
|
||||
var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId);
|
||||
return await ApplyFilterAndPageAsync(query, filter, ct);
|
||||
}
|
||||
|
||||
private static async Task<(int Total, IReadOnlyList<AuditLog> Items)> ApplyFilterAndPageAsync(
|
||||
private async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ApplyFilterAndPageAsync(
|
||||
IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
|
||||
{
|
||||
if (filter.From.HasValue)
|
||||
@@ -46,10 +46,21 @@ public class AuditLogQueryService(MicCheckDbContext db)
|
||||
|
||||
var total = await query.CountAsync(ct);
|
||||
var pageSize = Math.Clamp(filter.PageSize, 1, 100);
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(l => l.CreatedAt)
|
||||
.Skip((filter.Page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.GroupJoin(
|
||||
db.Users,
|
||||
log => log.ActorUserId,
|
||||
user => (int?)user.Id,
|
||||
(log, users) => new { log, users })
|
||||
.SelectMany(
|
||||
x => x.users.DefaultIfEmpty(),
|
||||
(x, user) => AuditLogResponse.From(
|
||||
x.log,
|
||||
user != null ? user.FirstName + " " + user.LastName : null))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return (total, items);
|
||||
|
||||
@@ -10,10 +10,11 @@ public record AuditLogResponse(
|
||||
int? ProjectId,
|
||||
int? EnvironmentId,
|
||||
int? ActorUserId,
|
||||
string? ActorUserName,
|
||||
DateTimeOffset CreatedAt
|
||||
)
|
||||
{
|
||||
public static AuditLogResponse From(AuditLog log) => new(
|
||||
public static AuditLogResponse From(AuditLog log, string? actorUserName = null) => new(
|
||||
log.Id,
|
||||
log.ResourceType,
|
||||
log.ResourceId,
|
||||
@@ -23,5 +24,6 @@ public record AuditLogResponse(
|
||||
log.ProjectId,
|
||||
log.EnvironmentId,
|
||||
log.ActorUserId,
|
||||
actorUserName,
|
||||
log.CreatedAt);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public class AuditLogsController(
|
||||
if (org is null) return NotFound();
|
||||
|
||||
var (total, logs) = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct);
|
||||
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.Select(AuditLogResponse.From).ToList()));
|
||||
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
|
||||
}
|
||||
|
||||
[HttpGet("api/v1/projects/{projectId}/audit-logs")]
|
||||
@@ -41,7 +41,7 @@ public class AuditLogsController(
|
||||
if (project is null) return NotFound();
|
||||
|
||||
var (total, logs) = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct);
|
||||
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.Select(AuditLogResponse.From).ToList()));
|
||||
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
|
||||
}
|
||||
|
||||
[HttpGet("api/v1/environments/{apiKey}/audit-logs")]
|
||||
@@ -54,6 +54,6 @@ public class AuditLogsController(
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var (total, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct);
|
||||
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.Select(AuditLogResponse.From).ToList()));
|
||||
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var (_, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct);
|
||||
return Ok(logs.Select(Audit.AuditLogResponse.From).ToList());
|
||||
return Ok(logs.ToList());
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/identities")]
|
||||
|
||||
@@ -132,6 +132,7 @@ try
|
||||
builder.Services.AddScoped<WebhookService>();
|
||||
builder.Services.AddScoped<WebhookDispatcher>();
|
||||
builder.Services.AddScoped<AdminIdentityService>();
|
||||
builder.Services.AddScoped<UserService>();
|
||||
builder.Services.AddSingleton<WebhookQueue>();
|
||||
builder.Services.AddHostedService<WebhookBackgroundService>();
|
||||
builder.Services.AddHostedService<WebhookRetryBackgroundService>();
|
||||
|
||||
3
src/api/MicCheck.Api/Users/ChangePasswordRequest.cs
Normal file
3
src/api/MicCheck.Api/Users/ChangePasswordRequest.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace MicCheck.Api.Users;
|
||||
|
||||
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
|
||||
3
src/api/MicCheck.Api/Users/UpdateProfileRequest.cs
Normal file
3
src/api/MicCheck.Api/Users/UpdateProfileRequest.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace MicCheck.Api.Users;
|
||||
|
||||
public record UpdateProfileRequest(string FirstName, string LastName, string Email);
|
||||
13
src/api/MicCheck.Api/Users/UserResponse.cs
Normal file
13
src/api/MicCheck.Api/Users/UserResponse.cs
Normal 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);
|
||||
}
|
||||
44
src/api/MicCheck.Api/Users/UserService.cs
Normal file
44
src/api/MicCheck.Api/Users/UserService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
67
src/api/MicCheck.Api/Users/UsersController.cs
Normal file
67
src/api/MicCheck.Api/Users/UsersController.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using MicCheck.Api.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace MicCheck.Api.Users;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/users")]
|
||||
[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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user