Add full profiles and latency tests
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using JobTrackerApi.Models;
|
using JobTrackerApi.Models;
|
||||||
using JobTrackerApi.Services;
|
using JobTrackerApi.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace JobTrackerApi.Controllers;
|
namespace JobTrackerApi.Controllers;
|
||||||
|
|
||||||
@@ -15,12 +16,15 @@ public sealed class AuthController : ControllerBase
|
|||||||
private readonly UserManager<ApplicationUser> _users;
|
private readonly UserManager<ApplicationUser> _users;
|
||||||
private readonly ITokenService _tokens;
|
private readonly ITokenService _tokens;
|
||||||
private readonly IAppEmailSender _email;
|
private readonly IAppEmailSender _email;
|
||||||
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email)
|
private readonly IGoogleTokenValidator _googleTokens;
|
||||||
|
|
||||||
|
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens)
|
||||||
{
|
{
|
||||||
_cfg = cfg;
|
_cfg = cfg;
|
||||||
_users = users;
|
_users = users;
|
||||||
_tokens = tokens;
|
_tokens = tokens;
|
||||||
_email = email;
|
_email = email;
|
||||||
|
_googleTokens = googleTokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("config")]
|
[HttpGet("config")]
|
||||||
@@ -43,6 +47,19 @@ public sealed class AuthController : ControllerBase
|
|||||||
public sealed record LoginRequest(string Email, string Password);
|
public sealed record LoginRequest(string Email, string Password);
|
||||||
public sealed record RegisterRequest(string Email, string Password);
|
public sealed record RegisterRequest(string Email, string Password);
|
||||||
public sealed record AuthResult(string AccessToken, string TokenType);
|
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<string> 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")]
|
[HttpPost("login")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
@@ -91,6 +108,44 @@ public sealed class AuthController : ControllerBase
|
|||||||
return Ok(new AuthResult(token, "Bearer"));
|
return Ok(new AuthResult(token, "Bearer"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("google/exchange")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<ActionResult<AuthResult>> 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")]
|
[HttpGet("me")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> Me(CancellationToken cancellationToken)
|
public async Task<IActionResult> Me(CancellationToken cancellationToken)
|
||||||
@@ -99,48 +154,47 @@ public sealed class AuthController : ControllerBase
|
|||||||
if (u is not null)
|
if (u is not null)
|
||||||
{
|
{
|
||||||
var roles = await _users.GetRolesAsync(u);
|
var roles = await _users.GetRolesAsync(u);
|
||||||
return Ok(new
|
return Ok(ToMeResult(u, roles));
|
||||||
{
|
|
||||||
provider = "local",
|
|
||||||
id = u.Id,
|
|
||||||
email = u.Email,
|
|
||||||
userName = u.UserName,
|
|
||||||
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 email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email");
|
||||||
var sub = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
var sub = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||||
var iss = User.FindFirstValue("iss") ?? "";
|
var iss = User.FindFirstValue("iss") ?? "";
|
||||||
var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase) ? "google" : "external";
|
var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase) ? "google" : "external";
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new MeResult(
|
||||||
{
|
Provider: provider,
|
||||||
provider,
|
Id: sub,
|
||||||
id = sub,
|
Email: email,
|
||||||
email,
|
UserName: null,
|
||||||
roles = Array.Empty<string>()
|
FirstName: User.FindFirstValue("given_name"),
|
||||||
});
|
LastName: User.FindFirstValue("family_name"),
|
||||||
|
DisplayName: User.FindFirstValue("name"),
|
||||||
|
Roles: Array.Empty<string>(),
|
||||||
|
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null));
|
||||||
}
|
}
|
||||||
public sealed record UpdateProfileRequest(string? Email, string? UserName);
|
|
||||||
|
|
||||||
[HttpPut("profile")]
|
[HttpPut("profile")]
|
||||||
[Authorize]
|
[Authorize(AuthenticationSchemes = "local")]
|
||||||
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request)
|
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request)
|
||||||
{
|
{
|
||||||
var u = await _users.GetUserAsync(User);
|
var u = await _users.GetUserAsync(User);
|
||||||
if (u is null)
|
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.");
|
return StatusCode(501, "Profile updates are only supported for local username/password accounts.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var email = string.IsNullOrWhiteSpace(request.Email) ? null : request.Email.Trim();
|
var email = TrimOrNull(request.Email);
|
||||||
var userName = string.IsNullOrWhiteSpace(request.UserName) ? null : request.UserName.Trim();
|
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 (email is not null) u.Email = email;
|
||||||
if (userName is not null) u.UserName = userName;
|
if (userName is not null) u.UserName = userName;
|
||||||
|
u.FirstName = firstName;
|
||||||
|
u.LastName = lastName;
|
||||||
|
u.DisplayName = displayName;
|
||||||
|
|
||||||
var res = await _users.UpdateAsync(u);
|
var res = await _users.UpdateAsync(u);
|
||||||
if (!res.Succeeded)
|
if (!res.Succeeded)
|
||||||
@@ -149,10 +203,80 @@ public sealed class AuthController : ControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("google/link")]
|
||||||
|
[Authorize(AuthenticationSchemes = "local")]
|
||||||
|
public async Task<ActionResult<GoogleLinkDto>> 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<IActionResult> 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);
|
public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword);
|
||||||
|
|
||||||
[HttpPost("change-password")]
|
[HttpPost("change-password")]
|
||||||
[Authorize]
|
[Authorize(AuthenticationSchemes = "local")]
|
||||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||||
{
|
{
|
||||||
var u = await _users.GetUserAsync(User);
|
var u = await _users.GetUserAsync(User);
|
||||||
@@ -170,6 +294,7 @@ public sealed class AuthController : ControllerBase
|
|||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record RequestPasswordResetRequest(string Email);
|
public sealed record RequestPasswordResetRequest(string Email);
|
||||||
|
|
||||||
[HttpPost("request-password-reset")]
|
[HttpPost("request-password-reset")]
|
||||||
@@ -182,7 +307,6 @@ public sealed class AuthController : ControllerBase
|
|||||||
var user = await _users.FindByEmailAsync(email);
|
var user = await _users.FindByEmailAsync(email);
|
||||||
if (user is null || string.IsNullOrWhiteSpace(user.Email))
|
if (user is null || string.IsNullOrWhiteSpace(user.Email))
|
||||||
{
|
{
|
||||||
// Avoid user enumeration.
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,5 +353,28 @@ public sealed class AuthController : ControllerBase
|
|||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? TrimOrNull(string? value)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MeResult ToMeResult(ApplicationUser user, IList<string> 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,17 @@ public sealed class UsersController : ControllerBase
|
|||||||
_cfg = cfg;
|
_cfg = cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record UserDto(string Id, string? Email, string? UserName, bool EmailConfirmed, List<string> Roles);
|
public sealed record UserDto(
|
||||||
|
string Id,
|
||||||
|
string? Email,
|
||||||
|
string? UserName,
|
||||||
|
string? FirstName,
|
||||||
|
string? LastName,
|
||||||
|
string? DisplayName,
|
||||||
|
bool EmailConfirmed,
|
||||||
|
string? GoogleEmail,
|
||||||
|
DateTimeOffset? GoogleLinkedAt,
|
||||||
|
List<string> Roles);
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<List<UserDto>>> List(CancellationToken cancellationToken)
|
public async Task<ActionResult<List<UserDto>>> List(CancellationToken cancellationToken)
|
||||||
@@ -38,13 +48,13 @@ public sealed class UsersController : ControllerBase
|
|||||||
foreach (var u in items)
|
foreach (var u in items)
|
||||||
{
|
{
|
||||||
var rs = await _users.GetRolesAsync(u);
|
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);
|
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]
|
[HttpPost]
|
||||||
public async Task<ActionResult<UserDto>> Create([FromBody] CreateUserRequest request, CancellationToken cancellationToken)
|
public async Task<ActionResult<UserDto>> Create([FromBody] CreateUserRequest request, CancellationToken cancellationToken)
|
||||||
@@ -58,7 +68,15 @@ public sealed class UsersController : ControllerBase
|
|||||||
var existing = await _users.FindByEmailAsync(email);
|
var existing = await _users.FindByEmailAsync(email);
|
||||||
if (existing is not null) return BadRequest("User already exists.");
|
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);
|
var res = await _users.CreateAsync(u, password);
|
||||||
if (!res.Succeeded)
|
if (!res.Succeeded)
|
||||||
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
|
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);
|
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);
|
public sealed record SetRolesRequest(string[] Roles);
|
||||||
@@ -167,4 +185,24 @@ public sealed class UsersController : ControllerBase
|
|||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static UserDto ToDto(ApplicationUser user, List<string> 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ builder.Services.AddDataProtection()
|
|||||||
builder.Services.AddHostedService<RulesHostedService>();
|
builder.Services.AddHostedService<RulesHostedService>();
|
||||||
builder.Services.AddHostedService<DailyExportHostedService>();
|
builder.Services.AddHostedService<DailyExportHostedService>();
|
||||||
builder.Services.AddHostedService<JobEnrichmentHostedService>();
|
builder.Services.AddHostedService<JobEnrichmentHostedService>();
|
||||||
|
builder.Services.AddHostedService<SummarizerProbeHostedService>();
|
||||||
|
|
||||||
builder.Services.AddHttpClient("jobimport")
|
builder.Services.AddHttpClient("jobimport")
|
||||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||||
@@ -112,6 +113,7 @@ builder.Services.AddHttpClient("summarizer", client =>
|
|||||||
|
|
||||||
builder.Services.AddMemoryCache();
|
builder.Services.AddMemoryCache();
|
||||||
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
|
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
|
||||||
|
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
|
||||||
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
|
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
|
||||||
|
|
||||||
builder.Services.AddIdentityCore<ApplicationUser>(options =>
|
builder.Services.AddIdentityCore<ApplicationUser>(options =>
|
||||||
@@ -382,7 +384,13 @@ CREATE TABLE IF NOT EXISTS "AspNetUsers" (
|
|||||||
"TwoFactorEnabled" INTEGER NOT NULL,
|
"TwoFactorEnabled" INTEGER NOT NULL,
|
||||||
"LockoutEnd" TEXT NULL,
|
"LockoutEnd" TEXT NULL,
|
||||||
"LockoutEnabled" INTEGER NOT 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);
|
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)
|
static void EnsureUserRuleSettingsTable(DbConnection c)
|
||||||
{
|
{
|
||||||
@@ -605,3 +625,9 @@ app.UseAuthorization();
|
|||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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<GoogleTokenPrincipal> ValidateAsync(string idToken, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class GoogleTokenValidator : IGoogleTokenValidator
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _cfg;
|
||||||
|
private readonly IConfigurationManager<OpenIdConnectConfiguration> _configManager;
|
||||||
|
|
||||||
|
public GoogleTokenValidator(IConfiguration cfg)
|
||||||
|
{
|
||||||
|
_cfg = cfg;
|
||||||
|
_configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
|
||||||
|
"https://accounts.google.com/.well-known/openid-configuration",
|
||||||
|
new OpenIdConnectConfigurationRetriever());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GoogleTokenPrincipal> 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ using System.Text.Json;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace JobTrackerApi.Services
|
namespace JobTrackerApi.Services
|
||||||
{
|
{
|
||||||
@@ -13,6 +16,11 @@ namespace JobTrackerApi.Services
|
|||||||
bool Healthy,
|
bool Healthy,
|
||||||
string? Model,
|
string? Model,
|
||||||
double? HealthLatencyMs,
|
double? HealthLatencyMs,
|
||||||
|
double? ProbeLatencyMs,
|
||||||
|
DateTimeOffset? LastProbeAt,
|
||||||
|
DateTimeOffset? LastProbeSuccessAt,
|
||||||
|
DateTimeOffset? LastProbeFailureAt,
|
||||||
|
int ProbeFailures,
|
||||||
int Requests,
|
int Requests,
|
||||||
int CacheHits,
|
int CacheHits,
|
||||||
int CacheMisses,
|
int CacheMisses,
|
||||||
@@ -26,6 +34,7 @@ namespace JobTrackerApi.Services
|
|||||||
public interface ISummarizerService
|
public interface ISummarizerService
|
||||||
{
|
{
|
||||||
Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30);
|
Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30);
|
||||||
|
Task RunProbeAsync(CancellationToken cancellationToken = default);
|
||||||
Task<SummarizerMetrics> GetMetricsAsync(CancellationToken cancellationToken = default);
|
Task<SummarizerMetrics> GetMetricsAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +50,11 @@ namespace JobTrackerApi.Services
|
|||||||
private long _totalLatencyTicks;
|
private long _totalLatencyTicks;
|
||||||
private DateTimeOffset? _lastSuccessAt;
|
private DateTimeOffset? _lastSuccessAt;
|
||||||
private DateTimeOffset? _lastFailureAt;
|
private DateTimeOffset? _lastFailureAt;
|
||||||
|
private double? _lastProbeLatencyMs;
|
||||||
|
private DateTimeOffset? _lastProbeAt;
|
||||||
|
private DateTimeOffset? _lastProbeSuccessAt;
|
||||||
|
private DateTimeOffset? _lastProbeFailureAt;
|
||||||
|
private int _probeFailures;
|
||||||
private string? _lastError;
|
private string? _lastError;
|
||||||
|
|
||||||
public SummarizerService(IHttpClientFactory httpFactory, IMemoryCache cache)
|
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<SummarizerMetrics> GetMetricsAsync(CancellationToken cancellationToken = default)
|
public async Task<SummarizerMetrics> GetMetricsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var client = _httpFactory.CreateClient("summarizer");
|
var client = _httpFactory.CreateClient("summarizer");
|
||||||
@@ -154,11 +231,19 @@ namespace JobTrackerApi.Services
|
|||||||
|
|
||||||
DateTimeOffset? lastSuccessAt;
|
DateTimeOffset? lastSuccessAt;
|
||||||
DateTimeOffset? lastFailureAt;
|
DateTimeOffset? lastFailureAt;
|
||||||
|
double? probeLatencyMs;
|
||||||
|
DateTimeOffset? lastProbeAt;
|
||||||
|
DateTimeOffset? lastProbeSuccessAt;
|
||||||
|
DateTimeOffset? lastProbeFailureAt;
|
||||||
string? lastError;
|
string? lastError;
|
||||||
lock (_metricsLock)
|
lock (_metricsLock)
|
||||||
{
|
{
|
||||||
lastSuccessAt = _lastSuccessAt;
|
lastSuccessAt = _lastSuccessAt;
|
||||||
lastFailureAt = _lastFailureAt;
|
lastFailureAt = _lastFailureAt;
|
||||||
|
probeLatencyMs = _lastProbeLatencyMs;
|
||||||
|
lastProbeAt = _lastProbeAt;
|
||||||
|
lastProbeSuccessAt = _lastProbeSuccessAt;
|
||||||
|
lastProbeFailureAt = _lastProbeFailureAt;
|
||||||
lastError = _lastError;
|
lastError = _lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +260,11 @@ namespace JobTrackerApi.Services
|
|||||||
Healthy: healthy,
|
Healthy: healthy,
|
||||||
Model: model,
|
Model: model,
|
||||||
HealthLatencyMs: healthLatencyMs,
|
HealthLatencyMs: healthLatencyMs,
|
||||||
|
ProbeLatencyMs: probeLatencyMs,
|
||||||
|
LastProbeAt: lastProbeAt,
|
||||||
|
LastProbeSuccessAt: lastProbeSuccessAt,
|
||||||
|
LastProbeFailureAt: lastProbeFailureAt,
|
||||||
|
ProbeFailures: Volatile.Read(ref _probeFailures),
|
||||||
Requests: requests,
|
Requests: requests,
|
||||||
CacheHits: cacheHits,
|
CacheHits: cacheHits,
|
||||||
CacheMisses: cacheMisses,
|
CacheMisses: cacheMisses,
|
||||||
@@ -186,4 +276,55 @@ namespace JobTrackerApi.Services
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class SummarizerProbeHostedService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ILogger<SummarizerProbeHostedService> _logger;
|
||||||
|
private readonly IConfiguration _cfg;
|
||||||
|
|
||||||
|
public SummarizerProbeHostedService(IServiceScopeFactory scopeFactory, ILogger<SummarizerProbeHostedService> 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<ISummarizerService>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,10 @@ namespace JobTrackerApi.Models;
|
|||||||
|
|
||||||
public sealed class ApplicationUser : IdentityUser
|
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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
import { Box, Button, Paper, Typography } from "@mui/material";
|
||||||
|
|
||||||
@@ -11,7 +11,14 @@ type MeResponse = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
displayName?: string;
|
||||||
roles?: string[];
|
roles?: string[];
|
||||||
|
googleLink?: {
|
||||||
|
linked: boolean;
|
||||||
|
email?: string | null;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AuthStatusCard() {
|
export default function AuthStatusCard() {
|
||||||
@@ -30,6 +37,8 @@ export default function AuthStatusCard() {
|
|||||||
.catch(() => setMe(null));
|
.catch(() => setMe(null));
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
|
const label = useMemo(() => me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email, [me]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ mt: 2, p: 2 }}>
|
<Paper sx={{ mt: 2, p: 2 }}>
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||||
@@ -41,13 +50,18 @@ export default function AuthStatusCard() {
|
|||||||
) : (
|
) : (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
|
||||||
<Typography sx={{ color: "text.secondary" }}>
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
Signed in{me?.email ? ` as ${me.email}` : ""}{me?.provider ? ` (${me.provider})` : ""}.
|
Signed in{label ? ` as ${label}` : ""}{me?.provider ? ` (${me.provider})` : ""}.
|
||||||
</Typography>
|
</Typography>
|
||||||
{me?.roles && me.roles.length > 0 ? (
|
{me?.roles && me.roles.length > 0 ? (
|
||||||
<Typography sx={{ color: "text.secondary" }}>
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
Roles: {me.roles.join(", ")}
|
Roles: {me.roles.join(", ")}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
|
{me?.googleLink?.linked ? (
|
||||||
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
|
Google linked{me.googleLink.email ? `: ${me.googleLink.email}` : "."}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Box sx={{ mt: 1 }}>
|
<Box sx={{ mt: 1 }}>
|
||||||
<Button
|
<Button
|
||||||
@@ -66,4 +80,3 @@ export default function AuthStatusCard() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ type SummarizerMetrics = {
|
|||||||
healthy: boolean;
|
healthy: boolean;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
healthLatencyMs?: number | null;
|
healthLatencyMs?: number | null;
|
||||||
|
probeLatencyMs?: number | null;
|
||||||
|
lastProbeAt?: string | null;
|
||||||
|
lastProbeSuccessAt?: string | null;
|
||||||
|
lastProbeFailureAt?: string | null;
|
||||||
|
probeFailures: number;
|
||||||
requests: number;
|
requests: number;
|
||||||
cacheHits: number;
|
cacheHits: number;
|
||||||
cacheMisses: number;
|
cacheMisses: number;
|
||||||
@@ -408,7 +413,7 @@ export default function DashboardView() {
|
|||||||
{tab === 2 ? (
|
{tab === 2 ? (
|
||||||
<Paper sx={{ p: 2.25 }}>
|
<Paper sx={{ p: 2.25 }}>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}>
|
||||||
{[{ label: "Service status", value: summarizerMetrics?.healthy ? "Healthy" : "Offline", sub: summarizerMetrics?.model || "Summarizer health check" }, { label: "Health latency", value: summarizerMetrics?.healthLatencyMs != null ? `${summarizerMetrics.healthLatencyMs} ms` : "-", sub: "Latest /health round-trip" }, { label: "Average latency", value: summarizerMetrics?.averageLatencyMs != null ? `${summarizerMetrics.averageLatencyMs} ms` : "-", sub: "Across API summary requests" }, { label: "Last success", value: formatRelative(summarizerMetrics?.lastSuccessAt), sub: "Recent successful summary request" }].map((m) => <Paper key={m.label} variant="outlined" sx={{ p: 2 }}><Typography variant="overline" sx={{ color: "text.secondary" }}>{m.label}</Typography><Typography variant="h5" sx={{ fontWeight: 950 }}>{m.value}</Typography><Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{m.sub}</Typography></Paper>)}
|
{[{ label: "Service status", value: summarizerMetrics?.healthy ? "Healthy" : "Offline", sub: summarizerMetrics?.model || "Summarizer health check" }, { label: "Health latency", value: summarizerMetrics?.healthLatencyMs != null ? `${summarizerMetrics.healthLatencyMs} ms` : "-", sub: "Latest /health round-trip" }, { label: "Probe latency", value: summarizerMetrics?.probeLatencyMs != null ? `${summarizerMetrics.probeLatencyMs} ms` : "-", sub: "Periodic small summarize request" }, { label: "Last success", value: formatRelative(summarizerMetrics?.lastProbeSuccessAt || summarizerMetrics?.lastSuccessAt), sub: "Recent successful latency sample" }].map((m) => <Paper key={m.label} variant="outlined" sx={{ p: 2 }}><Typography variant="overline" sx={{ color: "text.secondary" }}>{m.label}</Typography><Typography variant="h5" sx={{ fontWeight: 950 }}>{m.value}</Typography><Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{m.sub}</Typography></Paper>)}
|
||||||
</Box>
|
</Box>
|
||||||
<Paper variant="outlined" sx={{ p: 2, mt: 2 }}>
|
<Paper variant="outlined" sx={{ p: 2, mt: 2 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Telemetry</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Telemetry</Typography>
|
||||||
@@ -416,6 +421,7 @@ export default function DashboardView() {
|
|||||||
<Typography variant="body2"><strong>Cache hits:</strong> {summarizerMetrics?.cacheHits ?? 0}</Typography>
|
<Typography variant="body2"><strong>Cache hits:</strong> {summarizerMetrics?.cacheHits ?? 0}</Typography>
|
||||||
<Typography variant="body2"><strong>Cache misses:</strong> {summarizerMetrics?.cacheMisses ?? 0}</Typography>
|
<Typography variant="body2"><strong>Cache misses:</strong> {summarizerMetrics?.cacheMisses ?? 0}</Typography>
|
||||||
<Typography variant="body2"><strong>Failures:</strong> {summarizerMetrics?.failures ?? 0}</Typography>
|
<Typography variant="body2"><strong>Failures:</strong> {summarizerMetrics?.failures ?? 0}</Typography>
|
||||||
|
<Typography variant="body2"><strong>Probe failures:</strong> {summarizerMetrics?.probeFailures ?? 0}</Typography>
|
||||||
<Typography variant="body2"><strong>Last failure:</strong> {formatRelative(summarizerMetrics?.lastFailureAt)}</Typography>
|
<Typography variant="body2"><strong>Last failure:</strong> {formatRelative(summarizerMetrics?.lastFailureAt)}</Typography>
|
||||||
<Typography variant="body2" sx={{ mt: 1, color: summarizerMetrics?.lastError ? "warning.main" : "text.secondary" }}>{summarizerMetrics?.lastError || "No recent summarizer errors recorded."}</Typography>
|
<Typography variant="body2" sx={{ mt: 1, color: summarizerMetrics?.lastError ? "warning.main" : "text.secondary" }}>{summarizerMetrics?.lastError || "No recent summarizer errors recorded."}</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -424,3 +430,6 @@ export default function DashboardView() {
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { Box, Button, Paper, Typography } from "@mui/material";
|
import { Box, Button, Paper, Typography } from "@mui/material";
|
||||||
|
|
||||||
|
import { api } from "../api";
|
||||||
import { clearAuthToken, decodeJwtPayload, getAuthToken, setAuthToken } from "../auth";
|
import { clearAuthToken, decodeJwtPayload, getAuthToken, setAuthToken } from "../auth";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
|
|
||||||
@@ -11,6 +12,19 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MeResponse = {
|
||||||
|
provider?: "local" | "google" | "external";
|
||||||
|
email?: string;
|
||||||
|
displayName?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
googleLink?: {
|
||||||
|
linked: boolean;
|
||||||
|
email?: string | null;
|
||||||
|
linkedAt?: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
function loadGoogleScript(): Promise<void> {
|
function loadGoogleScript(): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (window.google?.accounts?.id) return resolve();
|
if (window.google?.accounts?.id) return resolve();
|
||||||
@@ -33,74 +47,177 @@ function loadGoogleScript(): Promise<void> {
|
|||||||
export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void }) {
|
export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void }) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [token, setToken] = useState<string | null>(() => getAuthToken());
|
const [token, setToken] = useState<string | null>(() => getAuthToken());
|
||||||
|
const [me, setMe] = useState<MeResponse | null>(null);
|
||||||
|
const [working, setWorking] = useState(false);
|
||||||
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const clientId = (process.env.REACT_APP_GOOGLE_CLIENT_ID || "").trim();
|
const clientId = (process.env.REACT_APP_GOOGLE_CLIENT_ID || "").trim();
|
||||||
|
|
||||||
const payload = useMemo(() => (token ? decodeJwtPayload(token) : null), [token]);
|
const payload = useMemo(() => (token ? decodeJwtPayload(token) : null), [token]);
|
||||||
const email = payload?.email as string | undefined;
|
const isRawGoogleToken = payload?.iss === "accounts.google.com" || payload?.iss === "https://accounts.google.com";
|
||||||
|
|
||||||
|
async function refreshMe() {
|
||||||
|
if (!getAuthToken()) {
|
||||||
|
setMe(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.get<MeResponse>("/auth/me");
|
||||||
|
setMe(res.data);
|
||||||
|
} catch {
|
||||||
|
setMe(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!clientId) return;
|
void refreshMe();
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !isRawGoogleToken) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const exchange = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token });
|
||||||
|
if (cancelled) return;
|
||||||
|
setAuthToken(res.data.accessToken);
|
||||||
|
setToken(res.data.accessToken);
|
||||||
|
toast("Signed in with Google.", "success");
|
||||||
|
onSignedIn?.();
|
||||||
|
} catch {
|
||||||
|
if (cancelled) return;
|
||||||
|
clearAuthToken();
|
||||||
|
setToken(null);
|
||||||
|
toast("This Google account is not linked yet. Sign in locally first to bind it.", "info");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void exchange();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [token, isRawGoogleToken, onSignedIn, toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!clientId || !hostRef.current) return;
|
||||||
|
const shouldRenderButton = !token || isRawGoogleToken || (me?.provider === "local" && !me?.googleLink?.linked);
|
||||||
|
if (!shouldRenderButton) {
|
||||||
|
hostRef.current.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void loadGoogleScript()
|
void loadGoogleScript()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (!window.google?.accounts?.id) return;
|
if (!window.google?.accounts?.id || !hostRef.current) return;
|
||||||
|
hostRef.current.innerHTML = "";
|
||||||
window.google.accounts.id.initialize({
|
window.google.accounts.id.initialize({
|
||||||
client_id: clientId,
|
client_id: clientId,
|
||||||
callback: (resp: any) => {
|
callback: async (resp: any) => {
|
||||||
if (resp?.credential) {
|
const credential = resp?.credential as string | undefined;
|
||||||
setAuthToken(resp.credential);
|
if (!credential) return;
|
||||||
setToken(resp.credential);
|
setWorking(true);
|
||||||
toast("Signed in.", "success");
|
try {
|
||||||
onSignedIn?.();
|
if (me?.provider === "local") {
|
||||||
|
const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/google/link", { token: credential });
|
||||||
|
toast(res.data?.email ? `Linked Google account ${res.data.email}.` : "Google account linked.", "success");
|
||||||
|
await refreshMe();
|
||||||
|
} else {
|
||||||
|
const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token: credential });
|
||||||
|
setAuthToken(res.data.accessToken);
|
||||||
|
setToken(res.data.accessToken);
|
||||||
|
toast("Signed in with Google.", "success");
|
||||||
|
onSignedIn?.();
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.data || e?.message || "Google authentication failed.";
|
||||||
|
toast(String(msg), "error");
|
||||||
|
} finally {
|
||||||
|
setWorking(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
window.google.accounts.id.renderButton(document.getElementById("gsi-btn"), {
|
window.google.accounts.id.renderButton(hostRef.current, {
|
||||||
theme: "outline",
|
theme: "outline",
|
||||||
size: "large",
|
size: "large",
|
||||||
type: "standard",
|
type: "standard",
|
||||||
shape: "pill",
|
shape: "pill",
|
||||||
|
text: me?.provider === "local" ? "continue_with" : "signin_with",
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => toast("Google auth script failed to load.", "error"));
|
.catch(() => toast("Google auth script failed to load.", "error"));
|
||||||
}, [clientId, onSignedIn, toast]);
|
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, isRawGoogleToken, token, toast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ mt: 2, p: 2 }}>
|
<Paper sx={{ mt: 2, p: 2 }}>
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||||
Authentication (Google)
|
Google account
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{!clientId && (
|
{!clientId && (
|
||||||
<Typography sx={{ color: "text.secondary" }}>
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
Set `REACT_APP_GOOGLE_CLIENT_ID` in your UI environment to enable sign-in.
|
Set `REACT_APP_GOOGLE_CLIENT_ID` in your UI environment to enable Google sign-in and account linking.
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{clientId && (
|
{clientId && (
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||||
<div id="gsi-btn" />
|
{!token ? (
|
||||||
{token ? (
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
<>
|
Sign in with a Google account that has already been linked to your Job Tracker user.
|
||||||
<Typography sx={{ color: "text.secondary" }}>
|
</Typography>
|
||||||
Signed in{email ? ` as ${email}` : ""}.
|
) : me?.provider === "local" ? (
|
||||||
</Typography>
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
|
{me.googleLink?.linked
|
||||||
|
? `Linked to ${me.googleLink.email || "your Google account"}.`
|
||||||
|
: "Bind a Google account to this user so you can sign in with Google and still keep your normal app roles and data."}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
|
Exchange your Google sign-in for a normal Job Tracker session.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||||
|
<div ref={hostRef} />
|
||||||
|
|
||||||
|
{token ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
clearAuthToken();
|
clearAuthToken();
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
setMe(null);
|
||||||
toast("Signed out.", "info");
|
toast("Signed out.", "info");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Sign out
|
Sign out
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
) : null}
|
||||||
) : (
|
|
||||||
<Typography sx={{ color: "text.secondary" }}>
|
{me?.provider === "local" && me.googleLink?.linked ? (
|
||||||
Sign in to unlock API access.
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="warning"
|
||||||
|
disabled={working}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await api.delete("/auth/google/link");
|
||||||
|
toast("Google account unlinked.", "info");
|
||||||
|
await refreshMe();
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.data || e?.message || "Failed to unlink Google account.";
|
||||||
|
toast(String(msg), "error");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Unlink Google
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{token && me?.email ? (
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
Signed in as {me.displayName || [me.firstName, me.lastName].filter(Boolean).join(" ") || me.email}.
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ type SummarizerMetrics = {
|
|||||||
healthy: boolean;
|
healthy: boolean;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
healthLatencyMs?: number | null;
|
healthLatencyMs?: number | null;
|
||||||
|
probeLatencyMs?: number | null;
|
||||||
|
lastProbeAt?: string | null;
|
||||||
|
lastProbeSuccessAt?: string | null;
|
||||||
|
lastProbeFailureAt?: string | null;
|
||||||
|
probeFailures: number;
|
||||||
requests: number;
|
requests: number;
|
||||||
cacheHits: number;
|
cacheHits: number;
|
||||||
cacheMisses: number;
|
cacheMisses: number;
|
||||||
@@ -107,7 +112,13 @@ export default function AdminSystemPage() {
|
|||||||
<Paper sx={{ p: 2 }}>
|
<Paper sx={{ p: 2 }}>
|
||||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Summarizer</Typography>
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>Summarizer</Typography>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 950 }}>{status?.summarizer.healthy ? "Healthy" : "Offline"}</Typography>
|
<Typography variant="h5" sx={{ fontWeight: 950 }}>{status?.summarizer.healthy ? "Healthy" : "Offline"}</Typography>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{status?.summarizer.healthLatencyMs != null ? `${status.summarizer.healthLatencyMs} ms` : "No latency data"}</Typography>
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>
|
||||||
|
{status?.summarizer.probeLatencyMs != null
|
||||||
|
? `${status.summarizer.probeLatencyMs} ms probe`
|
||||||
|
: status?.summarizer.healthLatencyMs != null
|
||||||
|
? `${status.summarizer.healthLatencyMs} ms health`
|
||||||
|
: "No latency data"}
|
||||||
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -134,7 +145,7 @@ export default function AdminSystemPage() {
|
|||||||
|
|
||||||
<Paper sx={{ p: 2 }}>
|
<Paper sx={{ p: 2 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Summarizer telemetry</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Summarizer telemetry</Typography>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(5, 1fr)" }, gap: 2 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Requests</Typography>
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>Requests</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.requests ?? 0}</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.requests ?? 0}</Typography>
|
||||||
@@ -151,7 +162,12 @@ export default function AdminSystemPage() {
|
|||||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Avg latency</Typography>
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>Avg latency</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.averageLatencyMs != null ? `${status.summarizer.averageLatencyMs} ms` : "-"}</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.averageLatencyMs != null ? `${status.summarizer.averageLatencyMs} ms` : "-"}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>Probe latency</Typography>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.probeLatencyMs != null ? `${status.summarizer.probeLatencyMs} ms` : "-"}</Typography>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Typography variant="body2" sx={{ mt: 1 }}><strong>Probe failures:</strong> {status?.summarizer.probeFailures ?? 0}</Typography>
|
||||||
{status?.summarizer.lastError ? <Alert severity="warning" sx={{ mt: 2 }}>{status.summarizer.lastError}</Alert> : null}
|
{status?.summarizer.lastError ? <Alert severity="warning" sx={{ mt: 2 }}>{status.summarizer.lastError}</Alert> : null}
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
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 { api } from "../api";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
@@ -10,15 +10,26 @@ type MeResponse = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
displayName?: string;
|
||||||
roles?: string[];
|
roles?: string[];
|
||||||
|
googleLink?: {
|
||||||
|
linked: boolean;
|
||||||
|
email?: string | null;
|
||||||
|
linkedAt?: string | null;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function initialsFrom(s?: string) {
|
function initialsFrom(values: Array<string | undefined>) {
|
||||||
const v = (s ?? "").trim();
|
const joined = values.map((x) => (x ?? "").trim()).filter(Boolean);
|
||||||
if (!v) return "?";
|
if (joined.length === 0) return "?";
|
||||||
const parts = v.split(/[\s@._-]+/).filter(Boolean);
|
if (joined.length === 1) {
|
||||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
const parts = joined[0].split(/[\s@._-]+/).filter(Boolean);
|
||||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
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() {
|
export default function ProfilePage() {
|
||||||
@@ -28,62 +39,76 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [userName, setUserName] = useState("");
|
const [userName, setUserName] = useState("");
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
|
||||||
|
async function loadProfile() {
|
||||||
|
try {
|
||||||
|
const r = await api.get<MeResponse>("/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(() => {
|
useEffect(() => {
|
||||||
api
|
void loadProfile();
|
||||||
.get<MeResponse>("/auth/me")
|
|
||||||
.then((r) => {
|
|
||||||
setMe(r.data);
|
|
||||||
setEmail(r.data?.email ?? "");
|
|
||||||
setUserName(r.data?.userName ?? "");
|
|
||||||
})
|
|
||||||
.catch(() => setMe(null));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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 isLocal = me?.provider === "local";
|
||||||
|
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ mt: 0, p: 2 }}>
|
<Paper sx={{ mt: 0, p: 2 }}>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||||
<Avatar sx={{ width: 44, height: 44, fontWeight: 900 }}>{initials}</Avatar>
|
<Avatar sx={{ width: 52, height: 52, fontWeight: 900 }}>{initials}</Avatar>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 900 }}>
|
<Typography variant="h5" sx={{ fontWeight: 900 }}>
|
||||||
Profile
|
Profile
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ color: "text.secondary" }}>
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
{me?.email ? me.email : "â€â€"} {me?.provider ? `(${me.provider})` : ""}
|
{me?.displayName || fullName || me?.userName || me?.email || "-"}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
{me?.email || "-"} {me?.provider ? `(${me.provider})` : ""}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
|
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||||
<Typography variant="h6">Account</Typography>
|
<Typography variant="h6">Account</Typography>
|
||||||
{!isLocal ? (
|
{!isLocal ? (
|
||||||
<Typography sx={{ color: "text.secondary" }}>
|
<Alert severity="info" sx={{ mt: 1 }}>
|
||||||
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.
|
||||||
</Typography>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<TextField
|
<TextField label="Display name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} disabled={!isLocal} fullWidth />
|
||||||
label="Email"
|
<TextField label="Username" value={userName} onChange={(e) => setUserName(e.target.value)} disabled={!isLocal} fullWidth />
|
||||||
value={email}
|
<TextField label="First name" value={firstName} onChange={(e) => setFirstName(e.target.value)} disabled={!isLocal} fullWidth />
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
<TextField label="Last name" value={lastName} onChange={(e) => setLastName(e.target.value)} disabled={!isLocal} fullWidth />
|
||||||
disabled={!isLocal}
|
<TextField label="Email" value={email} onChange={(e) => setEmail(e.target.value)} disabled={!isLocal} fullWidth sx={{ gridColumn: "1 / -1" }} />
|
||||||
fullWidth
|
|
||||||
/>
|
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||||
<TextField
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
label="Username"
|
Google account: {me?.googleLink?.linked ? `Linked${me.googleLink.email ? ` to ${me.googleLink.email}` : ""}` : "Not linked"}
|
||||||
value={userName}
|
</Typography>
|
||||||
onChange={(e) => setUserName(e.target.value)}
|
</Box>
|
||||||
disabled={!isLocal}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
|
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
|
||||||
<Button
|
<Button
|
||||||
@@ -92,7 +117,8 @@ export default function ProfilePage() {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.put("/auth/profile", { email, userName });
|
await api.put("/auth/profile", { email, userName, firstName, lastName, displayName });
|
||||||
|
await loadProfile();
|
||||||
toast("Profile updated.", "success");
|
toast("Profile updated.", "success");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e?.response?.data || e?.message || "Failed to update profile.";
|
const msg = e?.response?.data || e?.message || "Failed to update profile.";
|
||||||
@@ -161,4 +187,3 @@ export default function ProfilePage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user