using System.Security.Claims; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Controllers; [ApiController] [Route("api/account-lifecycle")] [Authorize(AuthenticationSchemes = "local")] public sealed class AccountLifecycleController( JobTrackerContext db, AccountDeletionService deletions, TimeProvider timeProvider) : ControllerBase { public sealed record DeleteAccountRequest(string Confirmation); [HttpGet("status")] public async Task Status(CancellationToken cancellationToken) { var ownerUserId = LocalAuthIdentity.GetRequiredUserId(User); if (ownerUserId is null) return Unauthorized(); var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == ownerUserId, cancellationToken); if (user is null) return NotFound(); var request = await db.AccountDeletionRequests.AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderByDescending(item => item.Id).FirstOrDefaultAsync(cancellationToken); return Ok(new { deletionEnabled = deletions.CanAcceptRequests, deletionStatus = user.DeletionStatus, requiredConfirmation = $"DELETE {user.Email}", request = request is null ? null : ToDto(request), }); } [HttpPost("delete")] [EnableRateLimiting("account-data")] public async Task Delete([FromBody] DeleteAccountRequest request, CancellationToken cancellationToken) { if (!deletions.CanAcceptRequests) return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Account deletion unavailable", detail: "Account deletion remains disabled until retention and restore safeguards are approved."); var ownerUserId = LocalAuthIdentity.GetRequiredUserId(User); if (ownerUserId is null) return Unauthorized(); var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == ownerUserId, cancellationToken); if (user is null) return NotFound(); if (!string.Equals(request.Confirmation?.Trim(), $"DELETE {user.Email}", StringComparison.Ordinal)) return BadRequest("Type the exact confirmation shown before deleting the account."); if (!await IsRecentlyAuthenticated(ownerUserId, cancellationToken)) return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetails { Title = "Recent sign-in required", Detail = "Sign in again before deleting the account." }); if (await IsFinalAdministrator(ownerUserId, cancellationToken)) return Conflict(new ProblemDetails { Title = "Last administrator protected", Detail = "Assign the Admin role to another user before deleting the final administrator." }); var result = await deletions.RequestAsync(ownerUserId, ownerUserId, cancellationToken); if (result is null) return StatusCode(StatusCodes.Status503ServiceUnavailable); ExpireCookies(); return Accepted(new { requestId = result.RequestId, status = result.Status, stage = result.Stage }); } [HttpGet("admin/requests/{id:guid}")] [Authorize(Roles = "Admin")] public async Task AdminStatus(Guid id, CancellationToken cancellationToken) { var request = await db.AccountDeletionRequests.AsNoTracking().FirstOrDefaultAsync(item => item.Id == id, cancellationToken); return request is null ? NotFound() : Ok(ToDto(request)); } private async Task IsRecentlyAuthenticated(string ownerUserId, CancellationToken cancellationToken) { var sid = User.FindFirstValue("sid"); if (string.IsNullOrWhiteSpace(sid)) return false; var session = await db.UserSessions.IgnoreQueryFilters().AsNoTracking().FirstOrDefaultAsync(item => item.Id == sid && item.UserId == ownerUserId, cancellationToken); return session is { RevokedAtUtc: null } && session.CreatedAtUtc >= timeProvider.GetUtcNow().AddMinutes(-15); } private async Task IsFinalAdministrator(string ownerUserId, CancellationToken cancellationToken) { var adminRoleId = await db.Roles.Where(item => item.NormalizedName == "ADMIN").Select(item => item.Id).FirstOrDefaultAsync(cancellationToken); if (adminRoleId is null) return false; var isAdmin = await db.UserRoles.AnyAsync(item => item.UserId == ownerUserId && item.RoleId == adminRoleId, cancellationToken); return isAdmin && await db.UserRoles.CountAsync(item => item.RoleId == adminRoleId, cancellationToken) <= 1; } private void ExpireCookies() { Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(Request.IsHttps)); Response.Cookies.Delete(AuthSessionOptions.CsrfCookieName, AuthSessionOptions.BuildExpiredReadableCookie(Request.IsHttps)); TrustedDeviceService.ClearCookie(Response, Request.IsHttps); } private static object ToDto(AccountDeletionRequest request) => new { request.Id, request.Status, request.Stage, request.AttemptCount, request.DatabaseRowCount, request.FileCount, request.WarningJson, request.LastErrorCategory, request.LastErrorMessage, request.RequestedAtUtc, request.StartedAtUtc, request.CompletedAtUtc, }; }