Fix account and backup admin settings flows

This commit is contained in:
2026-03-28 15:30:07 +01:00
parent 5f14490ead
commit 4103f84f85
12 changed files with 446 additions and 37 deletions
@@ -18,14 +18,16 @@ public sealed class AdminSystemController : ControllerBase
private readonly JobTrackerContext _db;
private readonly ISummarizerService _summarizer;
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;
_paths = paths;
_db = db;
_summarizer = summarizer;
_env = env;
_emailSettings = emailSettings;
}
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();
}
[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]
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())
&& !string.IsNullOrWhiteSpace((_cfg["Google:GmailRedirectUri"] ?? string.Empty).Trim());
var emailSettings = await _emailSettings.GetSnapshotAsync(cancellationToken);
return Ok(new SystemStatusDto(
Environment: _env.EnvironmentName,
@@ -208,12 +225,12 @@ public sealed class AdminSystemController : ControllerBase
DeletedCount: deletedCount
),
Email: new EmailStatusDto(
Enabled: _cfg.GetValue("Email:Enabled", false),
Host: (_cfg["Email:SmtpHost"] ?? string.Empty).Trim(),
Port: _cfg.GetValue("Email:SmtpPort", 587),
EnableSsl: _cfg.GetValue("Email:SmtpEnableSsl", true),
From: (_cfg["Email:From"] ?? string.Empty).Trim(),
FromName: (_cfg["Email:FromName"] ?? string.Empty).Trim()
Enabled: emailSettings.Enabled,
Host: emailSettings.Host,
Port: emailSettings.Port,
EnableSsl: emailSettings.EnableSsl,
From: emailSettings.From,
FromName: emailSettings.FromName
),
Database: new DatabaseStatusDto(
Provider: provider,
@@ -31,11 +31,6 @@ namespace JobTrackerApi.Controllers
[HttpPost("encrypted")]
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 envelope = new BackupEnvelope("jtbackup.v1", DateTime.Now, data);
+31 -1
View File
@@ -28,7 +28,8 @@ builder.Logging.AddDebug();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddSingleton<IAppEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<IEmailSettingsResolver, EmailSettingsResolver>();
builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>();
builder.Services.AddSingleton<AppPaths>();
@@ -722,6 +723,15 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
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)
{
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", "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"))
{
using var cmd = conn.CreateCommand();
@@ -905,3 +934,4 @@ app.UseAuthorization();
app.MapControllers();
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();
}
}
+14 -5
View File
@@ -1,4 +1,5 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
@@ -25,6 +26,12 @@ public sealed class GoogleTokenValidator : IGoogleTokenValidator
new OpenIdConnectConfigurationRetriever());
}
public GoogleTokenValidator(IConfiguration cfg, IConfigurationManager<OpenIdConnectConfiguration> configManager)
{
_cfg = cfg;
_configManager = configManager;
}
public async Task<GoogleTokenPrincipal> ValidateAsync(string idToken, CancellationToken cancellationToken = default)
{
var audience = (_cfg["Auth:GoogleClientId"] ?? "").Trim();
@@ -47,7 +54,9 @@ public sealed class GoogleTokenValidator : IGoogleTokenValidator
ClockSkew = TimeSpan.FromMinutes(2),
}, 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))
{
throw new InvalidOperationException("Google token is missing a subject.");
@@ -55,11 +64,11 @@ public sealed class GoogleTokenValidator : IGoogleTokenValidator
return new GoogleTokenPrincipal(
Subject: subject,
Email: principal.FindFirst("email")?.Value?.Trim(),
Email: principal.FindFirst("email")?.Value?.Trim() ?? principal.FindFirst(ClaimTypes.Email)?.Value?.Trim(),
EmailVerified: IsEmailVerified(principal),
GivenName: principal.FindFirst("given_name")?.Value?.Trim(),
FamilyName: principal.FindFirst("family_name")?.Value?.Trim(),
Name: principal.FindFirst("name")?.Value?.Trim()
GivenName: principal.FindFirst("given_name")?.Value?.Trim() ?? principal.FindFirst(ClaimTypes.GivenName)?.Value?.Trim(),
FamilyName: principal.FindFirst("family_name")?.Value?.Trim() ?? principal.FindFirst(ClaimTypes.Surname)?.Value?.Trim(),
Name: principal.FindFirst("name")?.Value?.Trim() ?? principal.Identity?.Name?.Trim()
);
}
+14 -18
View File
@@ -10,34 +10,31 @@ public interface IAppEmailSender
public sealed class SmtpEmailSender : IAppEmailSender
{
private readonly IConfiguration _cfg;
private readonly IEmailSettingsResolver _settings;
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;
}
public async Task SendAsync(string toEmail, string subject, string bodyText, CancellationToken cancellationToken = default)
{
var host = (_cfg["Email:SmtpHost"] ?? "").Trim();
var user = (_cfg["Email:SmtpUser"] ?? "").Trim();
var pass = (_cfg["Email:SmtpPassword"] ?? "").Trim();
var from = (_cfg["Email:From"] ?? user).Trim();
var fromName = (_cfg["Email:FromName"] ?? "Jobbjakt").Trim();
var snapshot = await _settings.GetSnapshotAsync(cancellationToken);
var host = snapshot.Host;
var user = snapshot.User;
var pass = snapshot.Password;
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);
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);
var enabled = snapshot.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;
}
@@ -63,7 +60,6 @@ public sealed class SmtpEmailSender : IAppEmailSender
smtp.Credentials = new NetworkCredential(user, pass);
}
// SmtpClient has no CancellationToken support; run on thread pool.
await Task.Run(() => smtp.Send(msg), cancellationToken);
}
}