Fix account and backup admin settings flows
This commit is contained in:
@@ -20,6 +20,7 @@ namespace JobTrackerApi.Data
|
|||||||
public DbSet<Attachment> Attachments => Set<Attachment>();
|
public DbSet<Attachment> Attachments => Set<Attachment>();
|
||||||
public DbSet<RuleSettings> RuleSettings => Set<RuleSettings>();
|
public DbSet<RuleSettings> RuleSettings => Set<RuleSettings>();
|
||||||
public DbSet<UserRuleSettings> UserRuleSettings => Set<UserRuleSettings>();
|
public DbSet<UserRuleSettings> UserRuleSettings => Set<UserRuleSettings>();
|
||||||
|
public DbSet<SystemEmailSettings> SystemEmailSettings => Set<SystemEmailSettings>();
|
||||||
public DbSet<JobEvent> JobEvents => Set<JobEvent>();
|
public DbSet<JobEvent> JobEvents => Set<JobEvent>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
|||||||
@@ -139,7 +139,13 @@ public sealed class AuthAndSystemControllerTests
|
|||||||
var summarizer = new Mock<ISummarizerService>();
|
var summarizer = new Mock<ISummarizerService>();
|
||||||
summarizer.Setup(x => x.RunProbeAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
summarizer.Setup(x => x.RunProbeAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
||||||
|
|
||||||
var controller = new AdminSystemController(BuildConfig(), new AppPaths(BuildConfig(), new FakeHostEnv()), null!, summarizer.Object, new FakeEnv());
|
var controller = new AdminSystemController(
|
||||||
|
BuildConfig(),
|
||||||
|
new AppPaths(BuildConfig(), new FakeHostEnv()),
|
||||||
|
null!,
|
||||||
|
summarizer.Object,
|
||||||
|
new FakeEnv(),
|
||||||
|
Mock.Of<IEmailSettingsResolver>());
|
||||||
|
|
||||||
var result = await controller.RunSummarizerProbe(CancellationToken.None);
|
var result = await controller.RunSummarizerProbe(CancellationToken.None);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using JobTrackerApi.Controllers;
|
||||||
|
using JobTrackerApi.Data;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
using JobTrackerApi.Services;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
public sealed class BackupControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Encrypted_returns_file_payload_on_non_windows_platforms_too()
|
||||||
|
{
|
||||||
|
await using var db = CreateDb();
|
||||||
|
db.Companies.Add(new Company { Name = "Acme", OwnerUserId = "user-1" });
|
||||||
|
db.JobApplications.Add(new JobApplication { JobTitle = "Backend Developer", OwnerUserId = "user-1" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var provider = DataProtectionProvider.Create(new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"jobtracker-tests-{Guid.NewGuid():N}")));
|
||||||
|
var controller = new BackupController(db, new NullLogger<BackupController>(), provider);
|
||||||
|
|
||||||
|
var result = await controller.Encrypted(CancellationToken.None);
|
||||||
|
|
||||||
|
var file = Assert.IsType<FileContentResult>(result);
|
||||||
|
Assert.Equal("application/octet-stream", file.ContentType);
|
||||||
|
Assert.EndsWith(".jtbackup", file.FileDownloadName);
|
||||||
|
Assert.NotEmpty(file.FileContents);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JobTrackerContext CreateDb()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
var currentUser = new Mock<ICurrentUserService>();
|
||||||
|
currentUser.SetupGet(x => x.UserId).Returns("user-1");
|
||||||
|
return new JobTrackerContext(options, currentUser.Object);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
using JobTrackerApi.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.IdentityModel.Protocols;
|
||||||
|
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
public sealed class GoogleTokenValidatorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAsync_accepts_subject_mapped_to_nameidentifier_claim()
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Auth:GoogleClientId"] = "client-123",
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("super-secret-signing-key-super-secret"));
|
||||||
|
var oidc = new OpenIdConnectConfiguration();
|
||||||
|
oidc.SigningKeys.Add(signingKey);
|
||||||
|
|
||||||
|
var configManager = new Mock<IConfigurationManager<OpenIdConnectConfiguration>>();
|
||||||
|
configManager.Setup(x => x.GetConfigurationAsync(It.IsAny<CancellationToken>())).ReturnsAsync(oidc);
|
||||||
|
|
||||||
|
var token = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(
|
||||||
|
issuer: "https://accounts.google.com",
|
||||||
|
audience: "client-123",
|
||||||
|
claims: new[]
|
||||||
|
{
|
||||||
|
new Claim(JwtRegisteredClaimNames.Sub, "google-subject-1"),
|
||||||
|
new Claim("email", "demo@example.com"),
|
||||||
|
new Claim("email_verified", "true"),
|
||||||
|
new Claim("given_name", "Demo"),
|
||||||
|
new Claim("family_name", "User"),
|
||||||
|
new Claim("name", "Demo User"),
|
||||||
|
},
|
||||||
|
expires: DateTime.UtcNow.AddMinutes(10),
|
||||||
|
signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256)));
|
||||||
|
|
||||||
|
var validator = new GoogleTokenValidator(config, configManager.Object);
|
||||||
|
var result = await validator.ValidateAsync(token);
|
||||||
|
|
||||||
|
Assert.Equal("google-subject-1", result.Subject);
|
||||||
|
Assert.Equal("demo@example.com", result.Email);
|
||||||
|
Assert.True(result.EmailVerified);
|
||||||
|
Assert.Equal("Demo", result.GivenName);
|
||||||
|
Assert.Equal("User", result.FamilyName);
|
||||||
|
Assert.Equal("Demo User", result.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,6 +144,96 @@ public sealed class JobApplicationsApplicationPackageTests
|
|||||||
Assert.Contains(payload.KeyPoints, item => item.Contains("recruiter language", StringComparison.OrdinalIgnoreCase));
|
Assert.Contains(payload.KeyPoints, item => item.Contains("recruiter language", StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Generate_application_package_passes_typed_structured_cv_context_to_summarizer()
|
||||||
|
{
|
||||||
|
await using var db = CreateDb();
|
||||||
|
var company = new Company
|
||||||
|
{
|
||||||
|
Name = "Acme",
|
||||||
|
OwnerUserId = "user-1"
|
||||||
|
};
|
||||||
|
db.Companies.Add(company);
|
||||||
|
db.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = "user-1",
|
||||||
|
UserName = "user@example.test",
|
||||||
|
Email = "user@example.test",
|
||||||
|
ProfileCvText = "Built APIs and shipped backend work.",
|
||||||
|
ProfileCvStructureJson = """
|
||||||
|
{
|
||||||
|
"version": "1",
|
||||||
|
"contact": {
|
||||||
|
"fullName": "Demo User",
|
||||||
|
"headline": "Backend Developer",
|
||||||
|
"email": "user@example.test",
|
||||||
|
"location": "Oslo"
|
||||||
|
},
|
||||||
|
"summary": ["Backend-focused developer with strong API delivery experience."],
|
||||||
|
"jobs": [
|
||||||
|
{
|
||||||
|
"title": "System Developer",
|
||||||
|
"company": "Acme Consulting",
|
||||||
|
"location": "Oslo",
|
||||||
|
"start": "2020",
|
||||||
|
"end": "2024",
|
||||||
|
"isCurrent": false,
|
||||||
|
"bullets": ["Owned .NET API delivery across multiple services."],
|
||||||
|
"skills": [".NET", "SQL", "APIs"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"education": [],
|
||||||
|
"skills": [".NET", "SQL", "APIs"],
|
||||||
|
"languages": [{ "name": "English", "level": "Native" }],
|
||||||
|
"interests": [],
|
||||||
|
"otherSections": []
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var job = new JobApplication
|
||||||
|
{
|
||||||
|
JobTitle = "Backend Developer",
|
||||||
|
CompanyId = company.Id,
|
||||||
|
OwnerUserId = "user-1",
|
||||||
|
Description = "Need .NET API ownership and strong SQL skills."
|
||||||
|
};
|
||||||
|
db.JobApplications.Add(job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
string? capturedContext = null;
|
||||||
|
var summarizer = new Mock<ISummarizerService>();
|
||||||
|
summarizer
|
||||||
|
.Setup(service => service.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
|
||||||
|
.ReturnsAsync((string instruction, string context, int _, int __) =>
|
||||||
|
{
|
||||||
|
if (instruction.Contains("Rewrite the candidate CV", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
capturedContext = context;
|
||||||
|
return "Tailored CV";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instruction.Contains("List up to 4 concrete application-package signals", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "Lead with .NET API ownership.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Draft";
|
||||||
|
});
|
||||||
|
|
||||||
|
var controller = CreateController(db, summarizer.Object, "user-1");
|
||||||
|
var result = await controller.GenerateApplicationPackage(job.Id, null, null, null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<OkObjectResult>(result.Result);
|
||||||
|
Assert.NotNull(capturedContext);
|
||||||
|
Assert.Contains("Structured CV:", capturedContext);
|
||||||
|
Assert.Contains("Name: Demo User", capturedContext);
|
||||||
|
Assert.Contains("Skills:\n.NET, SQL, APIs", capturedContext);
|
||||||
|
Assert.Contains("Work Experience:", capturedContext);
|
||||||
|
Assert.Contains("Owned .NET API delivery across multiple services.", capturedContext);
|
||||||
|
}
|
||||||
|
|
||||||
private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId)
|
private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId)
|
||||||
{
|
{
|
||||||
var controller = new JobApplicationsController(db, summarizer, Mock.Of<IAppEmailSender>(), CreateUserManager().Object, NullLogger<JobApplicationsController>.Instance);
|
var controller = new JobApplicationsController(db, summarizer, Mock.Of<IAppEmailSender>(), CreateUserManager().Object, NullLogger<JobApplicationsController>.Instance);
|
||||||
|
|||||||
@@ -18,14 +18,16 @@ public sealed class AdminSystemController : ControllerBase
|
|||||||
private readonly JobTrackerContext _db;
|
private readonly JobTrackerContext _db;
|
||||||
private readonly ISummarizerService _summarizer;
|
private readonly ISummarizerService _summarizer;
|
||||||
private readonly IWebHostEnvironment _env;
|
private readonly IWebHostEnvironment _env;
|
||||||
|
private readonly IEmailSettingsResolver _emailSettings;
|
||||||
|
|
||||||
public AdminSystemController(IConfiguration cfg, AppPaths paths, JobTrackerContext db, ISummarizerService summarizer, IWebHostEnvironment env)
|
public AdminSystemController(IConfiguration cfg, AppPaths paths, JobTrackerContext db, ISummarizerService summarizer, IWebHostEnvironment env, IEmailSettingsResolver emailSettings)
|
||||||
{
|
{
|
||||||
_cfg = cfg;
|
_cfg = cfg;
|
||||||
_paths = paths;
|
_paths = paths;
|
||||||
_db = db;
|
_db = db;
|
||||||
_summarizer = summarizer;
|
_summarizer = summarizer;
|
||||||
_env = env;
|
_env = env;
|
||||||
|
_emailSettings = emailSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record StorageStatusDto(string DataRoot, string DbPath, bool DbExists, long? DbSizeBytes, int CompanyCount, int JobCount, int DeletedCount);
|
public sealed record StorageStatusDto(string DataRoot, string DbPath, bool DbExists, long? DbSizeBytes, int CompanyCount, int JobCount, int DeletedCount);
|
||||||
@@ -70,6 +72,20 @@ public sealed class AdminSystemController : ControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("email-settings")]
|
||||||
|
public async Task<ActionResult<EmailSettingsAdminDto>> GetEmailSettings(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await _emailSettings.GetAdminDtoAsync(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("email-settings")]
|
||||||
|
public async Task<ActionResult<EmailSettingsAdminDto>> UpdateEmailSettings([FromBody] UpdateEmailSettingsRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request.Port <= 0) return BadRequest("SMTP port must be greater than 0.");
|
||||||
|
if (request.TimeoutMs <= 0) return BadRequest("SMTP timeout must be greater than 0.");
|
||||||
|
return Ok(await _emailSettings.UpdateAsync(request, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<SystemStatusDto>> Get(CancellationToken cancellationToken)
|
public async Task<ActionResult<SystemStatusDto>> Get(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -191,6 +207,7 @@ public sealed class AdminSystemController : ControllerBase
|
|||||||
|
|
||||||
var gmailConfigured = !string.IsNullOrWhiteSpace((_cfg["Google:GmailClientSecret"] ?? string.Empty).Trim())
|
var gmailConfigured = !string.IsNullOrWhiteSpace((_cfg["Google:GmailClientSecret"] ?? string.Empty).Trim())
|
||||||
&& !string.IsNullOrWhiteSpace((_cfg["Google:GmailRedirectUri"] ?? string.Empty).Trim());
|
&& !string.IsNullOrWhiteSpace((_cfg["Google:GmailRedirectUri"] ?? string.Empty).Trim());
|
||||||
|
var emailSettings = await _emailSettings.GetSnapshotAsync(cancellationToken);
|
||||||
|
|
||||||
return Ok(new SystemStatusDto(
|
return Ok(new SystemStatusDto(
|
||||||
Environment: _env.EnvironmentName,
|
Environment: _env.EnvironmentName,
|
||||||
@@ -208,12 +225,12 @@ public sealed class AdminSystemController : ControllerBase
|
|||||||
DeletedCount: deletedCount
|
DeletedCount: deletedCount
|
||||||
),
|
),
|
||||||
Email: new EmailStatusDto(
|
Email: new EmailStatusDto(
|
||||||
Enabled: _cfg.GetValue("Email:Enabled", false),
|
Enabled: emailSettings.Enabled,
|
||||||
Host: (_cfg["Email:SmtpHost"] ?? string.Empty).Trim(),
|
Host: emailSettings.Host,
|
||||||
Port: _cfg.GetValue("Email:SmtpPort", 587),
|
Port: emailSettings.Port,
|
||||||
EnableSsl: _cfg.GetValue("Email:SmtpEnableSsl", true),
|
EnableSsl: emailSettings.EnableSsl,
|
||||||
From: (_cfg["Email:From"] ?? string.Empty).Trim(),
|
From: emailSettings.From,
|
||||||
FromName: (_cfg["Email:FromName"] ?? string.Empty).Trim()
|
FromName: emailSettings.FromName
|
||||||
),
|
),
|
||||||
Database: new DatabaseStatusDto(
|
Database: new DatabaseStatusDto(
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
|
|||||||
@@ -31,11 +31,6 @@ namespace JobTrackerApi.Controllers
|
|||||||
[HttpPost("encrypted")]
|
[HttpPost("encrypted")]
|
||||||
public async Task<IActionResult> Encrypted(CancellationToken cancellationToken)
|
public async Task<IActionResult> Encrypted(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
return StatusCode(501, "Encrypted backups are only implemented for Windows (DPAPI) in this build.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var data = await BuildExport(cancellationToken);
|
var data = await BuildExport(cancellationToken);
|
||||||
var envelope = new BackupEnvelope("jtbackup.v1", DateTime.Now, data);
|
var envelope = new BackupEnvelope("jtbackup.v1", DateTime.Now, data);
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ builder.Logging.AddDebug();
|
|||||||
|
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||||
builder.Services.AddSingleton<IAppEmailSender, SmtpEmailSender>();
|
builder.Services.AddScoped<IEmailSettingsResolver, EmailSettingsResolver>();
|
||||||
|
builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>();
|
||||||
|
|
||||||
builder.Services.AddSingleton<AppPaths>();
|
builder.Services.AddSingleton<AppPaths>();
|
||||||
|
|
||||||
@@ -722,6 +723,15 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
|
|||||||
return cmd.ExecuteScalar() is not null;
|
return cmd.ExecuteScalar() is not null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool HasMySqlTable(MySqlConnection c, string table)
|
||||||
|
{
|
||||||
|
using var cmd = c.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;";
|
||||||
|
cmd.Parameters.AddWithValue("@schema", c.Database);
|
||||||
|
cmd.Parameters.AddWithValue("@table", table);
|
||||||
|
return cmd.ExecuteScalar() is not null;
|
||||||
|
}
|
||||||
|
|
||||||
static void EnsureMySqlColumn(MySqlConnection c, string table, string column, string ddl)
|
static void EnsureMySqlColumn(MySqlConnection c, string table, string column, string ddl)
|
||||||
{
|
{
|
||||||
using var existsCmd = c.CreateCommand();
|
using var existsCmd = c.CreateCommand();
|
||||||
@@ -782,6 +792,25 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
|
|||||||
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleEmail` longtext NULL;");
|
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleEmail` longtext NULL;");
|
||||||
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleLinkedAt` datetime NULL;");
|
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleLinkedAt` datetime NULL;");
|
||||||
|
|
||||||
|
if (!HasMySqlTable(conn, "SystemEmailSettings"))
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `SystemEmailSettings` (
|
||||||
|
`Id` int NOT NULL,
|
||||||
|
`Enabled` tinyint(1) NULL,
|
||||||
|
`SmtpHost` longtext NULL,
|
||||||
|
`SmtpPort` int NULL,
|
||||||
|
`SmtpUser` longtext NULL,
|
||||||
|
`SmtpPassword` longtext NULL,
|
||||||
|
`From` longtext NULL,
|
||||||
|
`FromName` longtext NULL,
|
||||||
|
`SmtpEnableSsl` tinyint(1) NULL,
|
||||||
|
`SmtpTimeoutMs` int NULL,
|
||||||
|
PRIMARY KEY (`Id`)
|
||||||
|
);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
||||||
{
|
{
|
||||||
using var cmd = conn.CreateCommand();
|
using var cmd = conn.CreateCommand();
|
||||||
@@ -905,3 +934,4 @@ app.UseAuthorization();
|
|||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
app.Run();
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
using JobTrackerApi.Data;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Services;
|
||||||
|
|
||||||
|
public sealed record EmailSettingsSnapshot(
|
||||||
|
bool Enabled,
|
||||||
|
string Host,
|
||||||
|
int Port,
|
||||||
|
string User,
|
||||||
|
string Password,
|
||||||
|
string From,
|
||||||
|
string FromName,
|
||||||
|
bool EnableSsl,
|
||||||
|
int TimeoutMs,
|
||||||
|
bool UsesOverrides,
|
||||||
|
bool HasPassword);
|
||||||
|
|
||||||
|
public sealed record EmailSettingsAdminDto(
|
||||||
|
bool Enabled,
|
||||||
|
string Host,
|
||||||
|
int Port,
|
||||||
|
string User,
|
||||||
|
string From,
|
||||||
|
string FromName,
|
||||||
|
bool EnableSsl,
|
||||||
|
int TimeoutMs,
|
||||||
|
bool UsesOverrides,
|
||||||
|
bool HasPassword);
|
||||||
|
|
||||||
|
public sealed record UpdateEmailSettingsRequest(
|
||||||
|
bool Enabled,
|
||||||
|
string? Host,
|
||||||
|
int Port,
|
||||||
|
string? User,
|
||||||
|
string? Password,
|
||||||
|
bool ClearPassword,
|
||||||
|
string? From,
|
||||||
|
string? FromName,
|
||||||
|
bool EnableSsl,
|
||||||
|
int TimeoutMs);
|
||||||
|
|
||||||
|
public interface IEmailSettingsResolver
|
||||||
|
{
|
||||||
|
Task<EmailSettingsSnapshot> GetSnapshotAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<EmailSettingsAdminDto> GetAdminDtoAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<EmailSettingsAdminDto> UpdateAsync(UpdateEmailSettingsRequest request, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class EmailSettingsResolver : IEmailSettingsResolver
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _cfg;
|
||||||
|
private readonly JobTrackerContext _db;
|
||||||
|
|
||||||
|
public EmailSettingsResolver(IConfiguration cfg, JobTrackerContext db)
|
||||||
|
{
|
||||||
|
_cfg = cfg;
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<EmailSettingsSnapshot> GetSnapshotAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var overrides = await _db.SystemEmailSettings.AsNoTracking().FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
|
||||||
|
return Merge(overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<EmailSettingsAdminDto> GetAdminDtoAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var snapshot = await GetSnapshotAsync(cancellationToken);
|
||||||
|
return new EmailSettingsAdminDto(
|
||||||
|
Enabled: snapshot.Enabled,
|
||||||
|
Host: snapshot.Host,
|
||||||
|
Port: snapshot.Port,
|
||||||
|
User: snapshot.User,
|
||||||
|
From: snapshot.From,
|
||||||
|
FromName: snapshot.FromName,
|
||||||
|
EnableSsl: snapshot.EnableSsl,
|
||||||
|
TimeoutMs: snapshot.TimeoutMs,
|
||||||
|
UsesOverrides: snapshot.UsesOverrides,
|
||||||
|
HasPassword: snapshot.HasPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<EmailSettingsAdminDto> UpdateAsync(UpdateEmailSettingsRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var settings = await _db.SystemEmailSettings.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
|
||||||
|
if (settings is null)
|
||||||
|
{
|
||||||
|
settings = new SystemEmailSettings { Id = 1 };
|
||||||
|
_db.SystemEmailSettings.Add(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.Enabled = request.Enabled;
|
||||||
|
settings.SmtpHost = TrimOrNull(request.Host);
|
||||||
|
settings.SmtpPort = request.Port <= 0 ? 587 : request.Port;
|
||||||
|
settings.SmtpUser = TrimOrNull(request.User);
|
||||||
|
settings.From = TrimOrNull(request.From);
|
||||||
|
settings.FromName = TrimOrNull(request.FromName);
|
||||||
|
settings.SmtpEnableSsl = request.EnableSsl;
|
||||||
|
settings.SmtpTimeoutMs = request.TimeoutMs <= 0 ? 15000 : request.TimeoutMs;
|
||||||
|
|
||||||
|
if (request.ClearPassword)
|
||||||
|
{
|
||||||
|
settings.SmtpPassword = null;
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(request.Password))
|
||||||
|
{
|
||||||
|
settings.SmtpPassword = request.Password.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return await GetAdminDtoAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private EmailSettingsSnapshot Merge(SystemEmailSettings? overrides)
|
||||||
|
{
|
||||||
|
var host = overrides?.SmtpHost ?? (_cfg["Email:SmtpHost"] ?? string.Empty).Trim();
|
||||||
|
var user = overrides?.SmtpUser ?? (_cfg["Email:SmtpUser"] ?? string.Empty).Trim();
|
||||||
|
var password = overrides?.SmtpPassword ?? (_cfg["Email:SmtpPassword"] ?? string.Empty).Trim();
|
||||||
|
var from = overrides?.From ?? (_cfg["Email:From"] ?? user).Trim();
|
||||||
|
var fromName = overrides?.FromName ?? (_cfg["Email:FromName"] ?? "Jobbjakt").Trim();
|
||||||
|
var port = overrides?.SmtpPort ?? _cfg.GetValue("Email:SmtpPort", 587);
|
||||||
|
if (port <= 0) port = 587;
|
||||||
|
var enableSsl = overrides?.SmtpEnableSsl ?? _cfg.GetValue("Email:SmtpEnableSsl", true);
|
||||||
|
var timeoutMs = overrides?.SmtpTimeoutMs ?? _cfg.GetValue("Email:SmtpTimeoutMs", 15000);
|
||||||
|
if (timeoutMs <= 0) timeoutMs = 15000;
|
||||||
|
var enabled = overrides?.Enabled ?? _cfg.GetValue("Email:Enabled", false);
|
||||||
|
var usesOverrides = overrides is not null;
|
||||||
|
|
||||||
|
return new EmailSettingsSnapshot(
|
||||||
|
Enabled: enabled,
|
||||||
|
Host: host,
|
||||||
|
Port: port,
|
||||||
|
User: user,
|
||||||
|
Password: password,
|
||||||
|
From: from,
|
||||||
|
FromName: fromName,
|
||||||
|
EnableSsl: enableSsl,
|
||||||
|
TimeoutMs: timeoutMs,
|
||||||
|
UsesOverrides: usesOverrides,
|
||||||
|
HasPassword: !string.IsNullOrWhiteSpace(password));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? TrimOrNull(string? value)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
using Microsoft.IdentityModel.Protocols;
|
using Microsoft.IdentityModel.Protocols;
|
||||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
@@ -25,6 +26,12 @@ public sealed class GoogleTokenValidator : IGoogleTokenValidator
|
|||||||
new OpenIdConnectConfigurationRetriever());
|
new OpenIdConnectConfigurationRetriever());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public GoogleTokenValidator(IConfiguration cfg, IConfigurationManager<OpenIdConnectConfiguration> configManager)
|
||||||
|
{
|
||||||
|
_cfg = cfg;
|
||||||
|
_configManager = configManager;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<GoogleTokenPrincipal> ValidateAsync(string idToken, CancellationToken cancellationToken = default)
|
public async Task<GoogleTokenPrincipal> ValidateAsync(string idToken, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var audience = (_cfg["Auth:GoogleClientId"] ?? "").Trim();
|
var audience = (_cfg["Auth:GoogleClientId"] ?? "").Trim();
|
||||||
@@ -47,7 +54,9 @@ public sealed class GoogleTokenValidator : IGoogleTokenValidator
|
|||||||
ClockSkew = TimeSpan.FromMinutes(2),
|
ClockSkew = TimeSpan.FromMinutes(2),
|
||||||
}, out _);
|
}, out _);
|
||||||
|
|
||||||
var subject = principal.FindFirst("sub")?.Value?.Trim();
|
var subject = principal.FindFirst("sub")?.Value?.Trim()
|
||||||
|
?? principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value?.Trim()
|
||||||
|
?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim();
|
||||||
if (string.IsNullOrWhiteSpace(subject))
|
if (string.IsNullOrWhiteSpace(subject))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Google token is missing a subject.");
|
throw new InvalidOperationException("Google token is missing a subject.");
|
||||||
@@ -55,11 +64,11 @@ public sealed class GoogleTokenValidator : IGoogleTokenValidator
|
|||||||
|
|
||||||
return new GoogleTokenPrincipal(
|
return new GoogleTokenPrincipal(
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
Email: principal.FindFirst("email")?.Value?.Trim(),
|
Email: principal.FindFirst("email")?.Value?.Trim() ?? principal.FindFirst(ClaimTypes.Email)?.Value?.Trim(),
|
||||||
EmailVerified: IsEmailVerified(principal),
|
EmailVerified: IsEmailVerified(principal),
|
||||||
GivenName: principal.FindFirst("given_name")?.Value?.Trim(),
|
GivenName: principal.FindFirst("given_name")?.Value?.Trim() ?? principal.FindFirst(ClaimTypes.GivenName)?.Value?.Trim(),
|
||||||
FamilyName: principal.FindFirst("family_name")?.Value?.Trim(),
|
FamilyName: principal.FindFirst("family_name")?.Value?.Trim() ?? principal.FindFirst(ClaimTypes.Surname)?.Value?.Trim(),
|
||||||
Name: principal.FindFirst("name")?.Value?.Trim()
|
Name: principal.FindFirst("name")?.Value?.Trim() ?? principal.Identity?.Name?.Trim()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,34 +10,31 @@ public interface IAppEmailSender
|
|||||||
|
|
||||||
public sealed class SmtpEmailSender : IAppEmailSender
|
public sealed class SmtpEmailSender : IAppEmailSender
|
||||||
{
|
{
|
||||||
private readonly IConfiguration _cfg;
|
private readonly IEmailSettingsResolver _settings;
|
||||||
private readonly ILogger<SmtpEmailSender> _logger;
|
private readonly ILogger<SmtpEmailSender> _logger;
|
||||||
|
|
||||||
public SmtpEmailSender(IConfiguration cfg, ILogger<SmtpEmailSender> logger)
|
public SmtpEmailSender(IEmailSettingsResolver settings, ILogger<SmtpEmailSender> logger)
|
||||||
{
|
{
|
||||||
_cfg = cfg;
|
_settings = settings;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendAsync(string toEmail, string subject, string bodyText, CancellationToken cancellationToken = default)
|
public async Task SendAsync(string toEmail, string subject, string bodyText, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var host = (_cfg["Email:SmtpHost"] ?? "").Trim();
|
var snapshot = await _settings.GetSnapshotAsync(cancellationToken);
|
||||||
var user = (_cfg["Email:SmtpUser"] ?? "").Trim();
|
var host = snapshot.Host;
|
||||||
var pass = (_cfg["Email:SmtpPassword"] ?? "").Trim();
|
var user = snapshot.User;
|
||||||
var from = (_cfg["Email:From"] ?? user).Trim();
|
var pass = snapshot.Password;
|
||||||
var fromName = (_cfg["Email:FromName"] ?? "Jobbjakt").Trim();
|
var from = snapshot.From;
|
||||||
|
var fromName = snapshot.FromName;
|
||||||
|
var port = snapshot.Port;
|
||||||
|
var enableSsl = snapshot.EnableSsl;
|
||||||
|
var timeoutMs = snapshot.TimeoutMs;
|
||||||
|
|
||||||
var port = _cfg.GetValue("Email:SmtpPort", 587);
|
var enabled = snapshot.Enabled;
|
||||||
if (port <= 0) port = 587;
|
|
||||||
|
|
||||||
var enableSsl = _cfg.GetValue("Email:SmtpEnableSsl", true);
|
|
||||||
var timeoutMs = _cfg.GetValue("Email:SmtpTimeoutMs", 15000);
|
|
||||||
if (timeoutMs <= 0) timeoutMs = 15000;
|
|
||||||
|
|
||||||
var enabled = _cfg.GetValue("Email:Enabled", false);
|
|
||||||
if (!enabled)
|
if (!enabled)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Email sending is disabled (Email:Enabled=false). Suppressed email to {To} subject={Subject}", toEmail, subject);
|
_logger.LogWarning("Email sending is disabled. Suppressed email to {To} subject={Subject}", toEmail, subject);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +60,6 @@ public sealed class SmtpEmailSender : IAppEmailSender
|
|||||||
smtp.Credentials = new NetworkCredential(user, pass);
|
smtp.Credentials = new NetworkCredential(user, pass);
|
||||||
}
|
}
|
||||||
|
|
||||||
// SmtpClient has no CancellationToken support; run on thread pool.
|
|
||||||
await Task.Run(() => smtp.Send(msg), cancellationToken);
|
await Task.Run(() => smtp.Send(msg), cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace JobTrackerApi.Models;
|
||||||
|
|
||||||
|
public sealed class SystemEmailSettings
|
||||||
|
{
|
||||||
|
public int Id { get; set; } = 1;
|
||||||
|
public bool? Enabled { get; set; }
|
||||||
|
public string? SmtpHost { get; set; }
|
||||||
|
public int? SmtpPort { get; set; }
|
||||||
|
public string? SmtpUser { get; set; }
|
||||||
|
public string? SmtpPassword { get; set; }
|
||||||
|
public string? From { get; set; }
|
||||||
|
public string? FromName { get; set; }
|
||||||
|
public bool? SmtpEnableSsl { get; set; }
|
||||||
|
public int? SmtpTimeoutMs { get; set; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user