feat(account): add deletion lifecycle
This commit is contained in:
@@ -199,7 +199,8 @@ public sealed class AccountDataExportTests
|
||||
.Options;
|
||||
var db = new JobTrackerContext(options, currentUser.Object);
|
||||
db.Database.EnsureCreated();
|
||||
return (db, paths, new AccountDataExportService(db, paths, new AttachmentStorage(paths), TimeProvider.System));
|
||||
var inventory = new AccountOwnedFileInventory(db, paths, new AttachmentStorage(paths));
|
||||
return (db, paths, new AccountDataExportService(db, paths, inventory, TimeProvider.System));
|
||||
}
|
||||
|
||||
private static string ReadText(ZipArchiveEntry entry)
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class AccountDeletionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Disabled_lifecycle_never_accepts_a_deletion_request()
|
||||
{
|
||||
await InFixtureAsync(enabled: false, async fixture =>
|
||||
{
|
||||
fixture.Db.Users.Add(User("owner"));
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
|
||||
Assert.Null(await fixture.Service.RequestAsync("owner", "owner", CancellationToken.None));
|
||||
Assert.Equal(AccountDeletionStatuses.Active, (await fixture.Db.Users.SingleAsync()).DeletionStatus);
|
||||
Assert.Empty(await fixture.Db.AccountDeletionRequests.ToListAsync());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Request_is_idempotent_and_immediately_locks_down_the_account()
|
||||
{
|
||||
await InFixtureAsync(enabled: true, async fixture =>
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
fixture.Db.Users.AddRange(User("owner"), User("other"));
|
||||
fixture.Db.CvVariants.Add(new CvVariant
|
||||
{
|
||||
OwnerUserId = "owner", PublicSlug = "public-owner", Name = "Owner CV", IsPublic = true,
|
||||
CreatedAtUtc = now, UpdatedAtUtc = now,
|
||||
});
|
||||
fixture.Db.UserSessions.Add(new UserSession
|
||||
{
|
||||
Id = "session", UserId = "owner", CreatedAtUtc = now, LastSeenAtUtc = now, ExpiresAtUtc = now.AddHours(1),
|
||||
});
|
||||
fixture.Db.TrustedDevices.Add(new TrustedDevice
|
||||
{
|
||||
UserId = "owner", TokenHash = "hash", CreatedAtUtc = now, LastSeenAtUtc = now, ExpiresAtUtc = now.AddDays(1),
|
||||
});
|
||||
var queued = Operation("owner", OperationStatuses.Queued);
|
||||
var running = Operation("owner", OperationStatuses.Running);
|
||||
fixture.Db.UserOperations.AddRange(queued, running);
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
|
||||
var first = await fixture.Service.RequestAsync("owner", "owner", CancellationToken.None);
|
||||
var second = await fixture.Service.RequestAsync("owner", "owner", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(first);
|
||||
Assert.Equal(first!.RequestId, second!.RequestId);
|
||||
Assert.Single(await fixture.Db.AccountDeletionRequests.ToListAsync());
|
||||
var user = await fixture.Db.Users.SingleAsync(item => item.Id == "owner");
|
||||
Assert.Equal(AccountDeletionStatuses.Pending, user.DeletionStatus);
|
||||
Assert.NotNull(user.DeletionRequestedAtUtc);
|
||||
Assert.False((await fixture.Db.CvVariants.IgnoreQueryFilters().SingleAsync()).IsPublic);
|
||||
Assert.NotNull((await fixture.Db.UserSessions.IgnoreQueryFilters().SingleAsync()).RevokedAtUtc);
|
||||
Assert.Empty(await fixture.Db.TrustedDevices.IgnoreQueryFilters().ToListAsync());
|
||||
Assert.Equal(OperationStatuses.Cancelled, (await fixture.Db.UserOperations.IgnoreQueryFilters().SingleAsync(item => item.Id == queued.Id)).Status);
|
||||
Assert.NotNull((await fixture.Db.UserOperations.IgnoreQueryFilters().SingleAsync(item => item.Id == running.Id)).CancellationRequestedAtUtc);
|
||||
Assert.Equal(AccountDeletionStatuses.Active, (await fixture.Db.Users.SingleAsync(item => item.Id == "other")).DeletionStatus);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completed_deletion_is_owner_isolated_purges_files_and_replays_after_restore()
|
||||
{
|
||||
await InFixtureAsync(enabled: true, async fixture =>
|
||||
{
|
||||
var owner = User("owner");
|
||||
var other = User("other");
|
||||
fixture.Db.Users.AddRange(owner, other);
|
||||
var ownerCompany = new Company { OwnerUserId = owner.Id, Name = "Owner company" };
|
||||
var otherCompany = new Company { OwnerUserId = other.Id, Name = "Other company" };
|
||||
fixture.Db.Companies.AddRange(ownerCompany, otherCompany);
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
var ownerApplication = new JobApplication { OwnerUserId = owner.Id, CompanyId = ownerCompany.Id, JobTitle = "Owner role" };
|
||||
var otherApplication = new JobApplication { OwnerUserId = other.Id, CompanyId = otherCompany.Id, JobTitle = "Other role" };
|
||||
fixture.Db.JobApplications.AddRange(ownerApplication, otherApplication);
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
|
||||
var attachmentPath = Path.Combine(fixture.Paths.AttachmentsRoot, ownerApplication.Id.ToString(), "owner.txt");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!);
|
||||
await File.WriteAllTextAsync(attachmentPath, "owner attachment");
|
||||
fixture.Db.Attachments.Add(new Attachment
|
||||
{
|
||||
JobApplicationId = ownerApplication.Id, FileName = "owner.txt", FilePath = attachmentPath,
|
||||
FileType = "text/plain", FileSize = new FileInfo(attachmentPath).Length,
|
||||
});
|
||||
var artifactRoot = Path.Combine(fixture.Paths.CvArtifactsRoot, AppPaths.GetOwnerStorageKey(owner.Id));
|
||||
Directory.CreateDirectory(artifactRoot);
|
||||
var artifactPath = Path.Combine(artifactRoot, "owner-cv.txt");
|
||||
await File.WriteAllTextAsync(artifactPath, "owner cv");
|
||||
fixture.Db.CvUploadArtifacts.Add(new CvUploadArtifact
|
||||
{
|
||||
OwnerUserId = owner.Id, OriginalFileName = "owner-cv.txt", StoredFileName = "owner-cv.txt",
|
||||
MimeType = "text/plain", ByteSize = new FileInfo(artifactPath).Length, Sha256 = "synthetic", StoragePath = artifactPath,
|
||||
});
|
||||
var otherOperation = Operation(other.Id, OperationStatuses.Queued);
|
||||
fixture.Db.UserOperations.Add(otherOperation);
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
var generatedRoot = Path.Combine(fixture.Paths.GetOwnerCvExportsRoot(owner.Id), "20260815");
|
||||
Directory.CreateDirectory(generatedRoot);
|
||||
var generatedPath = Path.Combine(generatedRoot, "owner.pdf");
|
||||
await File.WriteAllTextAsync(generatedPath, "owner pdf");
|
||||
var accountExportRoot = fixture.Paths.GetOwnerAccountExportsRoot(owner.Id);
|
||||
Directory.CreateDirectory(accountExportRoot);
|
||||
var accountExportPath = Path.Combine(accountExportRoot, "previous.zip");
|
||||
await File.WriteAllTextAsync(accountExportPath, "previous account export");
|
||||
|
||||
var accepted = await fixture.Service.RequestAsync(owner.Id, owner.Id, CancellationToken.None);
|
||||
Assert.NotNull(accepted);
|
||||
Assert.True(await fixture.Service.ProcessAsync(accepted!.RequestId, CancellationToken.None));
|
||||
|
||||
Assert.False(await fixture.Db.Users.AsNoTracking().AnyAsync(item => item.Id == owner.Id));
|
||||
Assert.True(await fixture.Db.Users.AsNoTracking().AnyAsync(item => item.Id == other.Id));
|
||||
Assert.False(await fixture.Db.Companies.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == owner.Id));
|
||||
Assert.True(await fixture.Db.Companies.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == other.Id));
|
||||
Assert.False(await fixture.Db.JobApplications.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == owner.Id));
|
||||
Assert.True(await fixture.Db.JobApplications.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == other.Id));
|
||||
Assert.True(await fixture.Db.UserOperations.IgnoreQueryFilters().AnyAsync(item => item.Id == otherOperation.Id));
|
||||
Assert.False(File.Exists(attachmentPath));
|
||||
Assert.False(File.Exists(artifactPath));
|
||||
Assert.False(File.Exists(generatedPath));
|
||||
Assert.False(File.Exists(accountExportPath));
|
||||
var request = await fixture.Db.AccountDeletionRequests.Include(item => item.Files).SingleAsync(item => item.Id == accepted.RequestId);
|
||||
Assert.Equal(AccountDeletionRequestStatuses.Completed, request.Status);
|
||||
Assert.Equal(AccountDeletionStages.Completed, request.Stage);
|
||||
Assert.NotEmpty(request.Files);
|
||||
Assert.All(request.Files, item => Assert.Equal("purged", item.Status));
|
||||
Assert.Single(await fixture.Tombstones.ReadAsync(CancellationToken.None));
|
||||
Assert.True(await fixture.Service.ProcessAsync(accepted.RequestId, CancellationToken.None));
|
||||
|
||||
fixture.Db.Users.Add(User(owner.Id));
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
Assert.Equal(1, await fixture.Service.StageRestoredAccountsAsync(CancellationToken.None));
|
||||
fixture.Db.ChangeTracker.Clear();
|
||||
Assert.Equal(AccountDeletionStatuses.Pending, (await fixture.Db.Users.AsNoTracking().SingleAsync(item => item.Id == owner.Id)).DeletionStatus);
|
||||
Assert.Equal(1, await fixture.Service.ProcessPendingAsync(CancellationToken.None));
|
||||
Assert.False(await fixture.Db.Users.AsNoTracking().AnyAsync(item => item.Id == owner.Id));
|
||||
Assert.Equal(2, (await fixture.Tombstones.ReadAsync(CancellationToken.None)).Count);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Quarantine_failure_restores_files_and_never_starts_database_deletion()
|
||||
{
|
||||
await InFixtureAsync(enabled: true, async fixture =>
|
||||
{
|
||||
fixture.Db.Users.Add(User("owner"));
|
||||
var source = Path.Combine(fixture.Paths.AttachmentsRoot, "source.txt");
|
||||
await File.WriteAllTextAsync(source, "must survive");
|
||||
var invalidTarget = source + ".quarantine";
|
||||
Directory.CreateDirectory(invalidTarget);
|
||||
var request = new AccountDeletionRequest
|
||||
{
|
||||
Id = Guid.NewGuid(), OwnerUserId = "owner", OwnerKey = AppPaths.GetOwnerStorageKey("owner"),
|
||||
RequestedByUserId = "owner", Status = AccountDeletionRequestStatuses.Pending,
|
||||
Stage = AccountDeletionStages.QuarantiningFiles, RequestedAtUtc = DateTimeOffset.UtcNow,
|
||||
Files =
|
||||
[
|
||||
new AccountDeletionFile
|
||||
{
|
||||
Category = "attachment", OriginalPath = source, QuarantinePath = invalidTarget,
|
||||
Status = "planned", ByteSize = new FileInfo(source).Length, Sha256 = "synthetic",
|
||||
},
|
||||
],
|
||||
};
|
||||
fixture.Db.AccountDeletionRequests.Add(request);
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
|
||||
Assert.False(await fixture.Service.ProcessAsync(request.Id, CancellationToken.None));
|
||||
fixture.Db.ChangeTracker.Clear();
|
||||
var failed = await fixture.Db.AccountDeletionRequests.SingleAsync(item => item.Id == request.Id);
|
||||
Assert.Equal(AccountDeletionRequestStatuses.RetryRequired, failed.Status);
|
||||
Assert.Equal(AccountDeletionStages.QuarantiningFiles, failed.Stage);
|
||||
Assert.True(File.Exists(source));
|
||||
Assert.True(await fixture.Db.Users.AnyAsync(item => item.Id == "owner"));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Self_service_requires_exact_phrase_and_recent_sign_in()
|
||||
{
|
||||
await InFixtureAsync(enabled: true, async fixture =>
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
fixture.Db.Users.Add(User("owner"));
|
||||
fixture.Db.UserSessions.Add(new UserSession
|
||||
{
|
||||
Id = "session", UserId = "owner", CreatedAtUtc = now.AddMinutes(-16),
|
||||
LastSeenAtUtc = now, ExpiresAtUtc = now.AddHours(1),
|
||||
});
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
var controller = Controller(fixture, "owner", "session");
|
||||
|
||||
Assert.IsType<BadRequestObjectResult>(await controller.Delete(new AccountLifecycleController.DeleteAccountRequest("DELETE wrong@example.test"), CancellationToken.None));
|
||||
var stale = Assert.IsType<ObjectResult>(await controller.Delete(new AccountLifecycleController.DeleteAccountRequest("DELETE owner@example.test"), CancellationToken.None));
|
||||
Assert.Equal(StatusCodes.Status403Forbidden, stale.StatusCode);
|
||||
|
||||
var session = await fixture.Db.UserSessions.SingleAsync();
|
||||
session.CreatedAtUtc = DateTimeOffset.UtcNow;
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
var accepted = Assert.IsType<AcceptedResult>(await controller.Delete(new AccountLifecycleController.DeleteAccountRequest("DELETE owner@example.test"), CancellationToken.None));
|
||||
Assert.Equal(StatusCodes.Status202Accepted, accepted.StatusCode);
|
||||
Assert.Equal(AccountDeletionStatuses.Pending, (await fixture.Db.Users.SingleAsync()).DeletionStatus);
|
||||
});
|
||||
}
|
||||
|
||||
private static ApplicationUser User(string id) => new()
|
||||
{
|
||||
Id = id, UserName = $"{id}@example.test", NormalizedUserName = $"{id.ToUpperInvariant()}@EXAMPLE.TEST",
|
||||
Email = $"{id}@example.test", NormalizedEmail = $"{id.ToUpperInvariant()}@EXAMPLE.TEST", EmailConfirmed = true,
|
||||
};
|
||||
|
||||
private static UserOperation Operation(string owner, string status) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(), OwnerUserId = owner, TaskType = "synthetic", IdempotencyKey = Guid.NewGuid().ToString("N"),
|
||||
Status = status, CreatedAtUtc = DateTime.UtcNow, AvailableAtUtc = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private static AccountLifecycleController Controller(Fixture fixture, string userId, string sessionId)
|
||||
{
|
||||
var controller = new AccountLifecycleController(fixture.Db, fixture.Service, TimeProvider.System)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
|
||||
};
|
||||
controller.ControllerContext.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
[
|
||||
new Claim(ClaimTypes.NameIdentifier, userId),
|
||||
new Claim("sid", sessionId),
|
||||
], "local"));
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static async Task InFixtureAsync(bool enabled, Func<Fixture, Task> test)
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"jobtracker-account-deletion-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(root);
|
||||
try
|
||||
{
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Data:Root"] = root,
|
||||
["AccountLifecycle:DeletionEnabled"] = enabled.ToString(),
|
||||
}).Build();
|
||||
var environment = new Mock<IHostEnvironment>();
|
||||
environment.SetupGet(item => item.ContentRootPath).Returns(root);
|
||||
var paths = new AppPaths(configuration, environment.Object);
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(item => item.UserId).Returns("owner");
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseSqlite($"Data Source={Path.Combine(root, "deletion-tests.db")}")
|
||||
.Options;
|
||||
await using var db = new JobTrackerContext(options, currentUser.Object);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var inventory = new AccountOwnedFileInventory(db, paths, new AttachmentStorage(paths));
|
||||
var tombstones = new AccountDeletionTombstoneStore(paths);
|
||||
var service = new AccountDeletionService(db, inventory, tombstones, configuration, cache, TimeProvider.System, NullLogger<AccountDeletionService>.Instance);
|
||||
await test(new Fixture(db, paths, tombstones, service));
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record Fixture(JobTrackerContext Db, AppPaths Paths, AccountDeletionTombstoneStore Tombstones, AccountDeletionService Service);
|
||||
}
|
||||
@@ -21,6 +21,9 @@ public sealed class AuthSessionRevocationTests
|
||||
public async Task Logout_revokes_the_exact_session_and_a_copied_principal_stops_working()
|
||||
{
|
||||
using var db = TestHostFactory.CreateInMemoryDb(null);
|
||||
db.Users.AddRange(
|
||||
new ApplicationUser { Id = "user-1", UserName = "one@example.test", Email = "one@example.test" },
|
||||
new ApplicationUser { Id = "user-2", UserName = "two@example.test", Email = "two@example.test" });
|
||||
db.UserSessions.Add(Session("sid-current", "user-1"));
|
||||
db.UserSessions.Add(Session("sid-other", "user-2"));
|
||||
await db.SaveChangesAsync();
|
||||
@@ -130,6 +133,17 @@ public sealed class AuthSessionRevocationTests
|
||||
Assert.True(await LocalSessionValidator.IsValidAsync(db, Principal("user-1", "sid-pending"), DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_validator_rejects_an_account_pending_deletion()
|
||||
{
|
||||
using var db = TestHostFactory.CreateInMemoryDb(null);
|
||||
db.Users.Add(new ApplicationUser { Id = "user-1", Email = "pending-delete@example.test", UserName = "pending-delete@example.test", DeletionStatus = AccountDeletionStatuses.Pending });
|
||||
db.UserSessions.Add(Session("sid-delete", "user-1"));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Assert.False(await LocalSessionValidator.IsValidAsync(db, Principal("user-1", "sid-delete"), DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Password_security_stamp_change_invalidates_a_pending_two_factor_challenge()
|
||||
{
|
||||
|
||||
@@ -161,6 +161,7 @@ public sealed class SessionsControllerTests
|
||||
public async Task List_returns_only_the_callers_own_active_sessions()
|
||||
{
|
||||
using var db = TestHostFactory.CreateInMemoryDb("user-1");
|
||||
db.Users.AddRange(new ApplicationUser { Id = "user-1" }, new ApplicationUser { Id = "user-2" });
|
||||
db.UserSessions.Add(NewSession("sid-mine", "user-1"));
|
||||
db.UserSessions.Add(NewSession("sid-other-user", "user-2"));
|
||||
db.UserSessions.Add(NewSession("sid-mine-expired", "user-1", expiresAtUtc: DateTimeOffset.UtcNow.AddHours(-1)));
|
||||
@@ -181,6 +182,7 @@ public sealed class SessionsControllerTests
|
||||
public async Task Revoke_enforces_ownership_and_blocks_a_subsequent_request_using_that_sessions_token()
|
||||
{
|
||||
using var db = TestHostFactory.CreateInMemoryDb("user-1");
|
||||
db.Users.AddRange(new ApplicationUser { Id = "user-1" }, new ApplicationUser { Id = "user-2" });
|
||||
db.UserSessions.Add(NewSession("sid-mine", "user-1"));
|
||||
db.UserSessions.Add(NewSession("sid-not-mine", "user-2"));
|
||||
await db.SaveChangesAsync();
|
||||
@@ -203,6 +205,7 @@ public sealed class SessionsControllerTests
|
||||
public async Task RevokeOthers_revokes_every_other_session_but_leaves_the_current_one_usable()
|
||||
{
|
||||
using var db = TestHostFactory.CreateInMemoryDb("user-1");
|
||||
db.Users.AddRange(new ApplicationUser { Id = "user-1" }, new ApplicationUser { Id = "user-2" });
|
||||
db.UserSessions.Add(NewSession("sid-current", "user-1"));
|
||||
db.UserSessions.Add(NewSession("sid-other-device", "user-1"));
|
||||
db.UserSessions.Add(NewSession("sid-not-mine", "user-2"));
|
||||
|
||||
@@ -67,6 +67,20 @@ public sealed class UsersControllerTests
|
||||
users.Verify(x => x.DeleteAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_never_falls_back_to_identity_only_removal()
|
||||
{
|
||||
var member = User("user-1");
|
||||
var users = TestHostFactory.CreateUserManager(member);
|
||||
users.Setup(x => x.IsInRoleAsync(member, "Admin")).ReturnsAsync(false);
|
||||
var controller = CreateController(users, "admin-1");
|
||||
|
||||
var result = Assert.IsType<ObjectResult>(await controller.Delete(member.Id, CancellationToken.None));
|
||||
|
||||
Assert.Equal(StatusCodes.Status503ServiceUnavailable, result.StatusCode);
|
||||
users.Verify(x => x.DeleteAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_marks_the_current_user_and_last_admin_safety_state()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<bool> 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<bool> 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,
|
||||
};
|
||||
}
|
||||
@@ -1087,6 +1087,8 @@ public sealed class AuthController : ControllerBase
|
||||
// decorative: skipping straight to AppSessionIssuer here would defeat the whole feature.
|
||||
private async Task<IActionResult> CompleteSignInAsync(ApplicationUser user, bool rememberMe, string provider, CancellationToken cancellationToken)
|
||||
{
|
||||
if (user.DeletionStatus != AccountDeletionStatuses.Active)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "account_deletion_pending" });
|
||||
// "Trust this device" cookie check happens BEFORE the 2FA gate: if it matches a
|
||||
// non-expired row for this exact user, skip straight to a real session, same as if 2FA
|
||||
// weren't required at all. Falls through to the normal gate for any other outcome
|
||||
|
||||
@@ -196,7 +196,7 @@ public sealed class TwoFactorController : ControllerBase
|
||||
if (session is null) return Unauthorized();
|
||||
|
||||
var user = await _users.FindByIdAsync(session.UserId);
|
||||
if (user is null || !user.TwoFactorEnabled || string.IsNullOrWhiteSpace(user.TotpSecretEncrypted))
|
||||
if (user is null || user.DeletionStatus != AccountDeletionStatuses.Active || !user.TwoFactorEnabled || string.IsNullOrWhiteSpace(user.TotpSecretEncrypted))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
@@ -19,13 +19,15 @@ public sealed class UsersController : ControllerBase
|
||||
private readonly IAppEmailSender _email;
|
||||
private readonly ExternalOrigin _externalOrigin;
|
||||
private readonly ILogger<UsersController> _logger;
|
||||
public UsersController(UserManager<ApplicationUser> users, RoleManager<IdentityRole> roles, IAppEmailSender email, IConfiguration cfg, ILogger<UsersController> logger, ExternalOrigin? externalOrigin = null)
|
||||
private readonly AccountDeletionService? _deletions;
|
||||
public UsersController(UserManager<ApplicationUser> users, RoleManager<IdentityRole> roles, IAppEmailSender email, IConfiguration cfg, ILogger<UsersController> logger, ExternalOrigin? externalOrigin = null, AccountDeletionService? deletions = null)
|
||||
{
|
||||
_users = users;
|
||||
_roles = roles;
|
||||
_email = email;
|
||||
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
|
||||
_logger = logger;
|
||||
_deletions = deletions;
|
||||
}
|
||||
|
||||
public sealed record UserDto(
|
||||
@@ -163,11 +165,16 @@ public sealed class UsersController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
var res = await _users.DeleteAsync(u);
|
||||
if (!res.Succeeded)
|
||||
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
|
||||
return NoContent();
|
||||
if (_deletions is null || !_deletions.CanAcceptRequests)
|
||||
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Account deletion unavailable", detail: "Account deletion remains disabled until retention and restore safeguards are approved.");
|
||||
if (!string.Equals(Request.Headers["X-Confirm-Account-Deletion"].ToString(), u.Email, StringComparison.OrdinalIgnoreCase))
|
||||
return BadRequest("Confirm the exact account email before deletion.");
|
||||
var requestedBy = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||
if (string.IsNullOrWhiteSpace(requestedBy)) return Unauthorized();
|
||||
var request = await _deletions.RequestAsync(u.Id, requestedBy, cancellationToken);
|
||||
return request is null
|
||||
? Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Account deletion unavailable")
|
||||
: Accepted(new { requestId = request.RequestId, status = request.Status, stage = request.Stage });
|
||||
}
|
||||
|
||||
[HttpPost("{id}/send-password-reset")]
|
||||
|
||||
@@ -63,6 +63,8 @@ namespace JobTrackerApi.Data
|
||||
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
|
||||
public DbSet<EmailSendAttempt> EmailSendAttempts => Set<EmailSendAttempt>();
|
||||
public DbSet<EmailDraft> EmailDrafts => Set<EmailDraft>();
|
||||
public DbSet<AccountDeletionRequest> AccountDeletionRequests => Set<AccountDeletionRequest>();
|
||||
public DbSet<AccountDeletionFile> AccountDeletionFiles => Set<AccountDeletionFile>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -80,6 +82,32 @@ namespace JobTrackerApi.Data
|
||||
.Property(x => x.MicrosoftObjectId)
|
||||
.HasMaxLength(36);
|
||||
|
||||
modelBuilder.Entity<ApplicationUser>()
|
||||
.Property(x => x.DeletionStatus)
|
||||
.HasMaxLength(32)
|
||||
.HasDefaultValue(AccountDeletionStatuses.Active);
|
||||
|
||||
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
||||
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.OwnerKey).HasMaxLength(64);
|
||||
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.RequestedByUserId).HasMaxLength(255);
|
||||
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.Status).HasMaxLength(32);
|
||||
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.Stage).HasMaxLength(32);
|
||||
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.LastErrorCategory).HasMaxLength(64);
|
||||
modelBuilder.Entity<AccountDeletionRequest>()
|
||||
.HasIndex(x => new { x.OwnerUserId, x.Status });
|
||||
modelBuilder.Entity<AccountDeletionRequest>()
|
||||
.HasIndex(x => new { x.Status, x.RequestedAtUtc });
|
||||
modelBuilder.Entity<AccountDeletionFile>().Property(x => x.Category).HasMaxLength(64);
|
||||
modelBuilder.Entity<AccountDeletionFile>().Property(x => x.Status).HasMaxLength(32);
|
||||
modelBuilder.Entity<AccountDeletionFile>().Property(x => x.Sha256).HasMaxLength(64);
|
||||
modelBuilder.Entity<AccountDeletionFile>()
|
||||
.HasIndex(x => new { x.AccountDeletionRequestId, x.Status });
|
||||
modelBuilder.Entity<AccountDeletionFile>()
|
||||
.HasOne(x => x.Request)
|
||||
.WithMany(x => x.Files)
|
||||
.HasForeignKey(x => x.AccountDeletionRequestId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Both supported databases allow multiple NULL values in a unique index, so legacy
|
||||
// rows remain unassigned while each proven tenant/object pair has exactly one owner.
|
||||
modelBuilder.Entity<ApplicationUser>()
|
||||
|
||||
+2842
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobTrackerApi.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAccountDeletionLifecycle : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
if (ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
ALTER TABLE `AspNetUsers`
|
||||
ADD COLUMN `DeletionRequestedAtUtc` datetime(6) NULL,
|
||||
ADD COLUMN `DeletionStatus` varchar(32) NOT NULL DEFAULT 'active';
|
||||
|
||||
CREATE TABLE `AccountDeletionRequests` (
|
||||
`Id` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`OwnerKey` char(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
||||
`RequestedByUserId` varchar(255) NOT NULL,
|
||||
`Status` varchar(32) NOT NULL,
|
||||
`Stage` varchar(32) NOT NULL,
|
||||
`AttemptCount` int NOT NULL,
|
||||
`DatabaseRowCount` int NOT NULL,
|
||||
`FileCount` int NOT NULL,
|
||||
`WarningJson` longtext NULL,
|
||||
`LastErrorCategory` varchar(64) NULL,
|
||||
`LastErrorMessage` varchar(1024) NULL,
|
||||
`RequestedAtUtc` datetime(6) NOT NULL,
|
||||
`StartedAtUtc` datetime(6) NULL,
|
||||
`CompletedAtUtc` datetime(6) NULL,
|
||||
CONSTRAINT `PK_AccountDeletionRequests` PRIMARY KEY (`Id`)
|
||||
) CHARACTER SET=utf8mb4;
|
||||
|
||||
CREATE TABLE `AccountDeletionFiles` (
|
||||
`Id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`AccountDeletionRequestId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
||||
`Category` varchar(64) NOT NULL,
|
||||
`OriginalPath` longtext NOT NULL,
|
||||
`QuarantinePath` longtext NOT NULL,
|
||||
`Status` varchar(32) NOT NULL,
|
||||
`ByteSize` bigint NOT NULL,
|
||||
`Sha256` char(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
||||
CONSTRAINT `PK_AccountDeletionFiles` PRIMARY KEY (`Id`),
|
||||
CONSTRAINT `FK_AccountDeletionFiles_AccountDeletionRequests_AccountDeletionRequestId`
|
||||
FOREIGN KEY (`AccountDeletionRequestId`) REFERENCES `AccountDeletionRequests` (`Id`) ON DELETE CASCADE
|
||||
) CHARACTER SET=utf8mb4;
|
||||
""");
|
||||
}
|
||||
else
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "DeletionRequestedAtUtc",
|
||||
table: "AspNetUsers",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DeletionStatus",
|
||||
table: "AspNetUsers",
|
||||
type: "TEXT",
|
||||
maxLength: 32,
|
||||
nullable: false,
|
||||
defaultValue: "active");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AccountDeletionRequests",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
OwnerUserId = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
|
||||
OwnerKey = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
RequestedByUserId = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
|
||||
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
Stage = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
AttemptCount = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
DatabaseRowCount = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
FileCount = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
WarningJson = table.Column<string>(type: "TEXT", nullable: true),
|
||||
LastErrorCategory = table.Column<string>(type: "TEXT", maxLength: 64, nullable: true),
|
||||
LastErrorMessage = table.Column<string>(type: "TEXT", nullable: true),
|
||||
RequestedAtUtc = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
StartedAtUtc = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
|
||||
CompletedAtUtc = table.Column<DateTimeOffset>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AccountDeletionRequests", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AccountDeletionFiles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
AccountDeletionRequestId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Category = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
OriginalPath = table.Column<string>(type: "TEXT", nullable: false),
|
||||
QuarantinePath = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
ByteSize = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
Sha256 = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AccountDeletionFiles", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AccountDeletionFiles_AccountDeletionRequests_AccountDeletionRequestId",
|
||||
column: x => x.AccountDeletionRequestId,
|
||||
principalTable: "AccountDeletionRequests",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
}
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AccountDeletionFiles_AccountDeletionRequestId_Status",
|
||||
table: "AccountDeletionFiles",
|
||||
columns: new[] { "AccountDeletionRequestId", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AccountDeletionRequests_OwnerUserId_Status",
|
||||
table: "AccountDeletionRequests",
|
||||
columns: new[] { "OwnerUserId", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AccountDeletionRequests_Status_RequestedAtUtc",
|
||||
table: "AccountDeletionRequests",
|
||||
columns: new[] { "Status", "RequestedAtUtc" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AccountDeletionFiles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AccountDeletionRequests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DeletionRequestedAtUtc",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DeletionStatus",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,116 @@ namespace JobTrackerApi.Migrations
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.14");
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.AccountDeletionFile", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("AccountDeletionRequestId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ByteSize")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OriginalPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("QuarantinePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Sha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountDeletionRequestId", "Status");
|
||||
|
||||
b.ToTable("AccountDeletionFiles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.AccountDeletionRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("AttemptCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("DatabaseRowCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("FileCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LastErrorCategory")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LastErrorMessage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OwnerKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OwnerUserId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("RequestedAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RequestedByUserId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset?>("StartedAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WarningJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerUserId", "Status");
|
||||
|
||||
b.HasIndex("Status", "RequestedAtUtc");
|
||||
|
||||
b.ToTable("AccountDeletionRequests");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.AiInteraction", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -209,6 +319,16 @@ namespace JobTrackerApi.Migrations
|
||||
b.Property<int?>("CurrentCvUploadArtifactId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletionRequestedAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DeletionStatus")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("active");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -2332,6 +2452,17 @@ namespace JobTrackerApi.Migrations
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.AccountDeletionFile", b =>
|
||||
{
|
||||
b.HasOne("JobTrackerApi.Models.AccountDeletionRequest", "Request")
|
||||
.WithMany("Files")
|
||||
.HasForeignKey("AccountDeletionRequestId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Request");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.AiInteraction", b =>
|
||||
{
|
||||
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
|
||||
@@ -2662,6 +2793,11 @@ namespace JobTrackerApi.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.AccountDeletionRequest", b =>
|
||||
{
|
||||
b.Navigation("Files");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.CareerProfile", b =>
|
||||
{
|
||||
b.Navigation("Certifications");
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
public static class AccountDeletionStatuses
|
||||
{
|
||||
public const string Active = "active";
|
||||
public const string Pending = "pending";
|
||||
public const string Completed = "completed";
|
||||
}
|
||||
|
||||
public static class AccountDeletionRequestStatuses
|
||||
{
|
||||
public const string Pending = "pending";
|
||||
public const string Processing = "processing";
|
||||
public const string RetryRequired = "retry_required";
|
||||
public const string Completed = "completed";
|
||||
}
|
||||
|
||||
public static class AccountDeletionStages
|
||||
{
|
||||
public const string Requested = "requested";
|
||||
public const string QuarantiningFiles = "quarantining_files";
|
||||
public const string DeletingDatabase = "deleting_database";
|
||||
public const string PurgingFiles = "purging_files";
|
||||
public const string RecordingTombstone = "recording_tombstone";
|
||||
public const string Completed = "completed";
|
||||
}
|
||||
|
||||
public sealed class AccountDeletionRequest
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string OwnerUserId { get; set; } = string.Empty;
|
||||
public string OwnerKey { get; set; } = string.Empty;
|
||||
public string RequestedByUserId { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = AccountDeletionRequestStatuses.Pending;
|
||||
public string Stage { get; set; } = AccountDeletionStages.Requested;
|
||||
public int AttemptCount { get; set; }
|
||||
public int DatabaseRowCount { get; set; }
|
||||
public int FileCount { get; set; }
|
||||
public string? WarningJson { get; set; }
|
||||
public string? LastErrorCategory { get; set; }
|
||||
public string? LastErrorMessage { get; set; }
|
||||
public DateTimeOffset RequestedAtUtc { get; set; }
|
||||
public DateTimeOffset? StartedAtUtc { get; set; }
|
||||
public DateTimeOffset? CompletedAtUtc { get; set; }
|
||||
public List<AccountDeletionFile> Files { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class AccountDeletionFile
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public Guid AccountDeletionRequestId { get; set; }
|
||||
public AccountDeletionRequest Request { get; set; } = null!;
|
||||
public string Category { get; set; } = string.Empty;
|
||||
public string OriginalPath { get; set; } = string.Empty;
|
||||
public string QuarantinePath { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = "planned";
|
||||
public long ByteSize { get; set; }
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -32,4 +32,6 @@ public sealed class ApplicationUser : IdentityUser
|
||||
public DateTime? StripeLastEventCreatedUtc { get; set; }
|
||||
public bool AiEnabled { get; set; } = true;
|
||||
public bool ExternalAiProcessingAllowed { get; set; }
|
||||
public string DeletionStatus { get; set; } = AccountDeletionStatuses.Active;
|
||||
public DateTimeOffset? DeletionRequestedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
@@ -46,7 +46,10 @@ builder.Services.AddSingleton<BackgroundTenantRunner>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddScoped<UserOperationStore>();
|
||||
builder.Services.AddScoped<EmailSendAttemptStore>();
|
||||
builder.Services.AddScoped<AccountOwnedFileInventory>();
|
||||
builder.Services.AddScoped<AccountDataExportService>();
|
||||
builder.Services.AddSingleton<AccountDeletionTombstoneStore>();
|
||||
builder.Services.AddScoped<AccountDeletionService>();
|
||||
builder.Services.AddScoped<AiOperationAdmission>();
|
||||
builder.Services.AddScoped<StrategySnapshotService>();
|
||||
builder.Services.AddSingleton<IAiOperationHandler, StrategySnapshotOperationHandler>();
|
||||
@@ -170,6 +173,7 @@ builder.Services.AddHostedService<JobEnrichmentHostedService>();
|
||||
builder.Services.AddHostedService<SummarizerProbeHostedService>();
|
||||
builder.Services.AddHostedService<AiOperationHostedService>();
|
||||
builder.Services.AddHostedService<EmailSendAttemptRecoveryHostedService>();
|
||||
builder.Services.AddHostedService<AccountDeletionHostedService>();
|
||||
|
||||
builder.Services.AddHttpClient("jobimport")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
@@ -552,6 +556,12 @@ app.Use(async (ctx, next) =>
|
||||
|
||||
await app.InitializeJobTrackerAsync();
|
||||
|
||||
await using (var deletionReplayScope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
var staged = await deletionReplayScope.ServiceProvider.GetRequiredService<AccountDeletionService>().StageRestoredAccountsAsync(CancellationToken.None);
|
||||
if (staged > 0) app.Logger.LogWarning("Staged {Count} restored deleted accounts for mandatory tombstone replay.", staged);
|
||||
}
|
||||
|
||||
await using (var attachmentScope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
var result = await attachmentScope.ServiceProvider.GetRequiredService<IAttachmentStorage>()
|
||||
|
||||
@@ -14,7 +14,7 @@ public sealed record AccountDataExportArtifact(string StoragePath, string Downlo
|
||||
public sealed class AccountDataExportService(
|
||||
JobTrackerContext db,
|
||||
AppPaths paths,
|
||||
IAttachmentStorage attachmentStorage,
|
||||
AccountOwnedFileInventory fileInventory,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
private const string SchemaVersion = "jobtracker.user-export.v1";
|
||||
@@ -32,8 +32,7 @@ public sealed class AccountDataExportService(
|
||||
var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == ownerUserId, cancellationToken)
|
||||
?? throw new InvalidOperationException("The account no longer exists.");
|
||||
var generatedAt = timeProvider.GetUtcNow();
|
||||
var ownerKey = AppPaths.GetOwnerStorageKey(ownerUserId);
|
||||
var outputRoot = Path.Combine(paths.DataRoot, "AccountExports", ownerKey);
|
||||
var outputRoot = paths.GetOwnerAccountExportsRoot(ownerUserId);
|
||||
Directory.CreateDirectory(outputRoot);
|
||||
var outputPath = Path.Combine(outputRoot, $"{Guid.NewGuid():N}.zip");
|
||||
var warnings = new List<string>();
|
||||
@@ -255,18 +254,15 @@ public sealed class AccountDataExportService(
|
||||
TrustedDevices = trustedDevices,
|
||||
}, sessions.Count + trustedDevices.Count + recoveryCodeCount);
|
||||
|
||||
foreach (var attachment in attachments)
|
||||
var ownedFiles = await fileInventory.BuildAsync(ownerUserId, cancellationToken);
|
||||
warnings.AddRange(ownedFiles.Warnings);
|
||||
foreach (var ownedFile in ownedFiles.Files)
|
||||
{
|
||||
await AddOwnedFileAsync(archive, entries, warnings, attachment.FilePath, $"files/attachments/{attachment.Id}/{SafeSegment(attachment.FileName)}", "attachment", attachmentStorage.IsManagedPath, cancellationToken);
|
||||
if (ownedFile.InlineBytes is not null)
|
||||
await AddBytesAsync(archive, entries, ownedFile.ExportPath, ownedFile.InlineBytes, ownedFile.Category, 1, cancellationToken);
|
||||
else
|
||||
await AddFileAsync(archive, entries, ownedFile.SourcePath!, ownedFile.ExportPath, ownedFile.Category, cancellationToken);
|
||||
}
|
||||
foreach (var artifact in artifacts)
|
||||
{
|
||||
await AddOwnedFileAsync(archive, entries, warnings, artifact.StoragePath, $"files/cv-artifacts/{artifact.Id}/{SafeSegment(artifact.OriginalFileName)}", "cv-artifact", path => IsManagedPath(paths.CvArtifactsRoot, path), cancellationToken);
|
||||
}
|
||||
|
||||
await AddAvatarAsync(archive, entries, warnings, paths, ownerUserId, user.AvatarImageDataUrl, cancellationToken);
|
||||
await AddDirectoryAsync(archive, entries, warnings, paths.GetOwnerCvExportsRoot(ownerUserId), "files/generated-cv", "generated-cv", cancellationToken);
|
||||
await AddDirectoryAsync(archive, entries, warnings, paths.GetOwnerDailyExportsRoot(null, ownerUserId), "files/daily-exports", "daily-export", cancellationToken);
|
||||
|
||||
const string readme = """
|
||||
Jobjakt readable account export
|
||||
@@ -317,77 +313,6 @@ public sealed class AccountDataExportService(
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task AddOwnedFileAsync(
|
||||
ZipArchive archive,
|
||||
ICollection<ManifestEntry> entries,
|
||||
ICollection<string> warnings,
|
||||
string sourcePath,
|
||||
string entryName,
|
||||
string category,
|
||||
Func<string, bool> isManaged,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!isManaged(sourcePath))
|
||||
{
|
||||
warnings.Add($"Excluded unsafe {category} path for {entryName}.");
|
||||
return;
|
||||
}
|
||||
if (!File.Exists(sourcePath))
|
||||
{
|
||||
warnings.Add($"Owned {category} file was unavailable: {entryName}.");
|
||||
return;
|
||||
}
|
||||
await AddFileAsync(archive, entries, sourcePath, entryName, category, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task AddDirectoryAsync(ZipArchive archive, ICollection<ManifestEntry> entries, ICollection<string> warnings, string root, string entryRoot, string category, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(root)) return;
|
||||
foreach (var path in Directory.EnumerateFiles(root, "*", new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.ReparsePoint,
|
||||
}))
|
||||
{
|
||||
var relative = Path.GetRelativePath(root, path);
|
||||
if (relative.StartsWith("..", StringComparison.Ordinal))
|
||||
{
|
||||
warnings.Add($"Excluded unsafe {category} path.");
|
||||
continue;
|
||||
}
|
||||
var entryName = $"{entryRoot}/{string.Join('/', relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Select(SafeSegment))}";
|
||||
await AddFileAsync(archive, entries, path, entryName, category, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task AddAvatarAsync(ZipArchive archive, ICollection<ManifestEntry> entries, ICollection<string> warnings, AppPaths paths, string ownerUserId, string? storedAvatar, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(storedAvatar)) return;
|
||||
if (storedAvatar.StartsWith("file:", StringComparison.Ordinal))
|
||||
{
|
||||
var path = storedAvatar[5..];
|
||||
var root = Path.Combine(paths.DataRoot, "Avatars", AppPaths.GetOwnerStorageKey(ownerUserId));
|
||||
await AddOwnedFileAsync(archive, entries, warnings, path, $"files/avatar/{SafeSegment(Path.GetFileName(path))}", "avatar", candidate => IsManagedPath(root, candidate), cancellationToken);
|
||||
return;
|
||||
}
|
||||
if (storedAvatar.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var comma = storedAvatar.IndexOf(',');
|
||||
if (comma > 0 && storedAvatar[..comma].Contains(";base64", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(storedAvatar[(comma + 1)..]);
|
||||
await AddBytesAsync(archive, entries, "files/avatar/avatar", bytes, "avatar", 1, cancellationToken);
|
||||
return;
|
||||
}
|
||||
catch (FormatException) { }
|
||||
}
|
||||
}
|
||||
warnings.Add("The profile avatar was stored in an unsupported format and could not be included.");
|
||||
}
|
||||
|
||||
private static async Task AddFileAsync(ZipArchive archive, ICollection<ManifestEntry> entries, string sourcePath, string entryName, string category, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var source = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
@@ -407,36 +332,5 @@ public sealed class AccountDataExportService(
|
||||
entries.Add(new ManifestEntry(entry.FullName, bytes.LongLength, Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(), category, itemCount));
|
||||
}
|
||||
|
||||
private static bool IsManagedPath(string root, string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return false;
|
||||
try
|
||||
{
|
||||
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
if (!fullPath.StartsWith(fullRoot + Path.DirectorySeparatorChar, comparison)) return false;
|
||||
var current = Path.GetDirectoryName(fullPath);
|
||||
while (!string.IsNullOrWhiteSpace(current) && !string.Equals(current, fullRoot, comparison))
|
||||
{
|
||||
if (Directory.Exists(current) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) return false;
|
||||
current = Path.GetDirectoryName(current);
|
||||
}
|
||||
return !File.Exists(fullPath) || (File.GetAttributes(fullPath) & FileAttributes.ReparsePoint) == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SafeSegment(string? value)
|
||||
{
|
||||
var candidate = Path.GetFileName(value ?? string.Empty).Trim();
|
||||
foreach (var invalid in Path.GetInvalidFileNameChars()) candidate = candidate.Replace(invalid, '_');
|
||||
if (candidate.Length > 120) candidate = candidate[..120];
|
||||
return string.IsNullOrWhiteSpace(candidate) || candidate is "." or ".." ? "file" : candidate;
|
||||
}
|
||||
|
||||
private sealed record ManifestEntry(string Path, long Bytes, string Sha256, string Category, int ItemCount);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record AccountDeletionRequestResult(Guid RequestId, string Status, string Stage);
|
||||
|
||||
public sealed class AccountDeletionService(
|
||||
JobTrackerContext db,
|
||||
AccountOwnedFileInventory fileInventory,
|
||||
AccountDeletionTombstoneStore tombstones,
|
||||
IConfiguration configuration,
|
||||
IMemoryCache memoryCache,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AccountDeletionService> logger)
|
||||
{
|
||||
private const int MaxAttempts = 20;
|
||||
public bool CanAcceptRequests => configuration.GetValue("AccountLifecycle:DeletionEnabled", false);
|
||||
|
||||
public async Task<AccountDeletionRequestResult?> RequestAsync(string ownerUserId, string requestedByUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!CanAcceptRequests) return null;
|
||||
var request = await RequestCoreAsync(ownerUserId, requestedByUserId, cancellationToken);
|
||||
return new AccountDeletionRequestResult(request.Id, request.Status, request.Stage);
|
||||
}
|
||||
|
||||
public async Task<int> StageRestoredAccountsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerKeys = (await tombstones.ReadAsync(cancellationToken)).Select(item => item.OwnerKey).ToHashSet(StringComparer.Ordinal);
|
||||
if (ownerKeys.Count == 0) return 0;
|
||||
var users = await db.Users.AsNoTracking().Select(item => item.Id).ToListAsync(cancellationToken);
|
||||
var restored = users.Where(item => ownerKeys.Contains(AppPaths.GetOwnerStorageKey(item))).ToList();
|
||||
foreach (var ownerUserId in restored)
|
||||
{
|
||||
await RequestCoreAsync(ownerUserId, "tombstone-replay", cancellationToken);
|
||||
}
|
||||
return restored.Count;
|
||||
}
|
||||
|
||||
public async Task<int> ProcessPendingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var requestIds = await db.AccountDeletionRequests.AsNoTracking()
|
||||
.Where(item => item.Status != AccountDeletionRequestStatuses.Completed && item.AttemptCount < MaxAttempts)
|
||||
.OrderBy(item => item.Id)
|
||||
.Select(item => item.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var completed = 0;
|
||||
foreach (var requestId in requestIds)
|
||||
{
|
||||
if (await ProcessAsync(requestId, cancellationToken)) completed++;
|
||||
}
|
||||
return completed;
|
||||
}
|
||||
|
||||
public async Task<bool> ProcessAsync(Guid requestId, CancellationToken cancellationToken)
|
||||
{
|
||||
// Requests are normally processed in a fresh background scope. Clearing here also makes
|
||||
// direct retries safe when the same scoped service accepted the request: ExecuteDelete
|
||||
// must not leave a previously tracked ApplicationUser pending for a later SaveChanges.
|
||||
db.ChangeTracker.Clear();
|
||||
var request = await db.AccountDeletionRequests.Include(item => item.Files).FirstOrDefaultAsync(item => item.Id == requestId, cancellationToken);
|
||||
if (request is null) return false;
|
||||
if (request.Status == AccountDeletionRequestStatuses.Completed) return true;
|
||||
request.Status = AccountDeletionRequestStatuses.Processing;
|
||||
request.AttemptCount++;
|
||||
request.StartedAtUtc ??= timeProvider.GetUtcNow();
|
||||
request.LastErrorCategory = null;
|
||||
request.LastErrorMessage = null;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
if (request.Stage == AccountDeletionStages.Requested)
|
||||
await PrepareFilesAsync(request, cancellationToken);
|
||||
if (request.Stage == AccountDeletionStages.QuarantiningFiles)
|
||||
await QuarantineFilesAsync(request, cancellationToken);
|
||||
if (request.Stage == AccountDeletionStages.DeletingDatabase)
|
||||
await DeleteDatabaseRowsAsync(request, cancellationToken);
|
||||
if (request.Stage == AccountDeletionStages.PurgingFiles)
|
||||
await PurgeFilesAsync(request, cancellationToken);
|
||||
if (request.Stage == AccountDeletionStages.RecordingTombstone)
|
||||
await CompleteAsync(request, cancellationToken);
|
||||
return request.Status == AccountDeletionRequestStatuses.Completed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
request.Status = AccountDeletionRequestStatuses.RetryRequired;
|
||||
request.LastErrorCategory = Classify(ex);
|
||||
request.LastErrorMessage = Sanitize(ex.Message);
|
||||
try { await db.SaveChangesAsync(cancellationToken); }
|
||||
catch (Exception saveError) { logger.LogError(saveError, "Could not persist account deletion failure for {RequestId}", request.Id); }
|
||||
logger.LogWarning(ex, "Account deletion request {RequestId} stopped at {Stage}; it remains retryable", request.Id, request.Stage);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AccountDeletionRequest> RequestCoreAsync(string ownerUserId, string requestedByUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await db.AccountDeletionRequests
|
||||
.Where(item => item.OwnerUserId == ownerUserId && item.Status != AccountDeletionRequestStatuses.Completed)
|
||||
.OrderBy(item => item.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (existing is not null) return existing;
|
||||
|
||||
var user = await db.Users.FirstOrDefaultAsync(item => item.Id == ownerUserId, cancellationToken)
|
||||
?? throw new InvalidOperationException("The account no longer exists.");
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var request = new AccountDeletionRequest
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = ownerUserId,
|
||||
OwnerKey = AppPaths.GetOwnerStorageKey(ownerUserId),
|
||||
RequestedByUserId = requestedByUserId,
|
||||
Status = AccountDeletionRequestStatuses.Pending,
|
||||
Stage = AccountDeletionStages.Requested,
|
||||
RequestedAtUtc = now,
|
||||
};
|
||||
user.DeletionStatus = AccountDeletionStatuses.Pending;
|
||||
user.DeletionRequestedAtUtc = now;
|
||||
user.SecurityStamp = Guid.NewGuid().ToString();
|
||||
foreach (var variant in await db.CvVariants.IgnoreQueryFilters().Where(item => item.OwnerUserId == ownerUserId && item.IsPublic).ToListAsync(cancellationToken))
|
||||
variant.IsPublic = false;
|
||||
foreach (var session in await db.UserSessions.IgnoreQueryFilters().Where(item => item.UserId == ownerUserId && item.RevokedAtUtc == null).ToListAsync(cancellationToken))
|
||||
session.RevokedAtUtc = now;
|
||||
db.TrustedDevices.RemoveRange(await db.TrustedDevices.IgnoreQueryFilters().Where(item => item.UserId == ownerUserId).ToListAsync(cancellationToken));
|
||||
var operations = await db.UserOperations.IgnoreQueryFilters().Where(item => item.OwnerUserId == ownerUserId
|
||||
&& item.Status != OperationStatuses.Succeeded
|
||||
&& item.Status != OperationStatuses.Failed
|
||||
&& item.Status != OperationStatuses.Cancelled).ToListAsync(cancellationToken);
|
||||
foreach (var operation in operations)
|
||||
{
|
||||
if (operation.Status == OperationStatuses.Running) operation.CancellationRequestedAtUtc = now.UtcDateTime;
|
||||
else
|
||||
{
|
||||
operation.Status = OperationStatuses.Cancelled;
|
||||
operation.CompletedAtUtc = now.UtcDateTime;
|
||||
operation.LeaseToken = null;
|
||||
operation.LeaseExpiresAtUtc = null;
|
||||
}
|
||||
}
|
||||
db.AccountDeletionRequests.Add(request);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return request;
|
||||
}
|
||||
|
||||
private async Task PrepareFilesAsync(AccountDeletionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var inventory = await fileInventory.BuildAsync(request.OwnerUserId, cancellationToken, includeAccountExports: true);
|
||||
if (inventory.Warnings.Any(item => item.StartsWith("Excluded unsafe", StringComparison.Ordinal)))
|
||||
throw new InvalidOperationException("One or more owned file paths failed the managed-root safety check.");
|
||||
request.WarningJson = JsonSerializer.Serialize(inventory.Warnings);
|
||||
foreach (var ownedFile in inventory.Files.Where(item => item.SourcePath is not null))
|
||||
{
|
||||
var sourcePath = ownedFile.SourcePath!;
|
||||
if (request.Files.Any(item => string.Equals(item.OriginalPath, sourcePath, PathComparison))) continue;
|
||||
await using var source = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
var hash = Convert.ToHexString(await SHA256.HashDataAsync(source, cancellationToken)).ToLowerInvariant();
|
||||
request.Files.Add(new AccountDeletionFile
|
||||
{
|
||||
AccountDeletionRequestId = request.Id,
|
||||
Category = ownedFile.Category,
|
||||
OriginalPath = sourcePath,
|
||||
QuarantinePath = sourcePath + $".{request.Id:N}.account-deleting",
|
||||
Status = "planned",
|
||||
ByteSize = source.Length,
|
||||
Sha256 = hash,
|
||||
});
|
||||
}
|
||||
request.FileCount = request.Files.Count;
|
||||
request.Stage = AccountDeletionStages.QuarantiningFiles;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task QuarantineFilesAsync(AccountDeletionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var file in request.Files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (File.Exists(file.QuarantinePath))
|
||||
{
|
||||
file.Status = "quarantined";
|
||||
continue;
|
||||
}
|
||||
if (!File.Exists(file.OriginalPath))
|
||||
{
|
||||
file.Status = "missing";
|
||||
AppendWarning(request, $"Owned {file.Category} file disappeared before quarantine.");
|
||||
continue;
|
||||
}
|
||||
File.Move(file.OriginalPath, file.QuarantinePath, overwrite: false);
|
||||
file.Status = "quarantined";
|
||||
}
|
||||
request.Stage = AccountDeletionStages.DeletingDatabase;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
RestoreQuarantinedFiles(request);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteDatabaseRowsAsync(AccountDeletionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var file in request.Files)
|
||||
{
|
||||
if (File.Exists(file.OriginalPath) || (file.Status != "missing" && !File.Exists(file.QuarantinePath)))
|
||||
throw new InvalidOperationException("Owned files are not fully quarantined; database deletion was not started.");
|
||||
}
|
||||
|
||||
var transaction = db.Database.IsRelational() ? await db.Database.BeginTransactionAsync(cancellationToken) : null;
|
||||
try
|
||||
{
|
||||
var owner = request.OwnerUserId;
|
||||
var applicationIds = await db.JobApplications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).Select(item => item.Id).ToListAsync(cancellationToken);
|
||||
var variantIds = await db.CvVariants.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).Select(item => item.Id).ToListAsync(cancellationToken);
|
||||
var deleted = 0;
|
||||
deleted += await db.EmailDrafts.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.EmailSendAttempts.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.ApplicationChecklistItems.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CoverLetterVersions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.InterviewPrepItems.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.InterviewPrepNotes.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.AiWorkspaceNotes.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.AiInteractions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.TailoredCvDrafts.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.Correspondences.IgnoreQueryFilters().Where(item => applicationIds.Contains(item.JobApplicationId)).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.JobEvents.IgnoreQueryFilters().Where(item => applicationIds.Contains(item.JobApplicationId)).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.Attachments.IgnoreQueryFilters().Where(item => applicationIds.Contains(item.JobApplicationId)).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.GmailReviewDecisions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CvVariantVersions.IgnoreQueryFilters().Where(item => variantIds.Contains(item.CvVariantId)).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CvVariants.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CareerExperiences.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CareerEducations.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CareerSkills.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CareerProjects.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CareerCertifications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CareerLanguages.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CareerProfileVersions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CareerProfiles.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CvExtractionRuns.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CvUploadArtifacts.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
if (await db.GmailConnections.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == owner, cancellationToken))
|
||||
AppendWarning(request, "Google consent was not revoked remotely; local Gmail credentials were deleted.");
|
||||
deleted += await db.GmailConnections.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.MicrosoftGraphConnections.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.ImapConnections.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.UserRuleSettings.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.UserNotifications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.UserOperations.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.JobApplications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.Jobs.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.Companies.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.TwoFactorRecoveryCodes.IgnoreQueryFilters().Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.TrustedDevices.IgnoreQueryFilters().Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.UserSessions.IgnoreQueryFilters().Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.UserClaims.Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.UserLogins.Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.UserTokens.Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.UserRoles.Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.Users.Where(item => item.Id == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
request.DatabaseRowCount = deleted;
|
||||
request.Stage = AccountDeletionStages.PurgingFiles;
|
||||
request.Status = AccountDeletionRequestStatuses.Processing;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (transaction is not null)
|
||||
{
|
||||
try { await transaction.RollbackAsync(CancellationToken.None); }
|
||||
catch (Exception rollbackError) { logger.LogError(rollbackError, "Account deletion transaction rollback outcome is uncertain for {RequestId}", request.Id); }
|
||||
}
|
||||
|
||||
// A commit can succeed at the database and still lose the acknowledgement. Restore
|
||||
// quarantined files only when the owner row proves the database deletion rolled back.
|
||||
// When the outcome cannot be read, leave files quarantined and safely replay deletion.
|
||||
try
|
||||
{
|
||||
if (await db.Users.AsNoTracking().AnyAsync(item => item.Id == request.OwnerUserId, CancellationToken.None))
|
||||
{
|
||||
RestoreQuarantinedFiles(request);
|
||||
request.Stage = AccountDeletionStages.DeletingDatabase;
|
||||
}
|
||||
}
|
||||
catch (Exception verificationError)
|
||||
{
|
||||
logger.LogError(verificationError, "Could not verify database deletion outcome for {RequestId}; files remain quarantined", request.Id);
|
||||
request.Stage = AccountDeletionStages.DeletingDatabase;
|
||||
}
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (transaction is not null) await transaction.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PurgeFilesAsync(AccountDeletionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var file in request.Files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (File.Exists(file.QuarantinePath)) File.Delete(file.QuarantinePath);
|
||||
file.Status = "purged";
|
||||
}
|
||||
if (memoryCache is MemoryCache cache) cache.Compact(1.0);
|
||||
AppendWarning(request, "The local AI sidecar cache is content-keyed and ages out under its configured TTL; production deletion remains disabled until cache purge/restart is rehearsed.");
|
||||
request.Stage = AccountDeletionStages.RecordingTombstone;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task CompleteAsync(AccountDeletionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var completedAt = timeProvider.GetUtcNow();
|
||||
await tombstones.AppendAsync(request.Id, request.OwnerKey, completedAt, cancellationToken);
|
||||
request.Stage = AccountDeletionStages.Completed;
|
||||
request.Status = AccountDeletionRequestStatuses.Completed;
|
||||
request.CompletedAtUtc = completedAt;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static void RestoreQuarantinedFiles(AccountDeletionRequest request)
|
||||
{
|
||||
foreach (var file in request.Files.Where(item => File.Exists(item.QuarantinePath) && !File.Exists(item.OriginalPath)))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Move(file.QuarantinePath, file.OriginalPath, overwrite: false);
|
||||
file.Status = "planned";
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendWarning(AccountDeletionRequest request, string warning)
|
||||
{
|
||||
var warnings = string.IsNullOrWhiteSpace(request.WarningJson)
|
||||
? new List<string>()
|
||||
: JsonSerializer.Deserialize<List<string>>(request.WarningJson) ?? new List<string>();
|
||||
if (!warnings.Contains(warning, StringComparer.Ordinal)) warnings.Add(warning);
|
||||
request.WarningJson = JsonSerializer.Serialize(warnings);
|
||||
}
|
||||
|
||||
private static string Classify(Exception exception) => exception switch
|
||||
{
|
||||
IOException => "file_io",
|
||||
UnauthorizedAccessException => "file_access",
|
||||
DbUpdateException => "database",
|
||||
OperationCanceledException => "cancelled",
|
||||
_ => "unexpected",
|
||||
};
|
||||
|
||||
private static string Sanitize(string value)
|
||||
{
|
||||
var message = value.Replace('\r', ' ').Replace('\n', ' ').Trim();
|
||||
return message.Length > 500 ? message[..500] : message;
|
||||
}
|
||||
|
||||
private static StringComparison PathComparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
}
|
||||
|
||||
public sealed class AccountDeletionHostedService(
|
||||
IServiceScopeFactory scopes,
|
||||
IStartupReadiness startupReadiness,
|
||||
ILogger<AccountDeletionHostedService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await startupReadiness.WaitUntilReadyAsync(stoppingToken);
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopes.CreateAsyncScope();
|
||||
await scope.ServiceProvider.GetRequiredService<AccountDeletionService>().ProcessPendingAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { }
|
||||
catch (Exception ex) { logger.LogError(ex, "Account deletion reconciliation failed; durable requests remain retryable"); }
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record AccountDeletionTombstone(string SchemaVersion, Guid RequestId, string OwnerKey, DateTimeOffset CompletedAtUtc);
|
||||
|
||||
public sealed class AccountDeletionTombstoneStore(AppPaths paths)
|
||||
{
|
||||
private const string SchemaVersion = "jobtracker.account-deletion-tombstone.v1";
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private string LedgerPath => Path.Combine(paths.AccountDeletionTombstonesRoot, "tombstones.jsonl");
|
||||
|
||||
public async Task AppendAsync(Guid requestId, string ownerKey, DateTimeOffset completedAtUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var existing = await ReadUnsafeAsync(cancellationToken);
|
||||
if (existing.Any(item => item.RequestId == requestId)) return;
|
||||
Directory.CreateDirectory(paths.AccountDeletionTombstonesRoot);
|
||||
var line = JsonSerializer.Serialize(new AccountDeletionTombstone(SchemaVersion, requestId, ownerKey, completedAtUtc));
|
||||
await File.AppendAllTextAsync(LedgerPath, line + Environment.NewLine, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AccountDeletionTombstone>> ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
try { return await ReadUnsafeAsync(cancellationToken); }
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<AccountDeletionTombstone>> ReadUnsafeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(LedgerPath)) return Array.Empty<AccountDeletionTombstone>();
|
||||
var result = new List<AccountDeletionTombstone>();
|
||||
foreach (var line in await File.ReadAllLinesAsync(LedgerPath, cancellationToken))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
try
|
||||
{
|
||||
var item = JsonSerializer.Deserialize<AccountDeletionTombstone>(line);
|
||||
if (item is null
|
||||
|| item.SchemaVersion != SchemaVersion
|
||||
|| item.RequestId == Guid.Empty
|
||||
|| item.OwnerKey.Length != 64
|
||||
|| item.OwnerKey.Any(character => !Uri.IsHexDigit(character)))
|
||||
throw new InvalidOperationException("The account-deletion tombstone ledger contains an invalid record.");
|
||||
result.Add(item);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A partial/corrupt line is never ignored by replay callers: expose a sentinel so
|
||||
// startup fails closed instead of declaring the tombstone set complete.
|
||||
throw new InvalidOperationException("The account-deletion tombstone ledger contains an invalid record.");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using JobTrackerApi.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record AccountOwnedFile(string Category, string ExportPath, string? SourcePath, byte[]? InlineBytes);
|
||||
public sealed record AccountOwnedFileInventoryResult(IReadOnlyList<AccountOwnedFile> Files, IReadOnlyList<string> Warnings);
|
||||
|
||||
public sealed class AccountOwnedFileInventory(JobTrackerContext db, AppPaths paths, IAttachmentStorage attachmentStorage)
|
||||
{
|
||||
public async Task<AccountOwnedFileInventoryResult> BuildAsync(string ownerUserId, CancellationToken cancellationToken, bool includeAccountExports = false)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(ownerUserId);
|
||||
var files = new List<AccountOwnedFile>();
|
||||
var warnings = new List<string>();
|
||||
var applicationIds = await db.JobApplications.IgnoreQueryFilters().AsNoTracking()
|
||||
.Where(item => item.OwnerUserId == ownerUserId)
|
||||
.Select(item => item.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var attachments = await db.Attachments.IgnoreQueryFilters().AsNoTracking()
|
||||
.Where(item => applicationIds.Contains(item.JobApplicationId))
|
||||
.OrderBy(item => item.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
AddPath(files, warnings, attachment.FilePath, $"files/attachments/{attachment.Id}/{SafeSegment(attachment.FileName)}", "attachment", attachmentStorage.IsManagedPath);
|
||||
}
|
||||
|
||||
var artifacts = await db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking()
|
||||
.Where(item => item.OwnerUserId == ownerUserId)
|
||||
.OrderBy(item => item.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var artifact in artifacts)
|
||||
{
|
||||
AddPath(files, warnings, artifact.StoragePath, $"files/cv-artifacts/{artifact.Id}/{SafeSegment(artifact.OriginalFileName)}", "cv-artifact", candidate => IsManagedPath(paths.CvArtifactsRoot, candidate));
|
||||
}
|
||||
|
||||
var avatar = await db.Users.AsNoTracking().Where(item => item.Id == ownerUserId).Select(item => item.AvatarImageDataUrl).FirstOrDefaultAsync(cancellationToken);
|
||||
AddAvatar(files, warnings, ownerUserId, avatar);
|
||||
AddDirectory(files, warnings, paths.GetOwnerCvExportsRoot(ownerUserId), "files/generated-cv", "generated-cv");
|
||||
AddDirectory(files, warnings, paths.GetOwnerDailyExportsRoot(null, ownerUserId), "files/daily-exports", "daily-export");
|
||||
if (includeAccountExports)
|
||||
AddDirectory(files, warnings, paths.GetOwnerAccountExportsRoot(ownerUserId), "files/account-exports", "account-export");
|
||||
return new AccountOwnedFileInventoryResult(files, warnings);
|
||||
}
|
||||
|
||||
private void AddAvatar(ICollection<AccountOwnedFile> files, ICollection<string> warnings, string ownerUserId, string? storedAvatar)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(storedAvatar)) return;
|
||||
if (storedAvatar.StartsWith("file:", StringComparison.Ordinal))
|
||||
{
|
||||
var path = storedAvatar[5..];
|
||||
var root = Path.Combine(paths.DataRoot, "Avatars", AppPaths.GetOwnerStorageKey(ownerUserId));
|
||||
AddPath(files, warnings, path, $"files/avatar/{SafeSegment(Path.GetFileName(path))}", "avatar", candidate => IsManagedPath(root, candidate));
|
||||
return;
|
||||
}
|
||||
if (storedAvatar.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var comma = storedAvatar.IndexOf(',');
|
||||
if (comma > 0 && storedAvatar[..comma].Contains(";base64", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
files.Add(new AccountOwnedFile("avatar", "files/avatar/avatar", null, Convert.FromBase64String(storedAvatar[(comma + 1)..])));
|
||||
return;
|
||||
}
|
||||
catch (FormatException) { }
|
||||
}
|
||||
}
|
||||
warnings.Add("The profile avatar was stored in an unsupported format and could not be included.");
|
||||
}
|
||||
|
||||
private static void AddPath(ICollection<AccountOwnedFile> files, ICollection<string> warnings, string sourcePath, string exportPath, string category, Func<string, bool> isManaged)
|
||||
{
|
||||
if (!isManaged(sourcePath))
|
||||
{
|
||||
warnings.Add($"Excluded unsafe {category} path for {exportPath}.");
|
||||
return;
|
||||
}
|
||||
if (!File.Exists(sourcePath))
|
||||
{
|
||||
warnings.Add($"Owned {category} file was unavailable: {exportPath}.");
|
||||
return;
|
||||
}
|
||||
files.Add(new AccountOwnedFile(category, exportPath, Path.GetFullPath(sourcePath), null));
|
||||
}
|
||||
|
||||
private static void AddDirectory(ICollection<AccountOwnedFile> files, ICollection<string> warnings, string root, string exportRoot, string category)
|
||||
{
|
||||
if (!Directory.Exists(root)) return;
|
||||
foreach (var path in Directory.EnumerateFiles(root, "*", new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.ReparsePoint,
|
||||
}))
|
||||
{
|
||||
var relative = Path.GetRelativePath(root, path);
|
||||
if (relative.StartsWith("..", StringComparison.Ordinal) || !IsManagedPath(root, path))
|
||||
{
|
||||
warnings.Add($"Excluded unsafe {category} path.");
|
||||
continue;
|
||||
}
|
||||
var exportPath = $"{exportRoot}/{string.Join('/', relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Select(SafeSegment))}";
|
||||
files.Add(new AccountOwnedFile(category, exportPath, Path.GetFullPath(path), null));
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsManagedPath(string root, string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return false;
|
||||
try
|
||||
{
|
||||
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
if (!fullPath.StartsWith(fullRoot + Path.DirectorySeparatorChar, comparison)) return false;
|
||||
var current = Path.GetDirectoryName(fullPath);
|
||||
while (!string.IsNullOrWhiteSpace(current) && !string.Equals(current, fullRoot, comparison))
|
||||
{
|
||||
if (Directory.Exists(current) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) return false;
|
||||
current = Path.GetDirectoryName(current);
|
||||
}
|
||||
return !File.Exists(fullPath) || (File.GetAttributes(fullPath) & FileAttributes.ReparsePoint) == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SafeSegment(string? value)
|
||||
{
|
||||
var candidate = Path.GetFileName(value ?? string.Empty).Trim();
|
||||
foreach (var invalid in Path.GetInvalidFileNameChars()) candidate = candidate.Replace(invalid, '_');
|
||||
if (candidate.Length > 120) candidate = candidate[..120];
|
||||
return string.IsNullOrWhiteSpace(candidate) || candidate is "." or ".." ? "file" : candidate;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ namespace JobTrackerApi.Services
|
||||
public string CvArtifactsRoot { get; }
|
||||
public string CvExportsRoot { get; }
|
||||
public string CvBenchmarksRoot { get; }
|
||||
public string AccountDeletionTombstonesRoot { get; }
|
||||
|
||||
public AppPaths(IConfiguration cfg, IHostEnvironment env)
|
||||
{
|
||||
@@ -49,6 +50,12 @@ namespace JobTrackerApi.Services
|
||||
|
||||
Directory.CreateDirectory(cvBenchmarksRoot);
|
||||
CvBenchmarksRoot = cvBenchmarksRoot;
|
||||
|
||||
var tombstonesRoot = (cfg["AccountLifecycle:TombstonesRoot"] ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(tombstonesRoot)) tombstonesRoot = Path.Combine(DataRoot, "DeletionTombstones");
|
||||
if (!Path.IsPathRooted(tombstonesRoot)) tombstonesRoot = Path.Combine(env.ContentRootPath, tombstonesRoot);
|
||||
Directory.CreateDirectory(tombstonesRoot);
|
||||
AccountDeletionTombstonesRoot = tombstonesRoot;
|
||||
}
|
||||
|
||||
public string GetDbPath(string fileName = "jobtracker.db") => Path.Combine(DataRoot, fileName);
|
||||
@@ -71,6 +78,9 @@ namespace JobTrackerApi.Services
|
||||
|
||||
public string GetOwnerDailyExportsRoot(string? configuredFolder, string ownerUserId) =>
|
||||
Path.Combine(GetExportsRoot(configuredFolder), GetOwnerStorageKey(ownerUserId));
|
||||
|
||||
public string GetOwnerAccountExportsRoot(string ownerUserId) =>
|
||||
Path.Combine(DataRoot, "AccountExports", GetOwnerStorageKey(ownerUserId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
@@ -19,7 +20,9 @@ public static class LocalSessionValidator
|
||||
var session = await db.UserSessions.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x => x.Id == sid && x.UserId == userId, cancellationToken);
|
||||
if (session is null || session.RevokedAtUtc is not null || session.ExpiresAtUtc <= now) return false;
|
||||
if (requireConfirmedEmail && !await db.Users.IgnoreQueryFilters().AnyAsync(x => x.Id == userId && x.EmailConfirmed, cancellationToken)) return false;
|
||||
var user = await db.Users.IgnoreQueryFilters().AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
if (user is null || user.DeletionStatus != AccountDeletionStatuses.Active) return false;
|
||||
if (requireConfirmedEmail && !user.EmailConfirmed) return false;
|
||||
|
||||
if (now - session.LastSeenAtUtc > TimeSpan.FromMinutes(5))
|
||||
{
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
"ExternalProvider": "ollama",
|
||||
"RoutingMode": "local_first"
|
||||
},
|
||||
"AccountLifecycle": {
|
||||
"DeletionEnabled": false
|
||||
},
|
||||
"AiQueue": {
|
||||
"WorkerConcurrency": 1,
|
||||
"GlobalCapacity": 100,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
|
||||
import BackupCard from './components/BackupCard';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { ToastProvider } from './toast';
|
||||
import { api } from './api';
|
||||
import { PromptProvider } from './prompt';
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
@@ -22,7 +23,9 @@ jest.mock('./api', () => ({
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedApi.get.mockReset();
|
||||
mockedApi.post.mockReset();
|
||||
mockedApi.get.mockResolvedValue({ data: { deletionEnabled: false, deletionStatus: 'active', requiredConfirmation: 'DELETE owner@example.test', request: null } } as any);
|
||||
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: jest.fn(() => 'blob:account-export') });
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: jest.fn() });
|
||||
jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined);
|
||||
@@ -35,7 +38,7 @@ test('downloads the readable account ZIP from the protected endpoint', async ()
|
||||
data: new Blob(['zip']),
|
||||
headers: { 'content-disposition': 'attachment; filename="jobjakt-account-export.zip"' },
|
||||
} as any);
|
||||
render(<ToastProvider><I18nProvider><BackupCard /></I18nProvider></ToastProvider>);
|
||||
render(<ToastProvider><I18nProvider><PromptProvider><BackupCard /></PromptProvider></I18nProvider></ToastProvider>);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /download readable account export/i }));
|
||||
|
||||
@@ -46,9 +49,36 @@ test('downloads the readable account ZIP from the protected endpoint', async ()
|
||||
|
||||
test('explains the recent-sign-in requirement without weakening it', async () => {
|
||||
mockedApi.post.mockRejectedValue({ response: { status: 403, data: { detail: 'Sign in again before downloading a complete account export.' } } });
|
||||
render(<ToastProvider><I18nProvider><BackupCard /></I18nProvider></ToastProvider>);
|
||||
render(<ToastProvider><I18nProvider><PromptProvider><BackupCard /></PromptProvider></I18nProvider></ToastProvider>);
|
||||
|
||||
expect(screen.getByText(/requires a sign-in from the last 15 minutes/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /download readable account export/i }));
|
||||
expect(await screen.findByText(/sign in again before downloading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('keeps account deletion hidden behind the server flag', async () => {
|
||||
render(<ToastProvider><I18nProvider><PromptProvider><BackupCard /></PromptProvider></I18nProvider></ToastProvider>);
|
||||
|
||||
expect(await screen.findByText(/account deletion is not enabled yet/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /delete my account/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('requires the exact server-provided deletion phrase', async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: { deletionEnabled: true, deletionStatus: 'active', requiredConfirmation: 'DELETE owner@example.test', request: null } } as any);
|
||||
mockedApi.post.mockRejectedValue({ response: { status: 503, data: { detail: 'Safeguard rehearsal is incomplete.' } } });
|
||||
render(<ToastProvider><I18nProvider><PromptProvider><BackupCard /></PromptProvider></I18nProvider></ToastProvider>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /delete my account/i }));
|
||||
let dialog = await screen.findByRole('dialog');
|
||||
fireEvent.change(within(dialog).getByRole('textbox'), { target: { value: 'DELETE somebody-else@example.test' } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /delete my account/i }));
|
||||
expect(await screen.findByText(/confirmation did not match/i)).toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalledWith('/account-lifecycle/delete', expect.anything());
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /delete my account/i }));
|
||||
dialog = await screen.findByRole('dialog');
|
||||
fireEvent.change(within(dialog).getByRole('textbox'), { target: { value: 'DELETE owner@example.test' } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /delete my account/i }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/account-lifecycle/delete', { confirmation: 'DELETE owner@example.test' }));
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ const mockedApi = api as jest.Mocked<typeof api>;
|
||||
function renderPage(users: unknown[]) {
|
||||
mockedApi.get.mockResolvedValue({ data: users } as any);
|
||||
mockedApi.put.mockResolvedValue({ data: null } as any);
|
||||
mockedApi.delete.mockResolvedValue({ data: null } as any);
|
||||
render(
|
||||
<CssVarsProvider theme={getTheme("light") as any} defaultMode="light">
|
||||
<I18nProvider>
|
||||
@@ -82,3 +83,14 @@ test("disables demotion and deletion for the final administrator", async () => {
|
||||
expect(await screen.findByRole("button", { name: "Remove admin" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("sends the exact account email after confirming deletion", async () => {
|
||||
renderPage([{ id: "other", email: "other@example.com", userName: "other", roles: [], emailConfirmed: true, isCurrentUser: false, canRemoveAdmin: true }]);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Delete" }));
|
||||
fireEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith("/users/other", {
|
||||
headers: { "X-Confirm-Account-Deletion": "other@example.com" },
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { Alert, Box, Button, Divider, Paper, Typography } from "@mui/material";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { clearAuthClientState } from "../auth";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { usePrompt } from "../prompt";
|
||||
|
||||
type AccountLifecycleStatus = {
|
||||
deletionEnabled: boolean;
|
||||
deletionStatus: string;
|
||||
requiredConfirmation: string;
|
||||
request?: { status: string; stage: string } | null;
|
||||
};
|
||||
|
||||
export default function BackupCard() {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { prompt } = usePrompt();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [exportingAccount, setExportingAccount] = useState(false);
|
||||
const [deletingAccount, setDeletingAccount] = useState(false);
|
||||
const [lifecycle, setLifecycle] = useState<AccountLifecycleStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
api.get<AccountLifecycleStatus>("/account-lifecycle/status")
|
||||
.then((response) => { if (active) setLifecycle(response.data); })
|
||||
.catch(() => { if (active) setLifecycle(null); });
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
const downloadBlob = (blob: Blob, contentDisposition: string | undefined, fallbackName: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -49,6 +69,31 @@ export default function BackupCard() {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAccount = async () => {
|
||||
if (!lifecycle?.deletionEnabled || lifecycle.deletionStatus !== "active") return;
|
||||
const confirmation = await prompt({
|
||||
title: t("accountDeleteTitle"),
|
||||
message: t("accountDeletePrompt", { confirmation: lifecycle.requiredConfirmation }),
|
||||
confirmLabel: t("accountDeleteButton"),
|
||||
cancelLabel: t("cancel"),
|
||||
});
|
||||
if (confirmation === null) return;
|
||||
if (confirmation.trim() !== lifecycle.requiredConfirmation) {
|
||||
toast(t("accountDeleteConfirmationMismatch"), "error");
|
||||
return;
|
||||
}
|
||||
setDeletingAccount(true);
|
||||
try {
|
||||
const response = await api.post("/account-lifecycle/delete", { confirmation });
|
||||
setLifecycle((current) => current ? { ...current, deletionStatus: "pending", request: response.data } : current);
|
||||
clearAuthClientState();
|
||||
window.location.assign("/login");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, t("accountDeleteFailed")), "error");
|
||||
setDeletingAccount(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
@@ -73,6 +118,20 @@ export default function BackupCard() {
|
||||
{downloading ? t("backupPreparing") : t("backupDownload")}
|
||||
</Button>
|
||||
</Box>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 0.5 }}>{t("accountDeleteTitle")}</Typography>
|
||||
{lifecycle?.deletionStatus === "pending" ? (
|
||||
<Alert severity="warning">{t("accountDeletePending", { stage: lifecycle.request?.stage ?? "pending" })}</Alert>
|
||||
) : lifecycle?.deletionEnabled ? (
|
||||
<Box>
|
||||
<Alert severity="error" sx={{ mb: 1.5 }}>{t("accountDeleteWarning")}</Alert>
|
||||
<Button color="error" variant="outlined" onClick={() => void deleteAccount()} disabled={deletingAccount}>
|
||||
{deletingAccount ? t("accountDeleteStarting") : t("accountDeleteButton")}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Alert severity="info">{t("accountDeleteDisabled")}</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -821,6 +821,15 @@ export const translations = {
|
||||
backupDownload: "Download encrypted backup",
|
||||
backupDownloaded: "Backup downloaded.",
|
||||
backupFailed: "Backup failed.",
|
||||
accountDeleteTitle: "Delete account",
|
||||
accountDeleteWarning: "This permanently removes your account, job history, career data, documents, sessions, and connected-account credentials. Export your data first. You will be signed out immediately.",
|
||||
accountDeletePrompt: "This cannot be undone. Type {confirmation} exactly to continue.",
|
||||
accountDeleteButton: "Delete my account",
|
||||
accountDeleteStarting: "Starting deletion...",
|
||||
accountDeleteConfirmationMismatch: "The confirmation did not match. Your account was not changed.",
|
||||
accountDeleteFailed: "Account deletion could not be started.",
|
||||
accountDeletePending: "Account deletion is in progress ({stage}). Access has already been disabled.",
|
||||
accountDeleteDisabled: "Account deletion is not enabled yet. The deletion workflow remains safely off until retention, restore, and production cache-purge safeguards are approved.",
|
||||
authStatusTitle: "Authentication",
|
||||
authStatusNotSignedIn: "Not signed in.",
|
||||
authStatusRoles: "Roles: {roles}",
|
||||
@@ -1981,6 +1990,15 @@ export const translations = {
|
||||
backupDownload: "Last ned kryptert sikkerhetskopi",
|
||||
backupDownloaded: "Sikkerhetskopi lastet ned.",
|
||||
backupFailed: "Sikkerhetskopiering mislyktes.",
|
||||
accountDeleteTitle: "Slett konto",
|
||||
accountDeleteWarning: "Dette fjerner kontoen, jobbhistorikken, karrieredata, dokumenter, økter og tilkoblede kontolegitimasjoner permanent. Eksporter dataene dine først. Du logges ut umiddelbart.",
|
||||
accountDeletePrompt: "Dette kan ikke angres. Skriv {confirmation} nøyaktig for å fortsette.",
|
||||
accountDeleteButton: "Slett kontoen min",
|
||||
accountDeleteStarting: "Starter sletting...",
|
||||
accountDeleteConfirmationMismatch: "Bekreftelsen stemte ikke. Kontoen ble ikke endret.",
|
||||
accountDeleteFailed: "Kontosletting kunne ikke startes.",
|
||||
accountDeletePending: "Kontosletting pågår ({stage}). Tilgangen er allerede deaktivert.",
|
||||
accountDeleteDisabled: "Kontosletting er ikke aktivert ennå. Arbeidsflyten forblir trygt avslått til vern for oppbevaring, gjenoppretting og tømming av produksjonsbuffer er godkjent.",
|
||||
authStatusTitle: "Autentisering",
|
||||
authStatusNotSignedIn: "Ikke logget inn.",
|
||||
authStatusRoles: "Roller: {roles}",
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function AdminUsersPage() {
|
||||
: t("adminUsersDeleteConfirmNamed", { name });
|
||||
if (!(await confirmAction(message, { title: t("adminUsersDeleteConfirmTitle"), confirmLabel: t("adminUsersDelete"), destructive: true }))) return;
|
||||
try {
|
||||
await api.delete(`/users/${u.id}`);
|
||||
await api.delete(`/users/${u.id}`, { headers: { "X-Confirm-Account-Deletion": u.email || u.userName || "" } });
|
||||
toast(t("adminUsersDeleted"), "info");
|
||||
await load();
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user