From 0fa481cab62e08aeaefb4b74cf078780c4284e84 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 22 Mar 2026 12:06:25 +0100 Subject: [PATCH] Add full profiles and latency tests --- JobTrackerApi/Controllers/AuthController.cs | 197 +++++++++++++++--- JobTrackerApi/Controllers/UsersController.cs | 48 ++++- JobTrackerApi/Program.cs | 28 ++- .../Services/GoogleTokenValidator.cs | 64 ++++++ JobTrackerApi/Services/SummarizerService.cs | 141 +++++++++++++ Models/ApplicationUser.cs | 7 +- .../src/components/AuthStatusCard.tsx | 19 +- .../src/components/DashboardView.tsx | 11 +- .../src/components/GoogleAuthCard.tsx | 171 ++++++++++++--- job-tracker-ui/src/pages/AdminSystemPage.tsx | 20 +- job-tracker-ui/src/pages/ProfilePage.tsx | 101 +++++---- 11 files changed, 704 insertions(+), 103 deletions(-) create mode 100644 JobTrackerApi/Services/GoogleTokenValidator.cs diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index 8cdcbbb..d2828db 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -1,9 +1,10 @@ -using System.Security.Claims; +using System.Security.Claims; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Controllers; @@ -15,12 +16,15 @@ public sealed class AuthController : ControllerBase private readonly UserManager _users; private readonly ITokenService _tokens; private readonly IAppEmailSender _email; - public AuthController(IConfiguration cfg, UserManager users, ITokenService tokens, IAppEmailSender email) + private readonly IGoogleTokenValidator _googleTokens; + + public AuthController(IConfiguration cfg, UserManager users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens) { _cfg = cfg; _users = users; _tokens = tokens; _email = email; + _googleTokens = googleTokens; } [HttpGet("config")] @@ -43,6 +47,19 @@ public sealed class AuthController : ControllerBase public sealed record LoginRequest(string Email, string Password); public sealed record RegisterRequest(string Email, string Password); public sealed record AuthResult(string AccessToken, string TokenType); + public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt); + public sealed record MeResult( + string Provider, + string? Id, + string? Email, + string? UserName, + string? FirstName, + string? LastName, + string? DisplayName, + IList Roles, + GoogleLinkDto? GoogleLink); + public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName); + public sealed record GoogleTokenRequest(string Token); [HttpPost("login")] [AllowAnonymous] @@ -91,6 +108,44 @@ public sealed class AuthController : ControllerBase return Ok(new AuthResult(token, "Bearer")); } + [HttpPost("google/exchange")] + [AllowAnonymous] + public async Task> ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken) + { + var token = (request.Token ?? "").Trim(); + if (token.Length == 0) return BadRequest("Google token is required."); + + GoogleTokenPrincipal google; + try + { + google = await _googleTokens.ValidateAsync(token, cancellationToken); + } + catch (Exception ex) + { + return Unauthorized(ex.Message); + } + + var user = await _users.Users.FirstOrDefaultAsync( + x => x.GoogleSubject == google.Subject || (!string.IsNullOrWhiteSpace(google.Email) && x.GoogleEmail == google.Email), + cancellationToken); + + if (user is null) + { + return Unauthorized("This Google account is not linked to a Job Tracker user yet."); + } + + if (string.IsNullOrWhiteSpace(user.GoogleSubject) || !string.Equals(user.GoogleSubject, google.Subject, StringComparison.Ordinal)) + { + user.GoogleSubject = google.Subject; + user.GoogleEmail = google.Email; + user.GoogleLinkedAt ??= DateTimeOffset.UtcNow; + await _users.UpdateAsync(user); + } + + var appToken = await _tokens.CreateAccessTokenAsync(user, cancellationToken); + return Ok(new AuthResult(appToken, "Bearer")); + } + [HttpGet("me")] [Authorize] public async Task Me(CancellationToken cancellationToken) @@ -99,48 +154,47 @@ public sealed class AuthController : ControllerBase if (u is not null) { var roles = await _users.GetRolesAsync(u); - return Ok(new - { - provider = "local", - id = u.Id, - email = u.Email, - userName = u.UserName, - roles - }); + return Ok(ToMeResult(u, roles)); } - // Google direct bearer tokens (or any other external JWT) won’t map to an Identity user. var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email"); var sub = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); var iss = User.FindFirstValue("iss") ?? ""; var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase) ? "google" : "external"; - return Ok(new - { - provider, - id = sub, - email, - roles = Array.Empty() - }); + return Ok(new MeResult( + Provider: provider, + Id: sub, + Email: email, + UserName: null, + FirstName: User.FindFirstValue("given_name"), + LastName: User.FindFirstValue("family_name"), + DisplayName: User.FindFirstValue("name"), + Roles: Array.Empty(), + GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null)); } - public sealed record UpdateProfileRequest(string? Email, string? UserName); [HttpPut("profile")] - [Authorize] + [Authorize(AuthenticationSchemes = "local")] public async Task UpdateProfile([FromBody] UpdateProfileRequest request) { var u = await _users.GetUserAsync(User); if (u is null) { - // Google bearer tokens are accepted, but they don't map to Identity users in this build. return StatusCode(501, "Profile updates are only supported for local username/password accounts."); } - var email = string.IsNullOrWhiteSpace(request.Email) ? null : request.Email.Trim(); - var userName = string.IsNullOrWhiteSpace(request.UserName) ? null : request.UserName.Trim(); + var email = TrimOrNull(request.Email); + var userName = TrimOrNull(request.UserName); + var firstName = TrimOrNull(request.FirstName); + var lastName = TrimOrNull(request.LastName); + var displayName = TrimOrNull(request.DisplayName); if (email is not null) u.Email = email; if (userName is not null) u.UserName = userName; + u.FirstName = firstName; + u.LastName = lastName; + u.DisplayName = displayName; var res = await _users.UpdateAsync(u); if (!res.Succeeded) @@ -149,10 +203,80 @@ public sealed class AuthController : ControllerBase return NoContent(); } + [HttpPost("google/link")] + [Authorize(AuthenticationSchemes = "local")] + public async Task> LinkGoogle([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken) + { + var user = await _users.GetUserAsync(User); + if (user is null) + { + return Unauthorized(); + } + + var token = (request.Token ?? "").Trim(); + if (token.Length == 0) return BadRequest("Google token is required."); + + GoogleTokenPrincipal google; + try + { + google = await _googleTokens.ValidateAsync(token, cancellationToken); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + + var conflict = await _users.Users + .Where(x => x.Id != user.Id) + .FirstOrDefaultAsync(x => x.GoogleSubject == google.Subject || (!string.IsNullOrWhiteSpace(google.Email) && x.GoogleEmail == google.Email), cancellationToken); + if (conflict is not null) + { + return Conflict("That Google account is already linked to another Job Tracker user."); + } + + user.GoogleSubject = google.Subject; + user.GoogleEmail = google.Email; + user.GoogleLinkedAt = DateTimeOffset.UtcNow; + user.DisplayName ??= TrimOrNull(google.Name); + user.FirstName ??= TrimOrNull(google.GivenName); + user.LastName ??= TrimOrNull(google.FamilyName); + + var result = await _users.UpdateAsync(user); + if (!result.Succeeded) + { + return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description))); + } + + return Ok(new GoogleLinkDto(true, user.GoogleEmail, user.GoogleLinkedAt)); + } + + [HttpDelete("google/link")] + [Authorize(AuthenticationSchemes = "local")] + public async Task UnlinkGoogle() + { + var user = await _users.GetUserAsync(User); + if (user is null) + { + return Unauthorized(); + } + + user.GoogleSubject = null; + user.GoogleEmail = null; + user.GoogleLinkedAt = null; + + var result = await _users.UpdateAsync(user); + if (!result.Succeeded) + { + return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description))); + } + + return NoContent(); + } + public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword); [HttpPost("change-password")] - [Authorize] + [Authorize(AuthenticationSchemes = "local")] public async Task ChangePassword([FromBody] ChangePasswordRequest request) { var u = await _users.GetUserAsync(User); @@ -170,6 +294,7 @@ public sealed class AuthController : ControllerBase return NoContent(); } + public sealed record RequestPasswordResetRequest(string Email); [HttpPost("request-password-reset")] @@ -182,7 +307,6 @@ public sealed class AuthController : ControllerBase var user = await _users.FindByEmailAsync(email); if (user is null || string.IsNullOrWhiteSpace(user.Email)) { - // Avoid user enumeration. return NoContent(); } @@ -229,5 +353,28 @@ public sealed class AuthController : ControllerBase return NoContent(); } + + private static string? TrimOrNull(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static MeResult ToMeResult(ApplicationUser user, IList roles) + { + return new MeResult( + Provider: "local", + Id: user.Id, + Email: user.Email, + UserName: user.UserName, + FirstName: user.FirstName, + LastName: user.LastName, + DisplayName: user.DisplayName, + Roles: roles, + GoogleLink: new GoogleLinkDto( + Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject), + Email: user.GoogleEmail, + LinkedAt: user.GoogleLinkedAt)); + } } + diff --git a/JobTrackerApi/Controllers/UsersController.cs b/JobTrackerApi/Controllers/UsersController.cs index 45c7983..5320d13 100644 --- a/JobTrackerApi/Controllers/UsersController.cs +++ b/JobTrackerApi/Controllers/UsersController.cs @@ -25,7 +25,17 @@ public sealed class UsersController : ControllerBase _cfg = cfg; } - public sealed record UserDto(string Id, string? Email, string? UserName, bool EmailConfirmed, List Roles); + public sealed record UserDto( + string Id, + string? Email, + string? UserName, + string? FirstName, + string? LastName, + string? DisplayName, + bool EmailConfirmed, + string? GoogleEmail, + DateTimeOffset? GoogleLinkedAt, + List Roles); [HttpGet] public async Task>> List(CancellationToken cancellationToken) @@ -38,13 +48,13 @@ public sealed class UsersController : ControllerBase foreach (var u in items) { var rs = await _users.GetRolesAsync(u); - outList.Add(new UserDto(u.Id, u.Email, u.UserName, u.EmailConfirmed, rs.ToList())); + outList.Add(ToDto(u, rs.ToList())); } return Ok(outList); } - public sealed record CreateUserRequest(string Email, string Password, string[]? Roles); + public sealed record CreateUserRequest(string Email, string Password, string? UserName, string? FirstName, string? LastName, string? DisplayName, string[]? Roles); [HttpPost] public async Task> Create([FromBody] CreateUserRequest request, CancellationToken cancellationToken) @@ -58,7 +68,15 @@ public sealed class UsersController : ControllerBase var existing = await _users.FindByEmailAsync(email); if (existing is not null) return BadRequest("User already exists."); - var u = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = true }; + var u = new ApplicationUser + { + UserName = string.IsNullOrWhiteSpace(request.UserName) ? email : request.UserName.Trim(), + Email = email, + EmailConfirmed = true, + FirstName = TrimOrNull(request.FirstName), + LastName = TrimOrNull(request.LastName), + DisplayName = TrimOrNull(request.DisplayName) + }; var res = await _users.CreateAsync(u, password); if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description))); @@ -72,7 +90,7 @@ public sealed class UsersController : ControllerBase } var rs = await _users.GetRolesAsync(u); - return Ok(new UserDto(u.Id, u.Email, u.UserName, u.EmailConfirmed, rs.ToList())); + return Ok(ToDto(u, rs.ToList())); } public sealed record SetRolesRequest(string[] Roles); @@ -167,4 +185,24 @@ public sealed class UsersController : ControllerBase return NoContent(); } + + private static UserDto ToDto(ApplicationUser user, List roles) + { + return new UserDto( + user.Id, + user.Email, + user.UserName, + user.FirstName, + user.LastName, + user.DisplayName, + user.EmailConfirmed, + user.GoogleEmail, + user.GoogleLinkedAt, + roles); + } + + private static string? TrimOrNull(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } } diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 90134e4..6e324d3 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -95,6 +95,7 @@ builder.Services.AddDataProtection() builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddHttpClient("jobimport") .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler @@ -112,6 +113,7 @@ builder.Services.AddHttpClient("summarizer", client => builder.Services.AddMemoryCache(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddIdentityCore(options => @@ -382,7 +384,13 @@ CREATE TABLE IF NOT EXISTS "AspNetUsers" ( "TwoFactorEnabled" INTEGER NOT NULL, "LockoutEnd" TEXT NULL, "LockoutEnabled" INTEGER NOT NULL, - "AccessFailedCount" INTEGER NOT NULL + "AccessFailedCount" INTEGER NOT NULL, + "FirstName" TEXT NULL, + "LastName" TEXT NULL, + "DisplayName" TEXT NULL, + "GoogleSubject" TEXT NULL, + "GoogleEmail" TEXT NULL, + "GoogleLinkedAt" TEXT NULL ); """); @@ -448,6 +456,18 @@ CREATE TABLE IF NOT EXISTS "AspNetUserTokens" ( } EnsureIdentityTables(conn); + EnsureColumn(conn, "AspNetUsers", "FirstName", "ALTER TABLE AspNetUsers ADD COLUMN FirstName TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "FirstName", "ALTER TABLE AspNetUsers ADD COLUMN FirstName TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;"); static void EnsureUserRuleSettingsTable(DbConnection c) { @@ -605,3 +625,9 @@ app.UseAuthorization(); app.MapControllers(); app.Run(); + + + + + + diff --git a/JobTrackerApi/Services/GoogleTokenValidator.cs b/JobTrackerApi/Services/GoogleTokenValidator.cs new file mode 100644 index 0000000..07cfd6c --- /dev/null +++ b/JobTrackerApi/Services/GoogleTokenValidator.cs @@ -0,0 +1,64 @@ +using System.IdentityModel.Tokens.Jwt; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; + +namespace JobTrackerApi.Services; + +public sealed record GoogleTokenPrincipal(string Subject, string? Email, string? GivenName, string? FamilyName, string? Name); + +public interface IGoogleTokenValidator +{ + Task ValidateAsync(string idToken, CancellationToken cancellationToken = default); +} + +public sealed class GoogleTokenValidator : IGoogleTokenValidator +{ + private readonly IConfiguration _cfg; + private readonly IConfigurationManager _configManager; + + public GoogleTokenValidator(IConfiguration cfg) + { + _cfg = cfg; + _configManager = new ConfigurationManager( + "https://accounts.google.com/.well-known/openid-configuration", + new OpenIdConnectConfigurationRetriever()); + } + + public async Task ValidateAsync(string idToken, CancellationToken cancellationToken = default) + { + var audience = (_cfg["Auth:GoogleClientId"] ?? "").Trim(); + if (string.IsNullOrWhiteSpace(audience)) + { + throw new InvalidOperationException("Google sign-in is not configured."); + } + + var config = await _configManager.GetConfigurationAsync(cancellationToken); + var handler = new JwtSecurityTokenHandler(); + var principal = handler.ValidateToken(idToken, new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuers = new[] { "accounts.google.com", "https://accounts.google.com" }, + ValidateAudience = true, + ValidAudience = audience, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKeys = config.SigningKeys, + ClockSkew = TimeSpan.FromMinutes(2), + }, out _); + + var subject = principal.FindFirst("sub")?.Value?.Trim(); + if (string.IsNullOrWhiteSpace(subject)) + { + throw new InvalidOperationException("Google token is missing a subject."); + } + + return new GoogleTokenPrincipal( + Subject: subject, + Email: principal.FindFirst("email")?.Value?.Trim(), + GivenName: principal.FindFirst("given_name")?.Value?.Trim(), + FamilyName: principal.FindFirst("family_name")?.Value?.Trim(), + Name: principal.FindFirst("name")?.Value?.Trim() + ); + } +} diff --git a/JobTrackerApi/Services/SummarizerService.cs b/JobTrackerApi/Services/SummarizerService.cs index 6a781f3..23a1a75 100644 --- a/JobTrackerApi/Services/SummarizerService.cs +++ b/JobTrackerApi/Services/SummarizerService.cs @@ -6,6 +6,9 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace JobTrackerApi.Services { @@ -13,6 +16,11 @@ namespace JobTrackerApi.Services bool Healthy, string? Model, double? HealthLatencyMs, + double? ProbeLatencyMs, + DateTimeOffset? LastProbeAt, + DateTimeOffset? LastProbeSuccessAt, + DateTimeOffset? LastProbeFailureAt, + int ProbeFailures, int Requests, int CacheHits, int CacheMisses, @@ -26,6 +34,7 @@ namespace JobTrackerApi.Services public interface ISummarizerService { Task SummarizeAsync(string text, int maxLength = 150, int minLength = 30); + Task RunProbeAsync(CancellationToken cancellationToken = default); Task GetMetricsAsync(CancellationToken cancellationToken = default); } @@ -41,6 +50,11 @@ namespace JobTrackerApi.Services private long _totalLatencyTicks; private DateTimeOffset? _lastSuccessAt; private DateTimeOffset? _lastFailureAt; + private double? _lastProbeLatencyMs; + private DateTimeOffset? _lastProbeAt; + private DateTimeOffset? _lastProbeSuccessAt; + private DateTimeOffset? _lastProbeFailureAt; + private int _probeFailures; private string? _lastError; public SummarizerService(IHttpClientFactory httpFactory, IMemoryCache cache) @@ -111,6 +125,69 @@ namespace JobTrackerApi.Services } } + public async Task RunProbeAsync(CancellationToken cancellationToken = default) + { + const string probeText = "Summarizer latency probe for job tracker telemetry."; + var client = _httpFactory.CreateClient("summarizer"); + var payload = JsonSerializer.Serialize(new { text = probeText, max_length = 48, min_length = 12 }); + using var content = new StringContent(payload, Encoding.UTF8, "application/json"); + var sw = Stopwatch.StartNew(); + + try + { + using var res = await client.PostAsync("/summarize", content, cancellationToken); + sw.Stop(); + + lock (_metricsLock) + { + _lastProbeAt = DateTimeOffset.UtcNow; + _lastProbeLatencyMs = Math.Round(sw.Elapsed.TotalMilliseconds, 1); + } + + if (!res.IsSuccessStatusCode) + { + Interlocked.Increment(ref _probeFailures); + lock (_metricsLock) + { + _lastProbeFailureAt = DateTimeOffset.UtcNow; + _lastError = $"Probe returned {(int)res.StatusCode}."; + } + return; + } + + using var stream = await res.Content.ReadAsStreamAsync(cancellationToken); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + if (!doc.RootElement.TryGetProperty("summary", out var summaryEl) || string.IsNullOrWhiteSpace(summaryEl.GetString())) + { + Interlocked.Increment(ref _probeFailures); + lock (_metricsLock) + { + _lastProbeFailureAt = DateTimeOffset.UtcNow; + _lastError = "Probe returned an empty summary."; + } + return; + } + + lock (_metricsLock) + { + _lastProbeSuccessAt = DateTimeOffset.UtcNow; + _lastError = null; + } + } + catch (Exception ex) + { + sw.Stop(); + Interlocked.Increment(ref _probeFailures); + lock (_metricsLock) + { + _lastProbeAt = DateTimeOffset.UtcNow; + _lastProbeLatencyMs = Math.Round(sw.Elapsed.TotalMilliseconds, 1); + _lastProbeFailureAt = DateTimeOffset.UtcNow; + _lastError = ex.Message; + } + } + } + public async Task GetMetricsAsync(CancellationToken cancellationToken = default) { var client = _httpFactory.CreateClient("summarizer"); @@ -154,11 +231,19 @@ namespace JobTrackerApi.Services DateTimeOffset? lastSuccessAt; DateTimeOffset? lastFailureAt; + double? probeLatencyMs; + DateTimeOffset? lastProbeAt; + DateTimeOffset? lastProbeSuccessAt; + DateTimeOffset? lastProbeFailureAt; string? lastError; lock (_metricsLock) { lastSuccessAt = _lastSuccessAt; lastFailureAt = _lastFailureAt; + probeLatencyMs = _lastProbeLatencyMs; + lastProbeAt = _lastProbeAt; + lastProbeSuccessAt = _lastProbeSuccessAt; + lastProbeFailureAt = _lastProbeFailureAt; lastError = _lastError; } @@ -175,6 +260,11 @@ namespace JobTrackerApi.Services Healthy: healthy, Model: model, HealthLatencyMs: healthLatencyMs, + ProbeLatencyMs: probeLatencyMs, + LastProbeAt: lastProbeAt, + LastProbeSuccessAt: lastProbeSuccessAt, + LastProbeFailureAt: lastProbeFailureAt, + ProbeFailures: Volatile.Read(ref _probeFailures), Requests: requests, CacheHits: cacheHits, CacheMisses: cacheMisses, @@ -186,4 +276,55 @@ namespace JobTrackerApi.Services ); } } + + public sealed class SummarizerProbeHostedService : BackgroundService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly IConfiguration _cfg; + + public SummarizerProbeHostedService(IServiceScopeFactory scopeFactory, ILogger logger, IConfiguration cfg) + { + _scopeFactory = scopeFactory; + _logger = logger; + _cfg = cfg; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var enabled = _cfg.GetValue("Summarizer:ProbeEnabled", true); + if (!enabled) + { + return; + } + + var intervalSeconds = Math.Clamp(_cfg.GetValue("Summarizer:ProbeIntervalSeconds", 300), 30, 3600); + var initialDelaySeconds = Math.Clamp(_cfg.GetValue("Summarizer:ProbeInitialDelaySeconds", 15), 0, 600); + + if (initialDelaySeconds > 0) + { + await Task.Delay(TimeSpan.FromSeconds(initialDelaySeconds), stoppingToken); + } + + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(intervalSeconds)); + do + { + try + { + using var scope = _scopeFactory.CreateScope(); + var summarizer = scope.ServiceProvider.GetRequiredService(); + await summarizer.RunProbeAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Summarizer latency probe failed."); + } + } + while (await timer.WaitForNextTickAsync(stoppingToken)); + } + } } diff --git a/Models/ApplicationUser.cs b/Models/ApplicationUser.cs index 48aee88..2dd6309 100644 --- a/Models/ApplicationUser.cs +++ b/Models/ApplicationUser.cs @@ -4,5 +4,10 @@ namespace JobTrackerApi.Models; public sealed class ApplicationUser : IdentityUser { + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? DisplayName { get; set; } + public string? GoogleSubject { get; set; } + public string? GoogleEmail { get; set; } + public DateTimeOffset? GoogleLinkedAt { get; set; } } - diff --git a/job-tracker-ui/src/components/AuthStatusCard.tsx b/job-tracker-ui/src/components/AuthStatusCard.tsx index 26cb960..3c10255 100644 --- a/job-tracker-ui/src/components/AuthStatusCard.tsx +++ b/job-tracker-ui/src/components/AuthStatusCard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Box, Button, Paper, Typography } from "@mui/material"; @@ -11,7 +11,14 @@ type MeResponse = { id?: string; email?: string; userName?: string; + firstName?: string; + lastName?: string; + displayName?: string; roles?: string[]; + googleLink?: { + linked: boolean; + email?: string | null; + } | null; }; export default function AuthStatusCard() { @@ -30,6 +37,8 @@ export default function AuthStatusCard() { .catch(() => setMe(null)); }, [token]); + const label = useMemo(() => me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email, [me]); + return ( @@ -41,13 +50,18 @@ export default function AuthStatusCard() { ) : ( - Signed in{me?.email ? ` as ${me.email}` : ""}{me?.provider ? ` (${me.provider})` : ""}. + Signed in{label ? ` as ${label}` : ""}{me?.provider ? ` (${me.provider})` : ""}. {me?.roles && me.roles.length > 0 ? ( Roles: {me.roles.join(", ")} ) : null} + {me?.googleLink?.linked ? ( + + Google linked{me.googleLink.email ? `: ${me.googleLink.email}` : "."} + + ) : null} - - ) : ( - - Sign in to unlock API access. + ) : null} + + {me?.provider === "local" && me.googleLink?.linked ? ( + + ) : null} + + + {token && me?.email ? ( + + Signed in as {me.displayName || [me.firstName, me.lastName].filter(Boolean).join(" ") || me.email}. - )} + ) : null} )} diff --git a/job-tracker-ui/src/pages/AdminSystemPage.tsx b/job-tracker-ui/src/pages/AdminSystemPage.tsx index 4832e9f..205fc0f 100644 --- a/job-tracker-ui/src/pages/AdminSystemPage.tsx +++ b/job-tracker-ui/src/pages/AdminSystemPage.tsx @@ -8,6 +8,11 @@ type SummarizerMetrics = { healthy: boolean; model?: string | null; healthLatencyMs?: number | null; + probeLatencyMs?: number | null; + lastProbeAt?: string | null; + lastProbeSuccessAt?: string | null; + lastProbeFailureAt?: string | null; + probeFailures: number; requests: number; cacheHits: number; cacheMisses: number; @@ -107,7 +112,13 @@ export default function AdminSystemPage() { Summarizer {status?.summarizer.healthy ? "Healthy" : "Offline"} - {status?.summarizer.healthLatencyMs != null ? `${status.summarizer.healthLatencyMs} ms` : "No latency data"} + + {status?.summarizer.probeLatencyMs != null + ? `${status.summarizer.probeLatencyMs} ms probe` + : status?.summarizer.healthLatencyMs != null + ? `${status.summarizer.healthLatencyMs} ms health` + : "No latency data"} + @@ -134,7 +145,7 @@ export default function AdminSystemPage() { Summarizer telemetry - + Requests {status?.summarizer.requests ?? 0} @@ -151,7 +162,12 @@ export default function AdminSystemPage() { Avg latency {status?.summarizer.averageLatencyMs != null ? `${status.summarizer.averageLatencyMs} ms` : "-"} + + Probe latency + {status?.summarizer.probeLatencyMs != null ? `${status.summarizer.probeLatencyMs} ms` : "-"} + + Probe failures: {status?.summarizer.probeFailures ?? 0} {status?.summarizer.lastError ? {status.summarizer.lastError} : null} diff --git a/job-tracker-ui/src/pages/ProfilePage.tsx b/job-tracker-ui/src/pages/ProfilePage.tsx index 24771ca..ee4fa50 100644 --- a/job-tracker-ui/src/pages/ProfilePage.tsx +++ b/job-tracker-ui/src/pages/ProfilePage.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from "react"; -import { Avatar, Box, Button, Paper, TextField, Typography } from "@mui/material"; +import { Alert, Avatar, Box, Button, Paper, TextField, Typography } from "@mui/material"; import { api } from "../api"; import { useToast } from "../toast"; @@ -10,15 +10,26 @@ type MeResponse = { id?: string; email?: string; userName?: string; + firstName?: string; + lastName?: string; + displayName?: string; roles?: string[]; + googleLink?: { + linked: boolean; + email?: string | null; + linkedAt?: string | null; + } | null; }; -function initialsFrom(s?: string) { - const v = (s ?? "").trim(); - if (!v) return "?"; - const parts = v.split(/[\s@._-]+/).filter(Boolean); - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); - return (parts[0][0] + parts[1][0]).toUpperCase(); +function initialsFrom(values: Array) { + const joined = values.map((x) => (x ?? "").trim()).filter(Boolean); + if (joined.length === 0) return "?"; + if (joined.length === 1) { + const parts = joined[0].split(/[\s@._-]+/).filter(Boolean); + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + return (joined[0][0] + joined[1][0]).toUpperCase(); } export default function ProfilePage() { @@ -28,62 +39,76 @@ export default function ProfilePage() { const [email, setEmail] = useState(""); const [userName, setUserName] = useState(""); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [displayName, setDisplayName] = useState(""); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); + async function loadProfile() { + try { + const r = await api.get("/auth/me"); + setMe(r.data); + setEmail(r.data?.email ?? ""); + setUserName(r.data?.userName ?? ""); + setFirstName(r.data?.firstName ?? ""); + setLastName(r.data?.lastName ?? ""); + setDisplayName(r.data?.displayName ?? ""); + } catch { + setMe(null); + } + } + useEffect(() => { - api - .get("/auth/me") - .then((r) => { - setMe(r.data); - setEmail(r.data?.email ?? ""); - setUserName(r.data?.userName ?? ""); - }) - .catch(() => setMe(null)); + void loadProfile(); }, []); - const initials = useMemo(() => initialsFrom(me?.userName || me?.email), [me]); + const initials = useMemo( + () => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]), + [me], + ); const isLocal = me?.provider === "local"; + const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" "); return ( - {initials} + {initials} Profile - {me?.email ? me.email : "—"} {me?.provider ? `(${me.provider})` : ""} + {me?.displayName || fullName || me?.userName || me?.email || "-"} + + + {me?.email || "-"} {me?.provider ? `(${me.provider})` : ""} - + Account {!isLocal ? ( - - This account is authenticated via Google; profile updates are read-only in this build. - + + This session is not using a local app token, so profile edits are read-only right now. + ) : null} - setEmail(e.target.value)} - disabled={!isLocal} - fullWidth - /> - setUserName(e.target.value)} - disabled={!isLocal} - fullWidth - /> + setDisplayName(e.target.value)} disabled={!isLocal} fullWidth /> + setUserName(e.target.value)} disabled={!isLocal} fullWidth /> + setFirstName(e.target.value)} disabled={!isLocal} fullWidth /> + setLastName(e.target.value)} disabled={!isLocal} fullWidth /> + setEmail(e.target.value)} disabled={!isLocal} fullWidth sx={{ gridColumn: "1 / -1" }} /> + + + + Google account: {me?.googleLink?.linked ? `Linked${me.googleLink.email ? ` to ${me.googleLink.email}` : ""}` : "Not linked"} + +