feat/Update_Controllers_to_Allow_for_Premium_Membership
This commit is contained in:
@@ -264,8 +264,7 @@ public sealed class AdminSystemController : ControllerBase
|
||||
: $"{dbWarning} {statusWarning}";
|
||||
}
|
||||
|
||||
var gmailConfigured = !string.IsNullOrWhiteSpace((_cfg["Google:GmailClientSecret"] ?? string.Empty).Trim())
|
||||
&& !string.IsNullOrWhiteSpace((_cfg["Google:GmailRedirectUri"] ?? string.Empty).Trim());
|
||||
var gmailConfigured = !string.IsNullOrWhiteSpace((_cfg["Google:GmailClientSecret"] ?? string.Empty).Trim());
|
||||
EmailSettingsSnapshot emailSettings;
|
||||
try
|
||||
{
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/ai/settings")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public sealed class AiSettingsController(
|
||||
UserManager<ApplicationUser> users,
|
||||
AiPrivacyPolicy privacyPolicy) : ControllerBase
|
||||
{
|
||||
public sealed record AiSettingsRequest(bool Enabled, bool ExternalProcessingAllowed);
|
||||
public sealed record AiSettingsDto(
|
||||
bool Enabled,
|
||||
bool ExternalProcessingAllowed,
|
||||
bool ExternalProcessingAvailable,
|
||||
bool EffectiveExternalProcessing,
|
||||
string Provider);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<AiSettingsDto>> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
return Ok(await ToDtoAsync(user, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
public async Task<ActionResult<AiSettingsDto>> Put(
|
||||
[FromBody] AiSettingsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
user.AiEnabled = request.Enabled;
|
||||
user.ExternalAiProcessingAllowed = request.ExternalProcessingAllowed;
|
||||
var result = await users.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
return Problem("AI privacy settings could not be saved.", statusCode: StatusCodes.Status500InternalServerError);
|
||||
|
||||
return Ok(await ToDtoAsync(user, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<AiSettingsDto> ToDtoAsync(ApplicationUser user, CancellationToken cancellationToken)
|
||||
{
|
||||
var decision = await privacyPolicy.EvaluateAsync(user.Id, cancellationToken);
|
||||
return new AiSettingsDto(
|
||||
user.AiEnabled,
|
||||
user.ExternalAiProcessingAllowed,
|
||||
privacyPolicy.ExternalProcessingAvailable,
|
||||
decision.ExternalProcessingAllowed,
|
||||
decision.Provider);
|
||||
}
|
||||
}
|
||||
@@ -33,10 +33,14 @@ public sealed class AiUsageController : ControllerBase
|
||||
var roles = await _users.GetRolesAsync(user);
|
||||
var entitlements = AccountPlans.ForRoles(roles);
|
||||
var monthStart = new DateTimeOffset(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var interactions = _db.AiInteractions.Where(x => x.OwnerUserId == user.Id);
|
||||
var currentMonth = _db.Database.IsSqlite()
|
||||
? Sum((await interactions.ToListAsync(cancellationToken)).Where(x => x.CreatedAtUtc >= monthStart))
|
||||
: await SumAsync(interactions.Where(x => x.CreatedAtUtc >= monthStart), cancellationToken);
|
||||
return Ok(new UsageDto(
|
||||
await SumAsync(_db.AiInteractions.Where(x => x.OwnerUserId == user.Id && x.CreatedAtUtc >= monthStart), cancellationToken),
|
||||
await SumAsync(_db.AiInteractions.Where(x => x.OwnerUserId == user.Id), cancellationToken),
|
||||
entitlements.AdvancedAi ? "premium" : "free",
|
||||
currentMonth,
|
||||
await SumAsync(interactions, cancellationToken),
|
||||
AccountPlans.Name(entitlements),
|
||||
entitlements.MonthlyAiCalls,
|
||||
entitlements.MonthlyAiTokens,
|
||||
await _db.Attachments.Where(x => x.JobApplication.OwnerUserId == user.Id).SumAsync(x => (long?)x.FileSize, cancellationToken) ?? 0,
|
||||
@@ -52,4 +56,14 @@ public sealed class AiUsageController : ControllerBase
|
||||
group.Sum(x => (long)x.EstimatedTokenCount))).FirstOrDefaultAsync(cancellationToken);
|
||||
return totals ?? new UsagePeriodDto(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
private static UsagePeriodDto Sum(IEnumerable<AiInteraction> interactions)
|
||||
{
|
||||
var rows = interactions.ToList();
|
||||
return new UsagePeriodDto(
|
||||
rows.Count,
|
||||
rows.Sum(x => (long)x.InputCharacterCount),
|
||||
rows.Sum(x => (long)x.OutputCharacterCount),
|
||||
rows.Sum(x => (long)x.EstimatedTokenCount));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ public sealed class AiWorkspaceController : ControllerBase
|
||||
public ActionResult<object> Modules() => Ok(new { modules = _workspace.Modules, provider = ResolveProvider() });
|
||||
|
||||
[HttpPost("generate")]
|
||||
[Authorize(Policy = ProEntitlement.Policy)]
|
||||
public async Task<ActionResult<InteractionDto>> Generate(int jobId, [FromBody] GenerateRequest request, CancellationToken ct)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
@@ -46,14 +47,32 @@ public sealed class AiWorkspaceController : ControllerBase
|
||||
var roles = await _users.GetRolesAsync(user);
|
||||
var entitlements = AccountPlans.ForRoles(roles);
|
||||
var monthStart = new DateTimeOffset(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var used = await _db.AiInteractions
|
||||
.Where(x => x.OwnerUserId == user.Id && x.CreatedAtUtc >= monthStart)
|
||||
.GroupBy(_ => 1)
|
||||
.Select(g => new { Calls = g.Count(), Tokens = g.Sum(x => (long)x.EstimatedTokenCount) })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if ((used?.Calls ?? 0) >= entitlements.MonthlyAiCalls)
|
||||
var interactions = _db.AiInteractions.Where(x => x.OwnerUserId == user.Id);
|
||||
int usedCalls;
|
||||
long usedTokens;
|
||||
if (_db.Database.IsSqlite())
|
||||
{
|
||||
var used = (await interactions
|
||||
.Select(x => new { x.CreatedAtUtc, x.EstimatedTokenCount })
|
||||
.ToListAsync(ct))
|
||||
.Where(x => x.CreatedAtUtc >= monthStart)
|
||||
.ToList();
|
||||
usedCalls = used.Count;
|
||||
usedTokens = used.Sum(x => (long)x.EstimatedTokenCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
var used = await interactions
|
||||
.Where(x => x.CreatedAtUtc >= monthStart)
|
||||
.GroupBy(_ => 1)
|
||||
.Select(g => new { Calls = g.Count(), Tokens = g.Sum(x => (long)x.EstimatedTokenCount) })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
usedCalls = used?.Calls ?? 0;
|
||||
usedTokens = used?.Tokens ?? 0;
|
||||
}
|
||||
if (usedCalls >= entitlements.MonthlyAiCalls)
|
||||
return StatusCode(StatusCodes.Status429TooManyRequests, $"Monthly AI limit reached ({entitlements.MonthlyAiCalls} generations). Upgrade your plan or try again next month.");
|
||||
if ((used?.Tokens ?? 0) >= entitlements.MonthlyAiTokens)
|
||||
if (usedTokens >= entitlements.MonthlyAiTokens)
|
||||
return StatusCode(StatusCodes.Status429TooManyRequests, $"Monthly AI cost limit reached ({entitlements.MonthlyAiTokens:N0} estimated tokens). Upgrade your plan or try again next month.");
|
||||
}
|
||||
|
||||
|
||||
@@ -21,15 +21,17 @@ namespace JobTrackerApi.Controllers
|
||||
".pdf", ".doc", ".docx", ".txt", ".rtf", ".png", ".jpg", ".jpeg", ".webp"
|
||||
};
|
||||
|
||||
private readonly AppPaths _paths;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly UserManager<ApplicationUser>? _users;
|
||||
private readonly IAttachmentStorage _storage;
|
||||
private readonly ILogger<AttachmentsController> _logger;
|
||||
|
||||
public AttachmentsController(AppPaths paths, JobTrackerContext db, UserManager<ApplicationUser>? users = null)
|
||||
public AttachmentsController(AppPaths paths, JobTrackerContext db, UserManager<ApplicationUser>? users = null, IAttachmentStorage? storage = null, ILogger<AttachmentsController>? logger = null)
|
||||
{
|
||||
_paths = paths;
|
||||
_db = db;
|
||||
_users = users;
|
||||
_storage = storage ?? new AttachmentStorage(paths);
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<AttachmentsController>.Instance;
|
||||
}
|
||||
|
||||
public sealed record AttachmentDto(int Id, string FileName, DateTime UploadDate, string FileType, long FileSize, string? Purpose, bool UseForAi);
|
||||
@@ -104,8 +106,14 @@ namespace JobTrackerApi.Controllers
|
||||
var att = await FindOwnedAttachmentAsync(id, cancellationToken);
|
||||
if (att is null) return NotFound();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(att.FilePath) || !System.IO.File.Exists(att.FilePath))
|
||||
if (string.IsNullOrWhiteSpace(att.FilePath) || !_storage.IsManagedPath(att.FilePath))
|
||||
return Conflict("The attachment storage path is invalid.");
|
||||
if (!System.IO.File.Exists(att.FilePath))
|
||||
{
|
||||
if (System.IO.File.Exists(_storage.StagePath(att.FilePath)))
|
||||
return Conflict("The attachment is still being finalized. Try again after the service restarts.");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var contentType = string.IsNullOrWhiteSpace(att.FileType) ? "application/octet-stream" : att.FileType;
|
||||
var fileName = Path.GetFileName(att.FileName);
|
||||
@@ -132,40 +140,29 @@ namespace JobTrackerApi.Controllers
|
||||
}
|
||||
|
||||
var rawName = (request.FileName ?? string.Empty).Trim();
|
||||
if (rawName.Length == 0)
|
||||
if (rawName.Length > 0)
|
||||
{
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (purposeChanged)
|
||||
{
|
||||
// Recompute needs the Purpose change committed first -- a fresh query
|
||||
// wouldn't see the pending change yet.
|
||||
await RecomputeAttachmentFlagsAsync(att.JobApplicationId, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
return NoContent();
|
||||
var name = Path.GetFileName(rawName);
|
||||
var ext = Path.GetExtension(name);
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return BadRequest("That file type is not allowed.");
|
||||
|
||||
// The generated storage name is intentionally stable. A user-visible rename is metadata,
|
||||
// so no filesystem/DB split can leave the row pointing at a moved file.
|
||||
att.FileName = name;
|
||||
}
|
||||
|
||||
var name = Path.GetFileName(rawName);
|
||||
var ext = Path.GetExtension(name);
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return BadRequest("That file type is not allowed.");
|
||||
|
||||
var folder = Path.GetDirectoryName(att.FilePath) ?? _paths.AttachmentsRoot;
|
||||
var newPath = Path.Combine(folder, BuildStoredFileName(name));
|
||||
|
||||
if (System.IO.File.Exists(att.FilePath) && !string.Equals(att.FilePath, newPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
System.IO.File.Move(att.FilePath, newPath, overwrite: false);
|
||||
}
|
||||
|
||||
att.FileName = name;
|
||||
att.FilePath = newPath;
|
||||
await using var transaction = _db.Database.IsRelational()
|
||||
? await _db.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (purposeChanged)
|
||||
{
|
||||
// This query must see the new persisted purpose; the transaction keeps both saves atomic.
|
||||
await RecomputeAttachmentFlagsAsync(att.JobApplicationId, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
@@ -178,19 +175,77 @@ namespace JobTrackerApi.Controllers
|
||||
|
||||
var path = att.FilePath;
|
||||
var jobId = att.JobApplicationId;
|
||||
_db.Attachments.Remove(att);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(path) && !_storage.IsManagedPath(path))
|
||||
return Conflict("The attachment storage path is invalid.");
|
||||
|
||||
var deletePath = string.IsNullOrWhiteSpace(path) ? null : _storage.DeletePath(path);
|
||||
var quarantined = false;
|
||||
if (!string.IsNullOrWhiteSpace(path) && System.IO.File.Exists(path))
|
||||
{
|
||||
_storage.Quarantine(path, deletePath!);
|
||||
quarantined = true;
|
||||
}
|
||||
else if (deletePath is not null && System.IO.File.Exists(deletePath))
|
||||
{
|
||||
return Accepted(new { recoveryPending = true });
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
_logger.LogWarning("Attachment {AttachmentId} metadata referenced missing bytes; deleting the stale row.", id);
|
||||
}
|
||||
|
||||
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? transaction = null;
|
||||
var rolledBack = false;
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(path) && System.IO.File.Exists(path))
|
||||
System.IO.File.Delete(path);
|
||||
if (_db.Database.IsRelational())
|
||||
transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
|
||||
_db.Attachments.Remove(att);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best effort
|
||||
if (transaction is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await transaction.RollbackAsync(CancellationToken.None);
|
||||
rolledBack = true;
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
_logger.LogWarning(rollbackError, "Attachment {AttachmentId} transaction outcome is uncertain; quarantined bytes await startup reconciliation.", id);
|
||||
}
|
||||
}
|
||||
if (rolledBack && quarantined && deletePath is not null && System.IO.File.Exists(deletePath) && !System.IO.File.Exists(path))
|
||||
{
|
||||
try { _storage.Restore(deletePath, path); }
|
||||
catch (Exception restoreError)
|
||||
{
|
||||
_logger.LogWarning(restoreError, "Attachment {AttachmentId} bytes could not be restored after database rollback; startup reconciliation will retry.", id);
|
||||
}
|
||||
}
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (transaction is not null) await transaction.DisposeAsync();
|
||||
}
|
||||
|
||||
if (deletePath is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_storage.Purge(deletePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Attachment {AttachmentId} metadata was deleted; quarantined bytes await startup reconciliation.", id);
|
||||
return Accepted(new { recoveryPending = true });
|
||||
}
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
@@ -218,9 +273,7 @@ namespace JobTrackerApi.Controllers
|
||||
return StatusCode(StatusCodes.Status413PayloadTooLarge, $"Storage limit reached ({limit / 1_000_000} MB). Remove files or upgrade your plan.");
|
||||
}
|
||||
|
||||
var folder = Path.Combine(_paths.AttachmentsRoot, jobId.ToString());
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
var validFiles = new List<(IFormFile File, string DisplayName, string ContentType, string Purpose, string FinalPath, string StagePath)>();
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (file.Length == 0) continue;
|
||||
@@ -232,30 +285,109 @@ namespace JobTrackerApi.Controllers
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return BadRequest($"{displayName} is not an allowed file type.");
|
||||
|
||||
// Store uploads under unique generated filenames so re-uploads never overwrite
|
||||
// earlier files with the same visible name.
|
||||
var storedName = BuildStoredFileName(displayName);
|
||||
var path = Path.Combine(folder, storedName);
|
||||
await using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None);
|
||||
await file.CopyToAsync(stream, cancellationToken);
|
||||
|
||||
_db.Attachments.Add(new Attachment
|
||||
{
|
||||
JobApplicationId = jobId,
|
||||
FileName = displayName,
|
||||
FilePath = path,
|
||||
UploadDate = DateTime.Now,
|
||||
FileType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType,
|
||||
FileSize = file.Length,
|
||||
Purpose = GuessPurpose(displayName),
|
||||
UseForAi = true,
|
||||
});
|
||||
var finalPath = _storage.CreateFinalPath(jobId, storedName);
|
||||
validFiles.Add((
|
||||
file,
|
||||
displayName,
|
||||
string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType,
|
||||
GuessPurpose(displayName),
|
||||
finalPath,
|
||||
_storage.StagePath(finalPath)));
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return Ok();
|
||||
if (validFiles.Count == 0) return BadRequest("At least one non-empty file is required.");
|
||||
|
||||
var stagedPaths = new List<string>();
|
||||
try
|
||||
{
|
||||
foreach (var item in validFiles)
|
||||
{
|
||||
await _storage.StageAsync(item.File, item.StagePath, cancellationToken);
|
||||
stagedPaths.Add(item.StagePath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
foreach (var stagedPath in stagedPaths)
|
||||
{
|
||||
try { _storage.Purge(stagedPath); } catch { }
|
||||
}
|
||||
throw;
|
||||
}
|
||||
|
||||
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? transaction = null;
|
||||
var rolledBack = false;
|
||||
try
|
||||
{
|
||||
if (_db.Database.IsRelational())
|
||||
transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
|
||||
foreach (var item in validFiles)
|
||||
{
|
||||
_db.Attachments.Add(new Attachment
|
||||
{
|
||||
JobApplicationId = jobId,
|
||||
FileName = item.DisplayName,
|
||||
FilePath = item.FinalPath,
|
||||
UploadDate = DateTime.Now,
|
||||
FileType = item.ContentType,
|
||||
FileSize = item.File.Length,
|
||||
Purpose = item.Purpose,
|
||||
UseForAi = true,
|
||||
});
|
||||
}
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
|
||||
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);
|
||||
rolledBack = true;
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
_logger.LogWarning(rollbackError, "Attachment upload transaction outcome is uncertain; staged bytes await startup reconciliation.");
|
||||
}
|
||||
}
|
||||
if (rolledBack)
|
||||
{
|
||||
foreach (var item in validFiles)
|
||||
{
|
||||
try { _storage.Purge(item.StagePath); }
|
||||
catch (Exception purgeError)
|
||||
{
|
||||
_logger.LogWarning(purgeError, "Attachment upload rolled back but staged bytes could not be removed; startup reconciliation will retry.");
|
||||
}
|
||||
}
|
||||
}
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (transaction is not null) await transaction.DisposeAsync();
|
||||
}
|
||||
|
||||
var pending = false;
|
||||
foreach (var item in validFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
_storage.Promote(item.StagePath, item.FinalPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
pending = true;
|
||||
_logger.LogWarning(ex, "Attachment upload committed for job {JobId}; staged bytes await startup reconciliation.", jobId);
|
||||
}
|
||||
}
|
||||
|
||||
return pending ? Accepted(new { recoveryPending = true }) : Ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Claims;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
@@ -26,8 +27,9 @@ public sealed class AuthController : ControllerBase
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly string _avatarDataRoot;
|
||||
private readonly IHttpClientFactory? _httpClients;
|
||||
private readonly ExternalOrigin _externalOrigin;
|
||||
|
||||
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db, IHostEnvironment? environment = null, IHttpClientFactory? httpClients = null)
|
||||
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db, IHostEnvironment? environment = null, IHttpClientFactory? httpClients = null, ExternalOrigin? externalOrigin = null)
|
||||
{
|
||||
_cfg = cfg;
|
||||
_users = users;
|
||||
@@ -39,6 +41,7 @@ public sealed class AuthController : ControllerBase
|
||||
_twoFactorPending = twoFactorPending;
|
||||
_db = db;
|
||||
_httpClients = httpClients;
|
||||
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
|
||||
_avatarDataRoot = Path.GetFullPath((_cfg["Data:Root"] ?? environment?.ContentRootPath ?? AppContext.BaseDirectory).Trim());
|
||||
}
|
||||
|
||||
@@ -70,6 +73,7 @@ public sealed class AuthController : ControllerBase
|
||||
public sealed record LoginRequest(string Email, string Password, bool RememberMe = true, string? TurnstileToken = null);
|
||||
public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true, string? TurnstileToken = null);
|
||||
public sealed record AuthSessionResult(bool Authenticated, string Provider);
|
||||
public sealed record RegistrationPendingResult(bool VerificationRequired);
|
||||
public sealed record TwoFactorRequiredResult(bool RequiresTwoFactor, string PendingToken);
|
||||
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
|
||||
public sealed record MicrosoftLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
|
||||
@@ -89,6 +93,7 @@ public sealed class AuthController : ControllerBase
|
||||
AccountEntitlements Entitlements,
|
||||
GoogleLinkDto? GoogleLink,
|
||||
MicrosoftLinkDto? MicrosoftLink);
|
||||
public sealed record PendingEmailChangeResult(string? PendingEmail, DateTimeOffset? RequestedAtUtc);
|
||||
private const int MaxAvatarBytes = 1_000_000;
|
||||
private static readonly HashSet<string> AllowedAvatarExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -96,7 +101,10 @@ public sealed class AuthController : ControllerBase
|
||||
};
|
||||
public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText, string? ProfileCvStructureJson);
|
||||
public sealed record GoogleTokenRequest(string Token, bool RememberMe = true);
|
||||
public sealed record MicrosoftTokenRequest(string Token, bool RememberMe = true);
|
||||
public sealed record MicrosoftTokenRequest(string Token, bool RememberMe = true, string? CurrentPassword = null);
|
||||
public sealed record MicrosoftLegacyRelinkRequiredResult(bool LegacyRelinkRequired);
|
||||
public sealed record ConfirmMicrosoftLegacyRelinkRequest(string UserId, string TenantId, string ObjectId, string RecoveryToken, string MicrosoftToken);
|
||||
public sealed record MicrosoftUnlinkRequest(string CurrentPassword);
|
||||
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
@@ -175,6 +183,8 @@ public sealed class AuthController : ControllerBase
|
||||
// created either way, the user can request a fresh link via resend-verification-email.
|
||||
_logger.LogError(ex, "Failed to send verification email to {Email}", user.Email);
|
||||
}
|
||||
|
||||
return StatusCode(StatusCodes.Status202Accepted, new RegistrationPendingResult(true));
|
||||
}
|
||||
|
||||
return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken);
|
||||
@@ -295,22 +305,50 @@ public sealed class AuthController : ControllerBase
|
||||
return Unauthorized(ex.Message);
|
||||
}
|
||||
|
||||
if (!TryGetMicrosoftKey(microsoft, out var tenantId, out var objectId))
|
||||
return Unauthorized("Microsoft token is missing its stable identity.");
|
||||
|
||||
var user = await _users.Users.FirstOrDefaultAsync(
|
||||
x => x.MicrosoftSubject == microsoft.Subject || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email),
|
||||
x => x.MicrosoftTenantId == tenantId && x.MicrosoftObjectId == objectId,
|
||||
cancellationToken);
|
||||
|
||||
if (user is null && microsoft.EmailVerified && !string.IsNullOrWhiteSpace(microsoft.Email))
|
||||
if (user is null)
|
||||
{
|
||||
user = await _users.FindByEmailAsync(microsoft.Email);
|
||||
if (user is not null)
|
||||
var legacyCandidates = await _users.Users
|
||||
.Where(x => x.MicrosoftTenantId == null && x.MicrosoftObjectId == null)
|
||||
.Where(x => x.MicrosoftSubject == objectId || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email))
|
||||
.ToListAsync(cancellationToken);
|
||||
if (legacyCandidates.Count > 1)
|
||||
return Conflict("This legacy Microsoft link requires administrator-assisted recovery.");
|
||||
if (legacyCandidates.Count == 1)
|
||||
{
|
||||
_logger.LogInformation("Auto-linking Microsoft sign-in for existing local account {Email}", microsoft.Email);
|
||||
var legacy = legacyCandidates[0];
|
||||
if (!legacy.EmailConfirmed || string.IsNullOrWhiteSpace(legacy.Email))
|
||||
return Conflict("This legacy Microsoft link requires administrator-assisted recovery.");
|
||||
|
||||
var purpose = MicrosoftLegacyRelinkPurpose(tenantId, objectId);
|
||||
var recoveryToken = await _users.GenerateUserTokenAsync(legacy, TokenOptions.DefaultProvider, purpose);
|
||||
var link = _externalOrigin.BuildPath($"/microsoft-legacy-relink?userId={Uri.EscapeDataString(legacy.Id)}&tenantId={Uri.EscapeDataString(tenantId)}&objectId={Uri.EscapeDataString(objectId)}&token={Uri.EscapeDataString(recoveryToken)}");
|
||||
try
|
||||
{
|
||||
await _email.SendAsync(
|
||||
legacy.Email,
|
||||
"Confirm your Microsoft account relink",
|
||||
$"A tenant-qualified Microsoft account requested access to your Jobbjakt account. If this was you, open the link and authenticate with the same Microsoft account:\n\n{link}\n\nIf this was not you, ignore this email.",
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send Microsoft legacy-relink proof");
|
||||
return EmailDeliveryUnavailable("Microsoft account recovery email could not be sent right now. Please try again later.");
|
||||
}
|
||||
return Accepted(new MicrosoftLegacyRelinkRequiredResult(true));
|
||||
}
|
||||
}
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
if (!microsoft.EmailVerified || string.IsNullOrWhiteSpace(microsoft.Email))
|
||||
if (string.IsNullOrWhiteSpace(microsoft.Email))
|
||||
{
|
||||
return Unauthorized("This Microsoft account is not linked to a Jobbjakt user yet.");
|
||||
}
|
||||
@@ -321,36 +359,95 @@ public sealed class AuthController : ControllerBase
|
||||
return Unauthorized("This Microsoft account is not linked to a Jobbjakt user yet.");
|
||||
}
|
||||
|
||||
user = new ApplicationUser { UserName = microsoft.Email, Email = microsoft.Email, EmailConfirmed = true };
|
||||
if (await _users.FindByEmailAsync(microsoft.Email) is not null)
|
||||
return Conflict("Sign in to the existing Jobbjakt account before linking Microsoft.");
|
||||
|
||||
user = new ApplicationUser
|
||||
{
|
||||
UserName = microsoft.Email,
|
||||
Email = microsoft.Email,
|
||||
EmailConfirmed = false,
|
||||
MicrosoftTenantId = tenantId,
|
||||
MicrosoftObjectId = objectId,
|
||||
MicrosoftEmail = microsoft.Email,
|
||||
MicrosoftLinkedAt = DateTimeOffset.UtcNow,
|
||||
DisplayName = TrimOrNull(microsoft.Name),
|
||||
FirstName = TrimOrNull(microsoft.GivenName),
|
||||
LastName = TrimOrNull(microsoft.FamilyName),
|
||||
};
|
||||
var created = await _users.CreateAsync(user);
|
||||
if (!created.Succeeded)
|
||||
{
|
||||
return BadRequest(string.Join("; ", created.Errors.Select(e => e.Description)));
|
||||
}
|
||||
_logger.LogInformation("Created new user via Microsoft sign-up for {Email}", microsoft.Email);
|
||||
_logger.LogInformation("Created a new tenant-qualified Microsoft user");
|
||||
|
||||
if (_cfg.GetValue("Auth:RequireEmailVerification", false))
|
||||
{
|
||||
try { await SendVerificationEmailAsync(user, cancellationToken); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "Failed to send verification email for Microsoft registration"); }
|
||||
return StatusCode(StatusCodes.Status202Accepted, new RegistrationPendingResult(true));
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(user.MicrosoftSubject) || !string.Equals(user.MicrosoftSubject, microsoft.Subject, StringComparison.Ordinal))
|
||||
{
|
||||
user.MicrosoftSubject = microsoft.Subject;
|
||||
user.MicrosoftEmail = microsoft.Email;
|
||||
user.MicrosoftLinkedAt ??= DateTimeOffset.UtcNow;
|
||||
user.DisplayName ??= TrimOrNull(microsoft.Name);
|
||||
user.FirstName ??= TrimOrNull(microsoft.GivenName);
|
||||
user.LastName ??= TrimOrNull(microsoft.FamilyName);
|
||||
await _users.UpdateAsync(user);
|
||||
}
|
||||
if (_cfg.GetValue("Auth:RequireEmailVerification", false) && !user.EmailConfirmed)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "email_not_verified" });
|
||||
|
||||
user.MicrosoftEmail = microsoft.Email;
|
||||
user.MicrosoftLinkedAt ??= DateTimeOffset.UtcNow;
|
||||
user.DisplayName ??= TrimOrNull(microsoft.Name);
|
||||
user.FirstName ??= TrimOrNull(microsoft.GivenName);
|
||||
user.LastName ??= TrimOrNull(microsoft.FamilyName);
|
||||
var metadataUpdate = await _users.UpdateAsync(user);
|
||||
if (!metadataUpdate.Succeeded) return BadRequest(string.Join("; ", metadataUpdate.Errors.Select(x => x.Description)));
|
||||
|
||||
return await CompleteSignInAsync(user, request.RememberMe, "microsoft", cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("microsoft/legacy-relink/confirm")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("auth-email")]
|
||||
public async Task<IActionResult> ConfirmMicrosoftLegacyRelink([FromBody] ConfirmMicrosoftLegacyRelinkRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
MicrosoftTokenPrincipal microsoft;
|
||||
try { microsoft = await _microsoftTokens.ValidateAsync(request.MicrosoftToken, cancellationToken); }
|
||||
catch (Exception ex) { return BadRequest(ex.Message); }
|
||||
if (!TryGetMicrosoftKey(microsoft, out var tenantId, out var objectId)
|
||||
|| !string.Equals(tenantId, request.TenantId, StringComparison.OrdinalIgnoreCase)
|
||||
|| !string.Equals(objectId, request.ObjectId, StringComparison.OrdinalIgnoreCase))
|
||||
return BadRequest("Microsoft identity does not match this recovery link.");
|
||||
|
||||
var user = await _users.FindByIdAsync(request.UserId);
|
||||
if (user is null || user.MicrosoftTenantId is not null || user.MicrosoftObjectId is not null)
|
||||
return BadRequest("Invalid or expired recovery link.");
|
||||
if (!await _users.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, MicrosoftLegacyRelinkPurpose(tenantId, objectId), request.RecoveryToken))
|
||||
return BadRequest("Invalid or expired recovery link.");
|
||||
if (await _users.Users.AnyAsync(x => x.Id != user.Id && x.MicrosoftTenantId == tenantId && x.MicrosoftObjectId == objectId, cancellationToken))
|
||||
return Conflict("That Microsoft account is already linked to another Jobbjakt user.");
|
||||
|
||||
user.MicrosoftTenantId = tenantId;
|
||||
user.MicrosoftObjectId = objectId;
|
||||
user.MicrosoftEmail = microsoft.Email;
|
||||
user.MicrosoftLinkedAt = DateTimeOffset.UtcNow;
|
||||
var update = await _users.UpdateAsync(user);
|
||||
if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(x => x.Description)));
|
||||
await SessionRevocation.RevokeAllAsync(_db, user.Id, trustedDeviceHashToKeep: null, cancellationToken);
|
||||
ClearSessionCookies();
|
||||
TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
// Anonymous on purpose, and now explicitly: this only clears the caller's own session cookies and
|
||||
// leaks nothing. Requiring authentication would mean a user whose token has already expired gets a
|
||||
// 401 when signing out and stays stuck in a half-signed-in state.
|
||||
[AllowAnonymous]
|
||||
public IActionResult Logout()
|
||||
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
|
||||
{
|
||||
var cookieToken = Request.Cookies[AuthSessionOptions.SessionCookieName];
|
||||
if (SessionRevocation.TryReadIdentity(User, cookieToken, out var userId, out var sessionId))
|
||||
await SessionRevocation.RevokeCurrentAsync(_db, userId, sessionId, cancellationToken);
|
||||
|
||||
ClearSessionCookies();
|
||||
return NoContent();
|
||||
}
|
||||
@@ -417,12 +514,8 @@ public sealed class AuthController : ControllerBase
|
||||
// - "value" -> set (trimmed)
|
||||
// This lets /profile save identity fields and /career save the master-profile fields
|
||||
// through the same endpoint without one wiping the other. Email and UserName are the
|
||||
// login identifiers and are never cleared to empty.
|
||||
if (request.Email is not null)
|
||||
{
|
||||
var v = request.Email.Trim();
|
||||
if (v.Length > 0) user.Email = v;
|
||||
}
|
||||
// login identifiers and are never cleared to empty. Email ownership changes use the
|
||||
// separate, token-confirmed email-change flow below.
|
||||
if (request.UserName is not null)
|
||||
{
|
||||
var v = request.UserName.Trim();
|
||||
@@ -441,6 +534,145 @@ public sealed class AuthController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
public sealed record RequestEmailChangeRequest(string Email, string CurrentPassword);
|
||||
public sealed record ConfirmEmailChangeRequest(string UserId, string Email, string Token);
|
||||
public sealed record CancelEmailChangeRequest(string CurrentPassword);
|
||||
|
||||
[HttpGet("email-change")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<ActionResult<PendingEmailChangeResult>> GetEmailChange()
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
return Ok(new PendingEmailChangeResult(user.PendingEmail, user.PendingEmailRequestedAtUtc));
|
||||
}
|
||||
|
||||
[HttpPost("email-change/request")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
[EnableRateLimiting("auth-email")]
|
||||
public async Task<IActionResult> RequestEmailChange([FromBody] RequestEmailChangeRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
if (!user.EmailConfirmed) return StatusCode(StatusCodes.Status403Forbidden, new { error = "email_not_verified" });
|
||||
if (!await _users.HasPasswordAsync(user) || !await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
|
||||
return BadRequest("Current password is incorrect.");
|
||||
|
||||
var newEmail = (request.Email ?? string.Empty).Trim();
|
||||
if (newEmail.Length > 320 || !new EmailAddressAttribute().IsValid(newEmail)) return BadRequest("A valid email is required.");
|
||||
if (string.Equals(_users.NormalizeEmail(newEmail), _users.NormalizeEmail(user.Email), StringComparison.Ordinal)) return BadRequest("The new email must be different.");
|
||||
|
||||
var existing = await _users.FindByEmailAsync(newEmail);
|
||||
if (existing is not null && !string.Equals(existing.Id, user.Id, StringComparison.Ordinal)) return BadRequest("Email is already in use.");
|
||||
|
||||
user.PendingEmail = newEmail;
|
||||
user.PendingEmailRequestedAtUtc = DateTimeOffset.UtcNow;
|
||||
user.SecurityStamp = Guid.NewGuid().ToString();
|
||||
var update = await _users.UpdateAsync(user);
|
||||
if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(x => x.Description)));
|
||||
|
||||
var token = await _users.GenerateChangeEmailTokenAsync(user, newEmail);
|
||||
var link = _externalOrigin.BuildPath($"/confirm-email-change?userId={Uri.EscapeDataString(user.Id)}&email={Uri.EscapeDataString(newEmail)}&token={Uri.EscapeDataString(token)}");
|
||||
try
|
||||
{
|
||||
await _email.SendAsync(newEmail, "Confirm your new email", $"Confirm this email address for your Jobbjakt account:\n\n{link}\n\nIf you did not request this change, ignore this email.", cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send an email-change confirmation");
|
||||
return EmailDeliveryUnavailable("The confirmation email could not be sent right now. Please try again later.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(user.Email))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _email.SendAsync(user.Email, "Email change requested", "A change to the email address on your Jobbjakt account was requested. Your current email remains active until the new address is confirmed.", cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send the current-address email-change notice");
|
||||
}
|
||||
}
|
||||
|
||||
return Accepted(new PendingEmailChangeResult(user.PendingEmail, user.PendingEmailRequestedAtUtc));
|
||||
}
|
||||
|
||||
[HttpPost("email-change/confirm")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("auth-email")]
|
||||
public async Task<IActionResult> ConfirmEmailChange([FromBody] ConfirmEmailChangeRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = (request.UserId ?? string.Empty).Trim();
|
||||
var newEmail = (request.Email ?? string.Empty).Trim();
|
||||
var token = request.Token ?? string.Empty;
|
||||
if (userId.Length == 0 || newEmail.Length == 0 || token.Length == 0) return BadRequest("Invalid or expired link.");
|
||||
|
||||
var user = await _users.FindByIdAsync(userId);
|
||||
if (user is null || !string.Equals(_users.NormalizeEmail(user.PendingEmail), _users.NormalizeEmail(newEmail), StringComparison.Ordinal))
|
||||
return BadRequest("Invalid or expired link.");
|
||||
|
||||
var oldEmail = user.Email;
|
||||
var updateUserName = string.Equals(_users.NormalizeName(user.UserName), _users.NormalizeEmail(oldEmail), StringComparison.Ordinal);
|
||||
var transaction = _db.Database.IsRelational() ? await _db.Database.BeginTransactionAsync(cancellationToken) : null;
|
||||
try
|
||||
{
|
||||
var changed = await _users.ChangeEmailAsync(user, newEmail, token);
|
||||
if (!changed.Succeeded) return BadRequest("Invalid or expired link.");
|
||||
|
||||
if (updateUserName)
|
||||
{
|
||||
var renamed = await _users.SetUserNameAsync(user, newEmail);
|
||||
if (!renamed.Succeeded) return BadRequest(string.Join("; ", renamed.Errors.Select(x => x.Description)));
|
||||
}
|
||||
|
||||
user.PendingEmail = null;
|
||||
user.PendingEmailRequestedAtUtc = null;
|
||||
var cleared = await _users.UpdateAsync(user);
|
||||
if (!cleared.Succeeded) return BadRequest(string.Join("; ", cleared.Errors.Select(x => x.Description)));
|
||||
|
||||
await SessionRevocation.RevokeAllAsync(_db, user.Id, trustedDeviceHashToKeep: null, cancellationToken);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (transaction is not null) await transaction.DisposeAsync();
|
||||
}
|
||||
|
||||
ClearSessionCookies();
|
||||
TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
|
||||
if (!string.IsNullOrWhiteSpace(oldEmail))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _email.SendAsync(oldEmail, "Your Jobbjakt email changed", "The email address on your Jobbjakt account was changed. If this was not you, reset your password and contact the administrator.", cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send the old-address email-change notice");
|
||||
}
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("email-change/cancel")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
[EnableRateLimiting("auth-email")]
|
||||
public async Task<IActionResult> CancelEmailChange([FromBody] CancelEmailChangeRequest request)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
if (!await _users.HasPasswordAsync(user) || !await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
|
||||
return BadRequest("Current password is incorrect.");
|
||||
|
||||
user.PendingEmail = null;
|
||||
user.PendingEmailRequestedAtUtc = null;
|
||||
var update = await _users.UpdateAsync(user);
|
||||
if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(x => x.Description)));
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("google/link")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<ActionResult<GoogleLinkDto>> LinkGoogle([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
|
||||
@@ -534,15 +766,21 @@ public sealed class AuthController : ControllerBase
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
|
||||
if (!TryGetMicrosoftKey(microsoft, out var tenantId, out var objectId))
|
||||
return BadRequest("Microsoft token is missing its stable identity.");
|
||||
if (!await _users.HasPasswordAsync(user) || !await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
|
||||
return BadRequest("Current password is required to link Microsoft.");
|
||||
|
||||
var conflict = await _users.Users
|
||||
.Where(x => x.Id != user.Id)
|
||||
.FirstOrDefaultAsync(x => x.MicrosoftSubject == microsoft.Subject || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email), cancellationToken);
|
||||
.FirstOrDefaultAsync(x => x.MicrosoftTenantId == tenantId && x.MicrosoftObjectId == objectId, cancellationToken);
|
||||
if (conflict is not null)
|
||||
{
|
||||
return Conflict("That Microsoft account is already linked to another Jobbjakt user.");
|
||||
}
|
||||
|
||||
user.MicrosoftSubject = microsoft.Subject;
|
||||
user.MicrosoftTenantId = tenantId;
|
||||
user.MicrosoftObjectId = objectId;
|
||||
user.MicrosoftEmail = microsoft.Email;
|
||||
user.MicrosoftLinkedAt = DateTimeOffset.UtcNow;
|
||||
user.DisplayName ??= TrimOrNull(microsoft.Name);
|
||||
@@ -555,12 +793,16 @@ public sealed class AuthController : ControllerBase
|
||||
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
await SessionRevocation.RevokeAllAsync(_db, user.Id, trustedDeviceHashToKeep: null, cancellationToken);
|
||||
ClearSessionCookies();
|
||||
TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
|
||||
|
||||
return Ok(new MicrosoftLinkDto(true, user.MicrosoftEmail, user.MicrosoftLinkedAt));
|
||||
}
|
||||
|
||||
[HttpDelete("microsoft/link")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> UnlinkMicrosoft()
|
||||
public async Task<IActionResult> UnlinkMicrosoft([FromBody] MicrosoftUnlinkRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null)
|
||||
@@ -568,7 +810,12 @@ public sealed class AuthController : ControllerBase
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (!await _users.HasPasswordAsync(user) || !await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
|
||||
return BadRequest("A current password is required before unlinking Microsoft.");
|
||||
|
||||
user.MicrosoftSubject = null;
|
||||
user.MicrosoftTenantId = null;
|
||||
user.MicrosoftObjectId = null;
|
||||
user.MicrosoftEmail = null;
|
||||
user.MicrosoftLinkedAt = null;
|
||||
|
||||
@@ -578,6 +825,10 @@ public sealed class AuthController : ControllerBase
|
||||
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
await SessionRevocation.RevokeAllAsync(_db, user.Id, trustedDeviceHashToKeep: null, cancellationToken);
|
||||
ClearSessionCookies();
|
||||
TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -656,7 +907,7 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
[HttpPost("change-password")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null)
|
||||
@@ -671,6 +922,10 @@ public sealed class AuthController : ControllerBase
|
||||
if (!res.Succeeded)
|
||||
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
|
||||
var currentTrustedDevice = TrustedDeviceService.CurrentDeviceTokenHash(Request);
|
||||
await SessionRevocation.RevokeAllAsync(_db, user.Id, currentTrustedDevice, cancellationToken);
|
||||
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, false, _externalOrigin.UsesHttps, cancellationToken);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -685,20 +940,14 @@ public sealed class AuthController : ControllerBase
|
||||
if (email.Length == 0) return NoContent();
|
||||
|
||||
var user = await _users.FindByEmailAsync(email);
|
||||
if (user is null || string.IsNullOrWhiteSpace(user.Email))
|
||||
if (user is null || string.IsNullOrWhiteSpace(user.Email) || !user.EmailConfirmed || !await _users.HasPasswordAsync(user))
|
||||
{
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
var token = await _users.GeneratePasswordResetTokenAsync(user);
|
||||
|
||||
var baseUrl = (_cfg["App:PublicBaseUrl"] ?? string.Empty).Trim().TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
baseUrl = $"{Request.Scheme}://{Request.Host}";
|
||||
}
|
||||
|
||||
var link = $"{baseUrl}/reset-password?email={Uri.EscapeDataString(user.Email)}&token={Uri.EscapeDataString(token)}";
|
||||
var link = _externalOrigin.BuildPath($"/reset-password?email={Uri.EscapeDataString(user.Email)}&token={Uri.EscapeDataString(token)}");
|
||||
|
||||
try
|
||||
{
|
||||
@@ -723,7 +972,7 @@ public sealed class AuthController : ControllerBase
|
||||
[HttpPost("reset-password")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("auth-email")]
|
||||
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request)
|
||||
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var email = (request.Email ?? string.Empty).Trim();
|
||||
var token = request.Token ?? string.Empty;
|
||||
@@ -740,6 +989,14 @@ public sealed class AuthController : ControllerBase
|
||||
if (!res.Succeeded)
|
||||
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
|
||||
await SessionRevocation.RevokeAllAsync(_db, user.Id, trustedDeviceHashToKeep: null, cancellationToken);
|
||||
if (SessionRevocation.TryReadIdentity(User, Request.Cookies[AuthSessionOptions.SessionCookieName], out var currentUserId, out _)
|
||||
&& string.Equals(currentUserId, user.Id, StringComparison.Ordinal))
|
||||
{
|
||||
ClearSessionCookies();
|
||||
}
|
||||
TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -803,13 +1060,7 @@ public sealed class AuthController : ControllerBase
|
||||
{
|
||||
var token = await _users.GenerateEmailConfirmationTokenAsync(user);
|
||||
|
||||
var baseUrl = (_cfg["App:PublicBaseUrl"] ?? string.Empty).Trim().TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
baseUrl = $"{Request.Scheme}://{Request.Host}";
|
||||
}
|
||||
|
||||
var link = $"{baseUrl}/verify-email?userId={Uri.EscapeDataString(user.Id)}&token={Uri.EscapeDataString(token)}";
|
||||
var link = _externalOrigin.BuildPath($"/verify-email?userId={Uri.EscapeDataString(user.Id)}&token={Uri.EscapeDataString(token)}");
|
||||
|
||||
await _email.SendAsync(
|
||||
user.Email!,
|
||||
@@ -837,32 +1088,30 @@ public sealed class AuthController : ControllerBase
|
||||
// (no cookie, wrong user, expired, revoked) -- never errors, just doesn't skip.
|
||||
if (user.TwoFactorEnabled && await TrustedDeviceService.IsDeviceTrustedAsync(_db, Request, user.Id, cancellationToken))
|
||||
{
|
||||
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, cancellationToken);
|
||||
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, _externalOrigin.UsesHttps, cancellationToken);
|
||||
return Ok(new AuthSessionResult(true, provider));
|
||||
}
|
||||
|
||||
if (user.TwoFactorEnabled)
|
||||
{
|
||||
var pendingToken = _twoFactorPending.IssuePendingToken(user.Id, rememberMe);
|
||||
var pendingToken = _twoFactorPending.IssuePendingToken(user.Id, rememberMe, user.SecurityStamp);
|
||||
return Ok(new TwoFactorRequiredResult(true, pendingToken));
|
||||
}
|
||||
|
||||
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, cancellationToken);
|
||||
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, _externalOrigin.UsesHttps, cancellationToken);
|
||||
return Ok(new AuthSessionResult(true, provider));
|
||||
}
|
||||
|
||||
private void EnsureCsrfCookie(bool persistent, bool? secureOverride = null)
|
||||
private void EnsureCsrfCookie(bool persistent)
|
||||
{
|
||||
var secure = secureOverride ?? Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
|
||||
var csrf = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
|
||||
Response.Cookies.Append(AuthSessionOptions.CsrfCookieName, csrf, AuthSessionOptions.BuildCsrfCookie(persistent, secure));
|
||||
Response.Cookies.Append(AuthSessionOptions.CsrfCookieName, csrf, AuthSessionOptions.BuildCsrfCookie(persistent, _externalOrigin.UsesHttps));
|
||||
}
|
||||
|
||||
private void ClearSessionCookies()
|
||||
{
|
||||
var secure = Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
|
||||
Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(secure));
|
||||
Response.Cookies.Delete(AuthSessionOptions.CsrfCookieName, AuthSessionOptions.BuildExpiredReadableCookie(secure));
|
||||
Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(_externalOrigin.UsesHttps));
|
||||
Response.Cookies.Delete(AuthSessionOptions.CsrfCookieName, AuthSessionOptions.BuildExpiredReadableCookie(_externalOrigin.UsesHttps));
|
||||
}
|
||||
|
||||
private static string? DetectAvatarContentType(byte[] bytes)
|
||||
@@ -909,9 +1158,20 @@ public sealed class AuthController : ControllerBase
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static bool TryGetMicrosoftKey(MicrosoftTokenPrincipal principal, out string tenantId, out string objectId)
|
||||
{
|
||||
tenantId = Guid.TryParse(principal.TenantId, out var tenant) ? tenant.ToString("D") : string.Empty;
|
||||
objectId = Guid.TryParse(principal.ObjectId, out var obj) ? obj.ToString("D") : string.Empty;
|
||||
return tenantId.Length > 0 && objectId.Length > 0;
|
||||
}
|
||||
|
||||
private static string MicrosoftLegacyRelinkPurpose(string tenantId, string objectId)
|
||||
=> $"microsoft-legacy-relink:{tenantId}:{objectId}";
|
||||
|
||||
private static MeResult ToMeResult(ApplicationUser user, IList<string> roles)
|
||||
{
|
||||
var entitlements = AccountPlans.ForRoles(roles);
|
||||
var planEntitlements = AccountPlans.ForRoles(roles);
|
||||
var entitlements = planEntitlements with { Ai = planEntitlements.Ai && user.AiEnabled };
|
||||
return new MeResult(
|
||||
Provider: "local",
|
||||
Id: user.Id,
|
||||
@@ -924,14 +1184,14 @@ public sealed class AuthController : ControllerBase
|
||||
ProfileCvStructureJson: user.ProfileCvStructureJson,
|
||||
AvatarImageDataUrl: AvatarStorage.Resolve(user.AvatarImageDataUrl),
|
||||
Roles: roles,
|
||||
Plan: entitlements.AdvancedAi ? "premium" : "free",
|
||||
Plan: AccountPlans.Name(planEntitlements),
|
||||
Entitlements: entitlements,
|
||||
GoogleLink: new GoogleLinkDto(
|
||||
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
|
||||
Email: user.GoogleEmail,
|
||||
LinkedAt: user.GoogleLinkedAt),
|
||||
MicrosoftLink: new MicrosoftLinkDto(
|
||||
Linked: !string.IsNullOrWhiteSpace(user.MicrosoftSubject),
|
||||
Linked: !string.IsNullOrWhiteSpace(user.MicrosoftTenantId) && !string.IsNullOrWhiteSpace(user.MicrosoftObjectId),
|
||||
Email: user.MicrosoftEmail,
|
||||
LinkedAt: user.MicrosoftLinkedAt));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -16,17 +17,20 @@ public sealed class BillingController : ControllerBase
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
private readonly RoleManager<IdentityRole> _roles;
|
||||
private readonly ILogger<BillingController> _logger;
|
||||
private readonly ExternalOrigin _externalOrigin;
|
||||
|
||||
public BillingController(
|
||||
IConfiguration configuration,
|
||||
UserManager<ApplicationUser> users,
|
||||
RoleManager<IdentityRole> roles,
|
||||
ILogger<BillingController> logger)
|
||||
ILogger<BillingController> logger,
|
||||
ExternalOrigin? externalOrigin = null)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_users = users;
|
||||
_roles = roles;
|
||||
_logger = logger;
|
||||
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(configuration);
|
||||
}
|
||||
|
||||
public sealed record BillingRedirectDto(string Url);
|
||||
@@ -44,7 +48,7 @@ public sealed class BillingController : ControllerBase
|
||||
var entitlements = AccountPlans.ForRoles(await _users.GetRolesAsync(user));
|
||||
return Ok(new BillingStatusDto(
|
||||
enabled,
|
||||
enabled && !entitlements.AdvancedAi,
|
||||
enabled && !entitlements.Ai,
|
||||
enabled && !string.IsNullOrWhiteSpace(user.StripeCustomerId)));
|
||||
}
|
||||
|
||||
@@ -60,8 +64,8 @@ public sealed class BillingController : ControllerBase
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var currentRoles = await _users.GetRolesAsync(user);
|
||||
if (AccountPlans.ForRoles(currentRoles).AdvancedAi)
|
||||
return Conflict("This account already has Premium access.");
|
||||
if (AccountPlans.ForRoles(currentRoles).Ai)
|
||||
return Conflict("This account already has Pro access.");
|
||||
|
||||
var metadata = new Dictionary<string, string> { [UserMetadataKey] = user.Id };
|
||||
var options = new Stripe.Checkout.SessionCreateOptions
|
||||
@@ -224,9 +228,7 @@ public sealed class BillingController : ControllerBase
|
||||
secretKey = (_configuration["Stripe:SecretKey"] ?? string.Empty).Trim();
|
||||
premiumPrice = (_configuration["Stripe:PricePremium"] ?? string.Empty).Trim();
|
||||
webhookSecret = (_configuration["Stripe:WebhookSecret"] ?? string.Empty).Trim();
|
||||
publicBaseUrl = (_configuration["App:PublicBaseUrl"] ?? string.Empty).Trim().TrimEnd('/');
|
||||
return secretKey.Length > 0 && premiumPrice.Length > 0 && webhookSecret.Length > 0
|
||||
&& Uri.TryCreate(publicBaseUrl, UriKind.Absolute, out var uri)
|
||||
&& uri.Scheme is "http" or "https";
|
||||
publicBaseUrl = _externalOrigin.BaseUrl;
|
||||
return secretKey.Length > 0 && premiumPrice.Length > 0 && webhookSecret.Length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ public sealed class CvVariantController : ControllerBase
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
var premiumThemes = AccountPlans.ForRoles(await _users.GetRolesAsync(user)).PremiumThemes;
|
||||
var proThemes = AccountPlans.ForRoles(await _users.GetRolesAsync(user)).ProThemes;
|
||||
var themes = CvThemeCatalog.Themes.Select(t => new
|
||||
{
|
||||
id = t.Id,
|
||||
@@ -54,8 +54,8 @@ public sealed class CvVariantController : ControllerBase
|
||||
photoShape = t.PhotoShape,
|
||||
supportsIcons = t.DefaultIcons,
|
||||
atsFriendly = t.AtsFriendly,
|
||||
premium = t.Premium,
|
||||
available = premiumThemes || !t.Premium,
|
||||
requiresPro = t.Premium,
|
||||
available = proThemes || !t.Premium,
|
||||
swatches = new[] { t.Accent, t.SidebarBg, t.Paper },
|
||||
});
|
||||
return Ok(themes);
|
||||
@@ -87,7 +87,7 @@ public sealed class CvVariantController : ControllerBase
|
||||
if (request?.Settings is not null && !CvThemeCatalog.Exists(request.Settings.ThemeId))
|
||||
return BadRequest("Unknown theme.");
|
||||
if (request?.Settings is not null && !await CanUseThemeAsync(user, request.Settings.ThemeId))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Premium.");
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro.");
|
||||
var variant = await _variants.CreateAsync(user.Id, request?.Name, request?.JobApplicationId, request?.Settings, ct);
|
||||
return Ok(ToDto(variant));
|
||||
}
|
||||
@@ -113,7 +113,7 @@ public sealed class CvVariantController : ControllerBase
|
||||
var current = await _variants.GetAsync(user.Id, id, ct);
|
||||
if (current is null) return NotFound();
|
||||
if (!string.Equals(CvVariantSettingsJson.Deserialize(current.SettingsJson).ThemeId, settings.ThemeId, StringComparison.OrdinalIgnoreCase))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Premium.");
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro.");
|
||||
}
|
||||
var variant = await _variants.SaveAsync(user.Id, id, request.Name, settings, request.Source ?? "autosave", ct);
|
||||
return variant is null ? NotFound() : Ok(ToDto(variant));
|
||||
@@ -197,6 +197,7 @@ public sealed class CvVariantController : ControllerBase
|
||||
// AI assistance on any text area. Never mutates the profile or variant — returns a suggestion the
|
||||
// user reviews and applies themselves. Reuses the existing provider abstraction (ISummarizerService).
|
||||
[HttpPost("ai/assist")]
|
||||
[Authorize(Policy = ProEntitlement.Policy)]
|
||||
public async Task<ActionResult<AiAssistResult>> AiAssist([FromBody] AiAssistRequest request, CancellationToken ct)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
@@ -238,7 +239,7 @@ public sealed class CvVariantController : ControllerBase
|
||||
}
|
||||
|
||||
private async Task<bool> CanUseThemeAsync(ApplicationUser user, string? themeId) =>
|
||||
CvThemeCatalog.CanUse(themeId, AccountPlans.ForRoles(await _users.GetRolesAsync(user)).PremiumThemes);
|
||||
CvThemeCatalog.CanUse(themeId, AccountPlans.ForRoles(await _users.GetRolesAsync(user)).ProThemes);
|
||||
|
||||
private static CvRenderPerson Person(ApplicationUser user)
|
||||
{
|
||||
|
||||
@@ -19,15 +19,15 @@ public sealed class GmailController : ControllerBase
|
||||
private readonly IGmailOAuthService _gmail;
|
||||
private readonly IGmailJobMatchingService _matching;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly IEmailProviderRegistry _providers;
|
||||
private readonly ExternalOrigin _externalOrigin;
|
||||
|
||||
public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg, IEmailProviderRegistry? providers = null)
|
||||
public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg, IEmailProviderRegistry? providers = null, ExternalOrigin? externalOrigin = null)
|
||||
{
|
||||
_gmail = gmail;
|
||||
_matching = matching;
|
||||
_db = db;
|
||||
_cfg = cfg;
|
||||
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
|
||||
// Fall back to a single-Gmail registry so direct construction (tests) keeps working.
|
||||
_providers = providers ?? new EmailProviderRegistry(new IEmailProvider[] { new GmailProvider(gmail) });
|
||||
}
|
||||
@@ -1011,16 +1011,7 @@ public sealed class GmailController : ControllerBase
|
||||
|
||||
private string GetRedirectUri()
|
||||
{
|
||||
var configured = (_cfg["Google:GmailRedirectUri"] ?? _cfg["Google:RedirectUri"] ?? "").Trim();
|
||||
if (!string.IsNullOrWhiteSpace(configured)) return configured;
|
||||
|
||||
var publicBaseUrl = (_cfg["App:PublicBaseUrl"] ?? "").Trim().TrimEnd('/');
|
||||
if (!string.IsNullOrWhiteSpace(publicBaseUrl))
|
||||
{
|
||||
return $"{publicBaseUrl}/api/gmail/oauth/callback";
|
||||
}
|
||||
|
||||
return $"{Request.Scheme}://{Request.Host}/api/gmail/oauth/callback";
|
||||
return _externalOrigin.BuildPath("/api/gmail/oauth/callback");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -155,8 +155,6 @@ namespace JobTrackerApi.Controllers
|
||||
|
||||
public sealed record JobEventDto(int Id, string Type, string? OldValue, string? NewValue, string? Note, DateTime At);
|
||||
|
||||
public sealed record TimelineItemDto(string Kind, DateTime At, object Data);
|
||||
|
||||
public sealed record AnalyticsPoint(string Month, int Applied, int Responses);
|
||||
|
||||
public sealed record TagPoint(string Tag, int Count);
|
||||
|
||||
@@ -82,6 +82,13 @@ namespace JobTrackerApi.Controllers
|
||||
return await _users.FindByIdAsync(userId);
|
||||
}
|
||||
|
||||
private async Task<bool> CanCurrentUserUseAiAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var user = await GetCurrentUserAsync(cancellationToken);
|
||||
return user is not null && user.AiEnabled && AccountPlans.ForRoles(await _users.GetRolesAsync(user)).Ai;
|
||||
}
|
||||
|
||||
private async Task<TailoredCvDraft?> FindTailoredCvDraftAsync(int jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == jobId, cancellationToken);
|
||||
@@ -622,7 +629,9 @@ Canonical profile:
|
||||
var d = RulesEngine.Evaluate(settings, job, now, lm);
|
||||
// Prefer translated content for the detailed summary so Norwegian postings
|
||||
// surface readable English analysis while the original text remains available.
|
||||
var full = await _summarizer.SummarizeAsync(BuildSummarySource(job), 250, 40);
|
||||
var full = await CanCurrentUserUseAiAsync(cancellationToken)
|
||||
? await _summarizer.SummarizeAsync(BuildSummarySource(job), 250, 40)
|
||||
: null;
|
||||
|
||||
return Ok(BuildJobApplicationDto(job, d, fullSummary: full));
|
||||
}
|
||||
@@ -774,8 +783,8 @@ Canonical profile:
|
||||
// Generate and persist a short summary at creation time to avoid repeated model calls.
|
||||
try
|
||||
{
|
||||
var shortSum = await _summarizer.SummarizeAsync(BuildSummarySource(job), 160, 60);
|
||||
job.ShortSummary = shortSum;
|
||||
if (await CanCurrentUserUseAiAsync(cancellationToken))
|
||||
job.ShortSummary = await _summarizer.SummarizeAsync(BuildSummarySource(job), 160, 60);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -957,6 +966,7 @@ Canonical profile:
|
||||
|
||||
|
||||
[HttpPost("{id:int}/refresh-ai")]
|
||||
[Authorize(Policy = ProEntitlement.Policy)]
|
||||
public async Task<ActionResult<JobApplicationDto>> RefreshAi([FromRoute] int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
@@ -1082,51 +1092,6 @@ Canonical profile:
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/timeline")]
|
||||
public async Task<ActionResult<List<TimelineItemDto>>> GetTimeline([FromRoute] int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await _db.JobApplications.AnyAsync(j => j.Id == id, cancellationToken);
|
||||
if (!exists) return NotFound();
|
||||
|
||||
var events = await _db.JobEvents
|
||||
.AsNoTracking()
|
||||
.Where(e => e.JobApplicationId == id)
|
||||
.Select(e => new TimelineItemDto(
|
||||
"event",
|
||||
e.At,
|
||||
new { e.Id, e.Type, e.OldValue, e.NewValue, e.Note }
|
||||
))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var messages = await _db.Correspondences
|
||||
.AsNoTracking()
|
||||
.Where(c => c.JobApplicationId == id)
|
||||
.Select(c => new TimelineItemDto(
|
||||
"message",
|
||||
c.Date,
|
||||
new { c.Id, c.From, c.Subject, c.Channel, c.Content }
|
||||
))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var attachments = await _db.Attachments
|
||||
.AsNoTracking()
|
||||
.Where(a => a.JobApplicationId == id)
|
||||
.Select(a => new TimelineItemDto(
|
||||
"attachment",
|
||||
a.UploadDate,
|
||||
new { a.Id, a.FileName, a.FileType, a.FileSize }
|
||||
))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var all = events
|
||||
.Concat(messages)
|
||||
.Concat(attachments)
|
||||
.OrderByDescending(x => x.At)
|
||||
.ToList();
|
||||
|
||||
return Ok(all);
|
||||
}
|
||||
|
||||
[HttpGet("stats")]
|
||||
public async Task<ActionResult<JobStats>> GetStats(CancellationToken cancellationToken)
|
||||
=> Ok(await _analytics.GetStatsAsync(cancellationToken));
|
||||
@@ -1424,6 +1389,7 @@ Canonical profile:
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/candidate-fit")]
|
||||
[Authorize(Policy = ProEntitlement.Policy)]
|
||||
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
@@ -1559,6 +1525,7 @@ Candidate CV/profile:
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/focus-plan")]
|
||||
[Authorize(Policy = ProEntitlement.Policy)]
|
||||
public async Task<ActionResult<FocusPlanDto>> GetFocusPlan([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
@@ -1676,7 +1643,8 @@ Candidate master CV:
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/interview-prep")]
|
||||
[HttpGet("{id:int}/interview-prep/brief")]
|
||||
[Authorize(Policy = ProEntitlement.Policy)]
|
||||
public async Task<ActionResult<InterviewPrepDto>> GetInterviewPrep([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
@@ -1867,6 +1835,7 @@ Candidate master CV:
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/generate-tailored-cv-draft")]
|
||||
[Authorize(Policy = ProEntitlement.Policy)]
|
||||
public async Task<ActionResult<TailoredCvDraftDto>> GenerateTailoredCvDraft([FromRoute] int id, [FromQuery] string? mode, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
@@ -1975,6 +1944,7 @@ Candidate master CV:
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/generate-application-package")]
|
||||
[Authorize(Policy = ProEntitlement.Policy)]
|
||||
public async Task<ActionResult<GenerateApplicationPackageDto>> GenerateApplicationPackage([FromRoute] int id, [FromQuery] string? mode, [FromQuery] string? coverLetterStyle, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
@@ -2240,6 +2210,7 @@ Candidate master CV:
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/followup-draft")]
|
||||
[Authorize(Policy = ProEntitlement.Policy)]
|
||||
public async Task<ActionResult<FollowUpDraftDto>> GetFollowUpDraft([FromRoute] int id, [FromQuery] string? mode, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
|
||||
@@ -17,12 +17,12 @@ namespace JobTrackerApi.Controllers;
|
||||
public sealed class MicrosoftGraphController : ControllerBase
|
||||
{
|
||||
private readonly IMicrosoftGraphOAuthService _graph;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ExternalOrigin _externalOrigin;
|
||||
|
||||
public MicrosoftGraphController(IMicrosoftGraphOAuthService graph, IConfiguration cfg)
|
||||
public MicrosoftGraphController(IMicrosoftGraphOAuthService graph, IConfiguration cfg, ExternalOrigin? externalOrigin = null)
|
||||
{
|
||||
_graph = graph;
|
||||
_cfg = cfg;
|
||||
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
|
||||
}
|
||||
|
||||
public sealed record MicrosoftGraphConnectionStatusDto(
|
||||
@@ -110,16 +110,7 @@ public sealed class MicrosoftGraphController : ControllerBase
|
||||
|
||||
private string GetRedirectUri()
|
||||
{
|
||||
var configured = (_cfg["Microsoft:RedirectUri"] ?? "").Trim();
|
||||
if (!string.IsNullOrWhiteSpace(configured)) return configured;
|
||||
|
||||
var publicBaseUrl = (_cfg["App:PublicBaseUrl"] ?? "").Trim().TrimEnd('/');
|
||||
if (!string.IsNullOrWhiteSpace(publicBaseUrl))
|
||||
{
|
||||
return $"{publicBaseUrl}/api/microsoft-graph/oauth/callback";
|
||||
}
|
||||
|
||||
return $"{Request.Scheme}://{Request.Host}/api/microsoft-graph/oauth/callback";
|
||||
return _externalOrigin.BuildPath("/api/microsoft-graph/oauth/callback");
|
||||
}
|
||||
|
||||
private static string BuildPopupHtml(bool success, string message)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/operations")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public sealed class OperationsController(UserOperationStore operations) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<OperationDto>>> List([FromQuery] int limit = 25, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (limit is < 1 or > 100) return BadRequest(new { code = "invalid_limit", message = "Limit must be between 1 and 100." });
|
||||
return Ok((await operations.ListAsync(limit, cancellationToken)).Select(ToDto).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<OperationDto>> Get(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var operation = await operations.GetAsync(id, cancellationToken);
|
||||
return operation is null ? NotFound() : Ok(ToDto(operation));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/cancel")]
|
||||
public async Task<ActionResult<OperationDto>> Cancel(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await operations.GetAsync(id, cancellationToken) is null) return NotFound();
|
||||
if (!await operations.RequestCancellationAsync(id, cancellationToken))
|
||||
return Conflict(new { code = "operation_not_cancellable", message = "This operation can no longer be cancelled." });
|
||||
return Ok(ToDto((await operations.GetAsync(id, cancellationToken))!));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/retry")]
|
||||
public async Task<ActionResult<OperationDto>> Retry(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await operations.GetAsync(id, cancellationToken) is null) return NotFound();
|
||||
if (!await operations.RetryAsync(id, cancellationToken))
|
||||
return Conflict(new { code = "operation_not_retryable", message = "Only failed or cancelled operations can be retried." });
|
||||
return Ok(ToDto((await operations.GetAsync(id, cancellationToken))!));
|
||||
}
|
||||
|
||||
private static OperationDto ToDto(UserOperation operation) => new(
|
||||
operation.Id,
|
||||
operation.TaskType,
|
||||
operation.Status,
|
||||
operation.SubjectType,
|
||||
operation.CreatedAtUtc,
|
||||
operation.StartedAtUtc,
|
||||
operation.CompletedAtUtc,
|
||||
operation.DeadlineAtUtc,
|
||||
operation.CancellationRequestedAtUtc,
|
||||
operation.ProgressStage,
|
||||
operation.ProgressPercent,
|
||||
operation.FailureCategory,
|
||||
!OperationStatuses.IsTerminal(operation.Status) && operation.CancellationRequestedAtUtc is null,
|
||||
operation.Status is OperationStatuses.Failed or OperationStatuses.Cancelled);
|
||||
}
|
||||
|
||||
public sealed record OperationDto(
|
||||
Guid Id,
|
||||
string TaskType,
|
||||
string Status,
|
||||
string? SubjectType,
|
||||
DateTime CreatedAtUtc,
|
||||
DateTime? StartedAtUtc,
|
||||
DateTime? CompletedAtUtc,
|
||||
DateTime? DeadlineAtUtc,
|
||||
DateTime? CancellationRequestedAtUtc,
|
||||
string? ProgressStage,
|
||||
int? ProgressPercent,
|
||||
string? FailureCategory,
|
||||
bool CanCancel,
|
||||
bool CanRetry);
|
||||
|
||||
[ApiController]
|
||||
[Route("api/notifications")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public sealed class NotificationsController(UserNotificationStore notifications) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<NotificationDto>>> List([FromQuery] int limit = 25, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (limit is < 1 or > 100) return BadRequest(new { code = "invalid_limit", message = "Limit must be between 1 and 100." });
|
||||
return Ok((await notifications.ListAsync(limit, cancellationToken)).Select(ToDto).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("unread-count")]
|
||||
public async Task<IActionResult> UnreadCount(CancellationToken cancellationToken) =>
|
||||
Ok(new { count = await notifications.UnreadCountAsync(cancellationToken) });
|
||||
|
||||
[HttpPost("{id:guid}/read")]
|
||||
public async Task<IActionResult> MarkRead(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await notifications.GetAsync(id, cancellationToken) is null) return NotFound();
|
||||
await notifications.MarkReadAsync(id, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> Dismiss(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await notifications.GetAsync(id, cancellationToken) is null) return NotFound();
|
||||
await notifications.DismissAsync(id, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static NotificationDto ToDto(UserNotification notification) => new(
|
||||
notification.Id,
|
||||
notification.OperationId,
|
||||
notification.Kind,
|
||||
notification.Title,
|
||||
notification.Message,
|
||||
notification.LinkPath,
|
||||
notification.CreatedAtUtc,
|
||||
notification.ReadAtUtc);
|
||||
}
|
||||
|
||||
public sealed record NotificationDto(
|
||||
Guid Id,
|
||||
Guid? OperationId,
|
||||
string Kind,
|
||||
string Title,
|
||||
string Message,
|
||||
string? LinkPath,
|
||||
DateTime CreatedAtUtc,
|
||||
DateTime? ReadAtUtc);
|
||||
@@ -327,6 +327,17 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.AiEnabled || !AccountPlans.ForRoles(await _users.GetRolesAsync(user)).Ai)
|
||||
{
|
||||
run.Status = "failed";
|
||||
run.ErrorMessage = user.AiEnabled
|
||||
? "This AI feature requires Pro."
|
||||
: "AI is disabled in your privacy settings.";
|
||||
run.CompletedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
run.Status = "running";
|
||||
run.ErrorMessage = null;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
@@ -416,11 +427,11 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
|
||||
private async Task PruneExtractionRunsAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var expired = await _db.CvExtractionRuns.IgnoreQueryFilters()
|
||||
.Where(x => x.OwnerUserId == ownerUserId && x.Status != "queued" && x.Status != "running")
|
||||
.OrderByDescending(x => x.StartedAtUtc)
|
||||
.Skip(ExtractionRunRetentionCount)
|
||||
.ToListAsync(cancellationToken);
|
||||
var completedRuns = _db.CvExtractionRuns.IgnoreQueryFilters()
|
||||
.Where(x => x.OwnerUserId == ownerUserId && x.Status != "queued" && x.Status != "running");
|
||||
var expired = _db.Database.IsSqlite()
|
||||
? (await completedRuns.ToListAsync(cancellationToken)).OrderByDescending(x => x.StartedAtUtc).Skip(ExtractionRunRetentionCount).ToList()
|
||||
: await completedRuns.OrderByDescending(x => x.StartedAtUtc).Skip(ExtractionRunRetentionCount).ToListAsync(cancellationToken);
|
||||
if (expired.Count > 0)
|
||||
{
|
||||
_db.CvExtractionRuns.RemoveRange(expired);
|
||||
|
||||
@@ -136,6 +136,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification);
|
||||
|
||||
[HttpPost("upload")]
|
||||
[Authorize(Policy = ProEntitlement.Policy)]
|
||||
[RequestSizeLimit(MaxFileSizeBytes)]
|
||||
public async Task<IActionResult> Upload([FromForm] IFormFile file)
|
||||
{
|
||||
@@ -206,11 +207,9 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var runs = await _db.CvExtractionRuns
|
||||
var runsQuery = _db.CvExtractionRuns
|
||||
.AsNoTracking()
|
||||
.Where(x => x.OwnerUserId == user.Id)
|
||||
.OrderByDescending(x => x.StartedAtUtc)
|
||||
.Take(10)
|
||||
.Select(x => new CvExtractionRunListItem(
|
||||
x.Id,
|
||||
x.Trigger,
|
||||
@@ -222,8 +221,10 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
x.ParserVersion,
|
||||
x.NormalizerVersion,
|
||||
x.LlmPromptVersion,
|
||||
x.ErrorMessage))
|
||||
.ToListAsync(HttpContext.RequestAborted);
|
||||
x.ErrorMessage));
|
||||
var runs = _db.Database.IsSqlite()
|
||||
? (await runsQuery.ToListAsync(HttpContext.RequestAborted)).OrderByDescending(x => x.StartedAtUtc).Take(10).ToList()
|
||||
: await runsQuery.OrderByDescending(x => x.StartedAtUtc).Take(10).ToListAsync(HttpContext.RequestAborted);
|
||||
|
||||
return Ok(runs);
|
||||
}
|
||||
@@ -294,15 +295,16 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("reprocess")]
|
||||
[Authorize(Policy = ProEntitlement.Policy)]
|
||||
public async Task<IActionResult> Reprocess()
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var artifact = await _db.CvUploadArtifacts
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(x => x.UploadedAtUtc)
|
||||
.FirstOrDefaultAsync(x => x.OwnerUserId == user.Id, HttpContext.RequestAborted);
|
||||
var artifactQuery = _db.CvUploadArtifacts.AsNoTracking().Where(x => x.OwnerUserId == user.Id);
|
||||
var artifact = _db.Database.IsSqlite()
|
||||
? (await artifactQuery.ToListAsync(HttpContext.RequestAborted)).MaxBy(x => x.UploadedAtUtc)
|
||||
: await artifactQuery.OrderByDescending(x => x.UploadedAtUtc).FirstOrDefaultAsync(HttpContext.RequestAborted);
|
||||
|
||||
if (artifact is null) return BadRequest("Upload a CV before reprocessing it.");
|
||||
if (string.IsNullOrWhiteSpace(artifact.StoragePath) || !System.IO.File.Exists(artifact.StoragePath))
|
||||
@@ -316,6 +318,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("rebuild")]
|
||||
[Authorize(Policy = ProEntitlement.Policy)]
|
||||
public async Task<IActionResult> Rebuild()
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
@@ -328,6 +331,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("rewrite-section")]
|
||||
[Authorize(Policy = ProEntitlement.Policy)]
|
||||
public async Task<IActionResult> RewriteSection([FromBody] RewriteSectionRequest request)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
@@ -431,6 +435,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("rewrite-preview")]
|
||||
[Authorize(Policy = ProEntitlement.Policy)]
|
||||
public async Task<ActionResult<ProfileCvPreviewDto>> BuildRewritePreview([FromBody] RewriteSectionRequest request)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
@@ -473,6 +478,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("export-pdf")]
|
||||
[Authorize(Policy = ProEntitlement.Policy)]
|
||||
public async Task<IActionResult> ExportProfileCvPdf([FromBody] RewriteSectionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var previewResult = await BuildRewritePreview(request);
|
||||
@@ -492,6 +498,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("parse")]
|
||||
[Authorize(Policy = ProEntitlement.Policy)]
|
||||
public async Task<ActionResult<object>> Parse([FromBody] ParseCvRequest? request)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
@@ -512,6 +519,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("improve")]
|
||||
[Authorize(Policy = ProEntitlement.Policy)]
|
||||
public async Task<IActionResult> Improve()
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
|
||||
@@ -18,11 +18,13 @@ public sealed class SessionsController : ControllerBase
|
||||
{
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly ExternalOrigin _externalOrigin;
|
||||
|
||||
public SessionsController(UserManager<ApplicationUser> users, JobTrackerContext db)
|
||||
public SessionsController(UserManager<ApplicationUser> users, JobTrackerContext db, ExternalOrigin? externalOrigin = null)
|
||||
{
|
||||
_users = users;
|
||||
_db = db;
|
||||
_externalOrigin = externalOrigin ?? ExternalOrigin.Parse(null, production: false);
|
||||
}
|
||||
|
||||
public sealed record SessionDto(string Id, string? DeviceLabel, DateTimeOffset CreatedAtUtc, DateTimeOffset LastSeenAtUtc, DateTimeOffset ExpiresAtUtc, bool IsCurrentSession);
|
||||
@@ -71,8 +73,7 @@ public sealed class SessionsController : ControllerBase
|
||||
|
||||
if (string.Equals(id, CurrentSid, StringComparison.Ordinal))
|
||||
{
|
||||
var secure = Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
|
||||
Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(secure));
|
||||
Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(_externalOrigin.UsesHttps));
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
|
||||
@@ -29,8 +29,9 @@ public sealed class TwoFactorController : ControllerBase
|
||||
private readonly ITwoFactorPendingTokenService _pending;
|
||||
private readonly IDataProtector _protector;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ExternalOrigin _externalOrigin;
|
||||
|
||||
public TwoFactorController(UserManager<ApplicationUser> users, ITokenService tokens, JobTrackerContext db, ITwoFactorPendingTokenService pending, IDataProtectionProvider protectionProvider, IConfiguration cfg)
|
||||
public TwoFactorController(UserManager<ApplicationUser> users, ITokenService tokens, JobTrackerContext db, ITwoFactorPendingTokenService pending, IDataProtectionProvider protectionProvider, IConfiguration cfg, ExternalOrigin? externalOrigin = null)
|
||||
{
|
||||
_users = users;
|
||||
_tokens = tokens;
|
||||
@@ -38,6 +39,7 @@ public sealed class TwoFactorController : ControllerBase
|
||||
_pending = pending;
|
||||
_protector = protectionProvider.CreateProtector("totp-secret-v1");
|
||||
_cfg = cfg;
|
||||
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
|
||||
}
|
||||
|
||||
public sealed record PasswordConfirmRequest(string CurrentPassword);
|
||||
@@ -198,17 +200,23 @@ public sealed class TwoFactorController : ControllerBase
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
if (session.SecurityStamp is not null
|
||||
&& !string.Equals(session.SecurityStamp, user.SecurityStamp, StringComparison.Ordinal))
|
||||
{
|
||||
_pending.Resolve(pendingToken, consume: true);
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var base32Secret = _protector.Unprotect(user.TotpSecretEncrypted);
|
||||
var verified = VerifyCode(base32Secret, code) || await TryConsumeRecoveryCodeAsync(user.Id, code, cancellationToken);
|
||||
if (!verified) return Unauthorized();
|
||||
|
||||
_pending.Resolve(pendingToken, consume: true);
|
||||
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, session.RememberMe, cancellationToken);
|
||||
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, session.RememberMe, _externalOrigin.UsesHttps, cancellationToken);
|
||||
|
||||
if (request.TrustDevice)
|
||||
{
|
||||
await TrustedDeviceService.IssueAsync(_db, Request, Response, user.Id, cancellationToken);
|
||||
await TrustedDeviceService.IssueAsync(_db, Request, Response, user.Id, _externalOrigin.UsesHttps, cancellationToken);
|
||||
}
|
||||
|
||||
return Ok(new AuthController.AuthSessionResult(true, "local"));
|
||||
@@ -250,7 +258,7 @@ public sealed class TwoFactorController : ControllerBase
|
||||
|
||||
if (isCurrentDevice)
|
||||
{
|
||||
TrustedDeviceService.ClearCookie(Request, Response);
|
||||
TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
@@ -270,7 +278,7 @@ public sealed class TwoFactorController : ControllerBase
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
TrustedDeviceService.ClearCookie(Request, Response);
|
||||
TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
||||
@@ -17,14 +17,14 @@ public sealed class UsersController : ControllerBase
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
private readonly RoleManager<IdentityRole> _roles;
|
||||
private readonly IAppEmailSender _email;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ExternalOrigin _externalOrigin;
|
||||
private readonly ILogger<UsersController> _logger;
|
||||
public UsersController(UserManager<ApplicationUser> users, RoleManager<IdentityRole> roles, IAppEmailSender email, IConfiguration cfg, ILogger<UsersController> logger)
|
||||
public UsersController(UserManager<ApplicationUser> users, RoleManager<IdentityRole> roles, IAppEmailSender email, IConfiguration cfg, ILogger<UsersController> logger, ExternalOrigin? externalOrigin = null)
|
||||
{
|
||||
_users = users;
|
||||
_roles = roles;
|
||||
_email = email;
|
||||
_cfg = cfg;
|
||||
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -146,13 +146,7 @@ public sealed class UsersController : ControllerBase
|
||||
|
||||
var token = await _users.GeneratePasswordResetTokenAsync(u);
|
||||
|
||||
var baseUrl = (_cfg["App:PublicBaseUrl"] ?? "").Trim().TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
baseUrl = $"{Request.Scheme}://{Request.Host}";
|
||||
}
|
||||
|
||||
var link = $"{baseUrl}/reset-password?email={Uri.EscapeDataString(u.Email)}&token={Uri.EscapeDataString(token)}";
|
||||
var link = _externalOrigin.BuildPath($"/reset-password?email={Uri.EscapeDataString(u.Email)}&token={Uri.EscapeDataString(token)}");
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user