Compare commits

..

2 Commits

Author SHA1 Message Date
cesnimda dbb15804a3 fix(frontend): relax npm peer-dep resolution for react-scripts vs TS 5.9
CI and Deploy / test (pull_request) Successful in 2m6s
CI and Deploy / deploy (pull_request) Has been skipped
CI's npm ci (strict peer resolution) rejected the TypeScript 5.9 bump
from the Next.js migration: react-scripts still declares typescript
^3.2.1||^4 as a peer. Local `npm install` didn't catch this -- it
resolves peer conflicts leniently by default; only `npm ci` enforces
them. react-scripts is kept solely as the Jest test runner now (it
doesn't type-check), so relaxing this one peer constraint is safe.
2026-07-12 01:22:18 +02:00
cesnimda acf60c2a07 build(frontend): migrate CRA to Next.js (CSR lift-and-shift)
CI and Deploy / test (pull_request) Failing after 50s
CI and Deploy / deploy (pull_request) Has been skipped
Wave 6. Swaps react-scripts' build/dev tooling for Next.js while
keeping the app's actual routing/rendering model unchanged -- the app
is almost entirely behind auth with no proven SSR/SEO need, so a real
App Router rewrite would touch ~90 files for zero user-visible benefit.

- next.config.js: output:'export' (static HTML+JS, same "single
  index.html served by nginx with try_files fallback" deploy as CRA).
- app/layout.tsx + app/page.tsx: root shell ports public/index.html's
  <head>, mounts the whole existing App tree client-only (ssr:false)
  since it reads window/localStorage during initial render and Next's
  static prerender would otherwise execute that on the server.
- Renamed src/pages/ -> src/views/ (Next's Pages Router auto-detects
  any `pages/` dir under the app root and tried to build our React
  Router page components as its own routes).
- REACT_APP_* -> NEXT_PUBLIC_* across code, .env.development,
  Dockerfile, docker-compose.yml build args.
- Replaced the CRA SVGR import (`ReactComponent` from .svg, unsupported
  under Turbopack) with a small inline JobbjaktMark component.
- TypeScript 4.9 -> 5.9 (MUI v8's type-checked build needs syntax
  4.9's parser rejects; CRA never hit this because babel doesn't
  type-check).
- Dropped CRA-only files (index.tsx, reportWebVitals, react-app-env.d.ts,
  public/index.html); kept react-scripts as the Jest test runner only
  (next/jest migration not needed -- the existing config already works).

Verified: `next build` static export succeeds, `next dev` serves the
landing page and client-side routes (login etc.) correctly, all 57
frontend tests + 172 backend tests still green.

Known caveat: deep-linking straight to a sub-route (e.g. /login) 404s
in `next dev` since there's no server route for it -- the app only
ever mounts at "/". Production is unaffected: nginx's existing
try_files fallback still serves index.html for any path.
2026-07-12 00:50:45 +02:00
51 changed files with 969 additions and 856 deletions
@@ -25,7 +25,7 @@ public sealed class AuthAndSystemControllerTests
userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user); userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance); var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), NullLogger<AuthController>.Instance);
var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(" new@example.com ", " newuser ", " Ada ", " Lovelace ", " Ada L. ", null, null)); var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(" new@example.com ", " newuser ", " Ada ", " Lovelace ", " Ada L. ", null, null));
@@ -50,7 +50,7 @@ public sealed class AuthAndSystemControllerTests
.Setup(x => x.SendAsync(user.Email!, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>())) .Setup(x => x.SendAsync(user.Email!, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("SMTP unavailable")); .ThrowsAsync(new InvalidOperationException("SMTP unavailable"));
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance) var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), NullLogger<AuthController>.Instance)
{ {
ControllerContext = new ControllerContext ControllerContext = new ControllerContext
{ {
@@ -91,7 +91,7 @@ public sealed class AuthAndSystemControllerTests
.Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>())) .Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GoogleTokenPrincipal("google-subject", "dj@cesnimda.co.uk", true, "Dan", "Jones", "Dan Jones")); .ReturnsAsync(new GoogleTokenPrincipal("google-subject", "dj@cesnimda.co.uk", true, "Dan", "Jones", "Dan Jones"));
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), googleValidator.Object, Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance) var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), googleValidator.Object, NullLogger<AuthController>.Instance)
{ {
ControllerContext = new ControllerContext ControllerContext = new ControllerContext
{ {
@@ -110,76 +110,6 @@ public sealed class AuthAndSystemControllerTests
Assert.NotNull(user.GoogleLinkedAt); Assert.NotNull(user.GoogleLinkedAt);
} }
[Fact]
public async Task Exchange_microsoft_token_creates_new_user_when_registration_allowed()
{
var userManager = CreateUserManager();
userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable<ApplicationUser>(new List<ApplicationUser>()));
userManager.Setup(x => x.FindByEmailAsync("new.hire@example.com")).ReturnsAsync((ApplicationUser?)null);
ApplicationUser? created = null;
userManager
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>()))
.Callback<ApplicationUser>(u => created = u)
.ReturnsAsync(IdentityResult.Success);
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
var tokenService = new Mock<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
var microsoftValidator = new Mock<IMicrosoftTokenValidator>();
microsoftValidator
.Setup(x => x.ValidateAsync("microsoft-token", It.IsAny<CancellationToken>()))
.ReturnsAsync(new MicrosoftTokenPrincipal("ms-subject", "new.hire@example.com", true, "New", "Hire", "New Hire"));
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:AllowRegistration"] = "true" })
.Build();
var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
Assert.True(payload.Authenticated);
Assert.Equal("microsoft", payload.Provider);
Assert.NotNull(created);
Assert.Equal("new.hire@example.com", created!.Email);
Assert.Equal("ms-subject", created.MicrosoftSubject);
}
[Fact]
public async Task Exchange_microsoft_token_rejects_unmatched_account_when_registration_disabled()
{
var userManager = CreateUserManager();
userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable<ApplicationUser>(new List<ApplicationUser>()));
userManager.Setup(x => x.FindByEmailAsync("nobody@example.com")).ReturnsAsync((ApplicationUser?)null);
var microsoftValidator = new Mock<IMicrosoftTokenValidator>();
microsoftValidator
.Setup(x => x.ValidateAsync("microsoft-token", It.IsAny<CancellationToken>()))
.ReturnsAsync(new MicrosoftTokenPrincipal("ms-subject", "nobody@example.com", true, null, null, null));
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None);
Assert.IsType<UnauthorizedObjectResult>(result.Result);
userManager.Verify(x => x.CreateAsync(It.IsAny<ApplicationUser>()), Times.Never);
}
[Fact] [Fact]
public void Me_result_includes_google_link_details_for_local_users() public void Me_result_includes_google_link_details_for_local_users()
{ {
@@ -51,7 +51,7 @@ public sealed class ClientErrorsControllerTests
var userManager = TestHostFactory.CreateUserManager(); var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user); userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), Mock.Of<ILogger<AuthController>>()) var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<ILogger<AuthController>>())
{ {
ControllerContext = new ControllerContext ControllerContext = new ControllerContext
{ {
@@ -1,77 +0,0 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using JobTrackerApi.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class MicrosoftTokenValidatorTests
{
private static (IConfiguration Config, Mock<IConfigurationManager<OpenIdConnectConfiguration>> ConfigManager, SymmetricSecurityKey Key) BuildHarness()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:MicrosoftClientId"] = "client-123" })
.Build();
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("super-secret-signing-key-super-secret"));
var oidc = new OpenIdConnectConfiguration();
oidc.SigningKeys.Add(signingKey);
var configManager = new Mock<IConfigurationManager<OpenIdConnectConfiguration>>();
configManager.Setup(x => x.GetConfigurationAsync(It.IsAny<CancellationToken>())).ReturnsAsync(oidc);
return (config, configManager, signingKey);
}
[Fact]
public async Task ValidateAsync_accepts_tenant_scoped_issuer_and_maps_oid_to_subject()
{
var (config, configManager, signingKey) = BuildHarness();
var token = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(
issuer: "https://login.microsoftonline.com/9f2c1e3a-tenant/v2.0",
audience: "client-123",
claims: new[]
{
new Claim("oid", "ms-subject-1"),
new Claim("email", "demo@example.com"),
new Claim("given_name", "Demo"),
new Claim("family_name", "User"),
new Claim("name", "Demo User"),
},
expires: DateTime.UtcNow.AddMinutes(10),
signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256)));
var validator = new MicrosoftTokenValidator(config, configManager.Object);
var result = await validator.ValidateAsync(token);
Assert.Equal("ms-subject-1", result.Subject);
Assert.Equal("demo@example.com", result.Email);
Assert.True(result.EmailVerified);
Assert.Equal("Demo", result.GivenName);
Assert.Equal("User", result.FamilyName);
}
[Fact]
public async Task ValidateAsync_rejects_non_microsoft_issuer()
{
var (config, configManager, signingKey) = BuildHarness();
var token = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(
issuer: "https://evil.example.com/v2.0",
audience: "client-123",
claims: new[] { new Claim("oid", "ms-subject-1") },
expires: DateTime.UtcNow.AddMinutes(10),
signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256)));
var validator = new MicrosoftTokenValidator(config, configManager.Object);
await Assert.ThrowsAsync<InvalidOperationException>(() => validator.ValidateAsync(token));
}
}
+5 -176
View File
@@ -19,17 +19,15 @@ public sealed class AuthController : ControllerBase
private readonly ITokenService _tokens; private readonly ITokenService _tokens;
private readonly IAppEmailSender _email; private readonly IAppEmailSender _email;
private readonly IGoogleTokenValidator _googleTokens; private readonly IGoogleTokenValidator _googleTokens;
private readonly IMicrosoftTokenValidator _microsoftTokens;
private readonly ILogger<AuthController> _logger; private readonly ILogger<AuthController> _logger;
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger) public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, ILogger<AuthController> logger)
{ {
_cfg = cfg; _cfg = cfg;
_users = users; _users = users;
_tokens = tokens; _tokens = tokens;
_email = email; _email = email;
_googleTokens = googleTokens; _googleTokens = googleTokens;
_microsoftTokens = microsoftTokens;
_logger = logger; _logger = logger;
} }
@@ -39,14 +37,12 @@ public sealed class AuthController : ControllerBase
{ {
var requireAuth = _cfg.GetValue("Auth:Require", false); var requireAuth = _cfg.GetValue("Auth:Require", false);
var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? string.Empty).Trim()); var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? string.Empty).Trim());
var microsoftEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:MicrosoftClientId"] ?? string.Empty).Trim());
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false); var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
return Ok(new return Ok(new
{ {
requireAuth, requireAuth,
googleEnabled, googleEnabled,
microsoftEnabled,
localEnabled = true, localEnabled = true,
allowRegistration, allowRegistration,
}); });
@@ -56,7 +52,6 @@ public sealed class AuthController : ControllerBase
public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true); public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true);
public sealed record AuthSessionResult(bool Authenticated, string Provider); public sealed record AuthSessionResult(bool Authenticated, string Provider);
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt); public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
public sealed record MicrosoftLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
public sealed record MeResult( public sealed record MeResult(
string Provider, string Provider,
string? Id, string? Id,
@@ -69,8 +64,7 @@ public sealed class AuthController : ControllerBase
string? ProfileCvStructureJson, string? ProfileCvStructureJson,
string? AvatarImageDataUrl, string? AvatarImageDataUrl,
IList<string> Roles, IList<string> Roles,
GoogleLinkDto? GoogleLink, GoogleLinkDto? GoogleLink);
MicrosoftLinkDto? MicrosoftLink);
private const int MaxAvatarBytes = 1_000_000; private const int MaxAvatarBytes = 1_000_000;
private static readonly HashSet<string> AllowedAvatarExtensions = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> AllowedAvatarExtensions = new(StringComparer.OrdinalIgnoreCase)
{ {
@@ -78,7 +72,6 @@ public sealed class AuthController : ControllerBase
}; };
public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText, string? ProfileCvStructureJson); public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText, string? ProfileCvStructureJson);
public sealed record GoogleTokenRequest(string Token, bool RememberMe = true); public sealed record GoogleTokenRequest(string Token, bool RememberMe = true);
public sealed record MicrosoftTokenRequest(string Token, bool RememberMe = true);
[HttpPost("login")] [HttpPost("login")]
[AllowAnonymous] [AllowAnonymous]
@@ -161,27 +154,10 @@ public sealed class AuthController : ControllerBase
} }
if (user is null) if (user is null)
{
if (!google.EmailVerified || string.IsNullOrWhiteSpace(google.Email))
{ {
return Unauthorized("This Google account is not linked to a Jobbjakt user yet."); return Unauthorized("This Google account is not linked to a Jobbjakt user yet.");
} }
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
if (!allowRegistration)
{
return Unauthorized("This Google account is not linked to a Jobbjakt user yet.");
}
user = new ApplicationUser { UserName = google.Email, Email = google.Email, EmailConfirmed = true };
var created = await _users.CreateAsync(user);
if (!created.Succeeded)
{
return BadRequest(string.Join("; ", created.Errors.Select(e => e.Description)));
}
_logger.LogInformation("Created new user via Google sign-up for {Email}", google.Email);
}
if (string.IsNullOrWhiteSpace(user.GoogleSubject) || !string.Equals(user.GoogleSubject, google.Subject, StringComparison.Ordinal)) if (string.IsNullOrWhiteSpace(user.GoogleSubject) || !string.Equals(user.GoogleSubject, google.Subject, StringComparison.Ordinal))
{ {
user.GoogleSubject = google.Subject; user.GoogleSubject = google.Subject;
@@ -197,74 +173,6 @@ public sealed class AuthController : ControllerBase
return Ok(new AuthSessionResult(true, "google")); return Ok(new AuthSessionResult(true, "google"));
} }
[HttpPost("microsoft/exchange")]
[AllowAnonymous]
[EnableRateLimiting("auth-login")]
public async Task<ActionResult<AuthSessionResult>> ExchangeMicrosoftToken([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken)
{
var token = (request.Token ?? string.Empty).Trim();
if (token.Length == 0) return BadRequest("Microsoft token is required.");
MicrosoftTokenPrincipal microsoft;
try
{
microsoft = await _microsoftTokens.ValidateAsync(token, cancellationToken);
}
catch (Exception ex)
{
return Unauthorized(ex.Message);
}
var user = await _users.Users.FirstOrDefaultAsync(
x => x.MicrosoftSubject == microsoft.Subject || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email),
cancellationToken);
if (user is null && microsoft.EmailVerified && !string.IsNullOrWhiteSpace(microsoft.Email))
{
user = await _users.FindByEmailAsync(microsoft.Email);
if (user is not null)
{
_logger.LogInformation("Auto-linking Microsoft sign-in for existing local account {Email}", microsoft.Email);
}
}
if (user is null)
{
if (!microsoft.EmailVerified || string.IsNullOrWhiteSpace(microsoft.Email))
{
return Unauthorized("This Microsoft account is not linked to a Jobbjakt user yet.");
}
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
if (!allowRegistration)
{
return Unauthorized("This Microsoft account is not linked to a Jobbjakt user yet.");
}
user = new ApplicationUser { UserName = microsoft.Email, Email = microsoft.Email, EmailConfirmed = true };
var created = await _users.CreateAsync(user);
if (!created.Succeeded)
{
return BadRequest(string.Join("; ", created.Errors.Select(e => e.Description)));
}
_logger.LogInformation("Created new user via Microsoft sign-up for {Email}", microsoft.Email);
}
if (string.IsNullOrWhiteSpace(user.MicrosoftSubject) || !string.Equals(user.MicrosoftSubject, microsoft.Subject, StringComparison.Ordinal))
{
user.MicrosoftSubject = microsoft.Subject;
user.MicrosoftEmail = microsoft.Email;
user.MicrosoftLinkedAt ??= DateTimeOffset.UtcNow;
user.DisplayName ??= TrimOrNull(microsoft.Name);
user.FirstName ??= TrimOrNull(microsoft.GivenName);
user.LastName ??= TrimOrNull(microsoft.FamilyName);
await _users.UpdateAsync(user);
}
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
return Ok(new AuthSessionResult(true, "microsoft"));
}
[HttpPost("logout")] [HttpPost("logout")]
public IActionResult Logout() public IActionResult Logout()
{ {
@@ -294,11 +202,7 @@ public sealed class AuthController : ControllerBase
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") ?? string.Empty; var iss = User.FindFirstValue("iss") ?? string.Empty;
var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase) var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase) ? "google" : "external";
? "google"
: iss.Contains("login.microsoftonline.com", StringComparison.OrdinalIgnoreCase)
? "microsoft"
: "external";
return Ok(new MeResult( return Ok(new MeResult(
Provider: provider, Provider: provider,
@@ -312,8 +216,7 @@ public sealed class AuthController : ControllerBase
ProfileCvStructureJson: null, ProfileCvStructureJson: null,
AvatarImageDataUrl: null, AvatarImageDataUrl: null,
Roles: Array.Empty<string>(), Roles: Array.Empty<string>(),
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null, GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null));
MicrosoftLink: provider == "microsoft" ? new MicrosoftLinkDto(false, email, null) : null));
} }
[HttpPut("profile")] [HttpPut("profile")]
@@ -419,76 +322,6 @@ public sealed class AuthController : ControllerBase
return NoContent(); return NoContent();
} }
[HttpPost("microsoft/link")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<MicrosoftLinkDto>> LinkMicrosoft([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
var token = (request.Token ?? string.Empty).Trim();
if (token.Length == 0) return BadRequest("Microsoft token is required.");
MicrosoftTokenPrincipal microsoft;
try
{
microsoft = await _microsoftTokens.ValidateAsync(token, cancellationToken);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
var conflict = await _users.Users
.Where(x => x.Id != user.Id)
.FirstOrDefaultAsync(x => x.MicrosoftSubject == microsoft.Subject || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email), cancellationToken);
if (conflict is not null)
{
return Conflict("That Microsoft account is already linked to another Jobbjakt user.");
}
user.MicrosoftSubject = microsoft.Subject;
user.MicrosoftEmail = microsoft.Email;
user.MicrosoftLinkedAt = DateTimeOffset.UtcNow;
user.DisplayName ??= TrimOrNull(microsoft.Name);
user.FirstName ??= TrimOrNull(microsoft.GivenName);
user.LastName ??= TrimOrNull(microsoft.FamilyName);
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return Ok(new MicrosoftLinkDto(true, user.MicrosoftEmail, user.MicrosoftLinkedAt));
}
[HttpDelete("microsoft/link")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> UnlinkMicrosoft()
{
var user = await _users.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
user.MicrosoftSubject = null;
user.MicrosoftEmail = null;
user.MicrosoftLinkedAt = null;
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return NoContent();
}
[HttpPost("avatar")] [HttpPost("avatar")]
[Authorize(AuthenticationSchemes = "local")] [Authorize(AuthenticationSchemes = "local")]
[RequestSizeLimit(MaxAvatarBytes)] [RequestSizeLimit(MaxAvatarBytes)]
@@ -738,10 +571,6 @@ public sealed class AuthController : ControllerBase
GoogleLink: new GoogleLinkDto( GoogleLink: new GoogleLinkDto(
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject), Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
Email: user.GoogleEmail, Email: user.GoogleEmail,
LinkedAt: user.GoogleLinkedAt), LinkedAt: user.GoogleLinkedAt));
MicrosoftLink: new MicrosoftLinkDto(
Linked: !string.IsNullOrWhiteSpace(user.MicrosoftSubject),
Email: user.MicrosoftEmail,
LinkedAt: user.MicrosoftLinkedAt));
} }
} }
+4 -25
View File
@@ -162,7 +162,6 @@ builder.Services.AddSingleton<IJobCvMatchService, JobCvMatchService>();
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>(); builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>(); builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>(); builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
builder.Services.AddSingleton<IMicrosoftTokenValidator, MicrosoftTokenValidator>();
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>(); builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>(); builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>(); builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
@@ -210,7 +209,6 @@ builder.Services.AddScoped<JobImportService>();
var requireAuth = builder.Configuration.GetValue("Auth:Require", false); var requireAuth = builder.Configuration.GetValue("Auth:Require", false);
var googleClientId = (builder.Configuration["Auth:GoogleClientId"] ?? "").Trim(); var googleClientId = (builder.Configuration["Auth:GoogleClientId"] ?? "").Trim();
var microsoftClientId = (builder.Configuration["Auth:MicrosoftClientId"] ?? "").Trim();
var jwtKey = (builder.Configuration["Auth:JwtKey"] ?? "").Trim(); var jwtKey = (builder.Configuration["Auth:JwtKey"] ?? "").Trim();
var ephemeralJwtKey = false; var ephemeralJwtKey = false;
@@ -236,7 +234,7 @@ builder.Services.AddAuthentication(options =>
{ {
options.ForwardDefaultSelector = ctx => options.ForwardDefaultSelector = ctx =>
{ {
if (string.IsNullOrWhiteSpace(googleClientId) && string.IsNullOrWhiteSpace(microsoftClientId)) if (string.IsNullOrWhiteSpace(googleClientId))
return "local"; return "local";
var auth = ctx.Request.Headers.Authorization.ToString(); var auth = ctx.Request.Headers.Authorization.ToString();
@@ -252,11 +250,9 @@ builder.Services.AddAuthentication(options =>
{ {
var jwt = handler.ReadJwtToken(token); var jwt = handler.ReadJwtToken(token);
var iss = jwt.Issuer ?? ""; var iss = jwt.Issuer ?? "";
if (!string.IsNullOrWhiteSpace(googleClientId) && iss is "accounts.google.com" or "https://accounts.google.com") return iss is "accounts.google.com" or "https://accounts.google.com"
return "google"; ? "google"
if (!string.IsNullOrWhiteSpace(microsoftClientId) && iss.StartsWith("https://login.microsoftonline.com/", StringComparison.OrdinalIgnoreCase)) : "local";
return "microsoft";
return "local";
} }
catch catch
{ {
@@ -326,23 +322,6 @@ if (!string.IsNullOrWhiteSpace(googleClientId))
}); });
} }
if (!string.IsNullOrWhiteSpace(microsoftClientId))
{
builder.Services.AddAuthentication().AddJwtBearer("microsoft", options =>
{
// Validate Microsoft (Entra ID / personal account) ID tokens as bearer tokens.
// "common" authority + ValidateIssuer=false: multi-tenant issuer varies per tenant id.
options.Authority = "https://login.microsoftonline.com/common/v2.0";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = true,
ValidAudience = microsoftClientId,
ValidateLifetime = true,
};
});
}
builder.Services.AddAuthorization(options => builder.Services.AddAuthorization(options =>
{ {
if (requireAuth) if (requireAuth)
@@ -1,100 +0,0 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
namespace JobTrackerApi.Services;
public sealed record MicrosoftTokenPrincipal(string Subject, string? Email, bool EmailVerified, string? GivenName, string? FamilyName, string? Name);
public interface IMicrosoftTokenValidator
{
Task<MicrosoftTokenPrincipal> ValidateAsync(string idToken, CancellationToken cancellationToken = default);
}
public sealed class MicrosoftTokenValidator : IMicrosoftTokenValidator
{
private readonly IConfiguration _cfg;
private readonly IConfigurationManager<OpenIdConnectConfiguration> _configManager;
public MicrosoftTokenValidator(IConfiguration cfg)
{
_cfg = cfg;
// "common" endpoint: accepts both personal Microsoft accounts and work/school (Entra ID) tenants.
_configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
"https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration",
new OpenIdConnectConfigurationRetriever());
}
public MicrosoftTokenValidator(IConfiguration cfg, IConfigurationManager<OpenIdConnectConfiguration> configManager)
{
_cfg = cfg;
_configManager = configManager;
}
public async Task<MicrosoftTokenPrincipal> ValidateAsync(string idToken, CancellationToken cancellationToken = default)
{
var audience = (_cfg["Auth:MicrosoftClientId"] ?? "").Trim();
if (string.IsNullOrWhiteSpace(audience))
{
throw new InvalidOperationException("Microsoft sign-in is not configured.");
}
var config = await _configManager.GetConfigurationAsync(cancellationToken);
var handler = new JwtSecurityTokenHandler
{
// The handler's default inbound claim map rewrites "oid"/"tid" to long Microsoft
// schema URIs (an AAD-specific quirk not shared by Google's OIDC claims) -- keep
// claim names as issued so FindFirst("oid") below actually matches.
MapInboundClaims = false,
};
// ponytail: multi-tenant "common" app -- each tenant's issuer embeds its own tenant id
// (https://login.microsoftonline.com/{tenantId}/v2.0), so issuer is checked by shape below
// rather than pinned to one value. Signature/audience/lifetime are still fully validated.
var principal = handler.ValidateToken(idToken, new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = true,
ValidAudience = audience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKeys = config.SigningKeys,
ClockSkew = TimeSpan.FromMinutes(2),
}, out var validatedToken);
var issuer = (validatedToken as JwtSecurityToken)?.Issuer ?? principal.FindFirst("iss")?.Value ?? "";
if (!IsMicrosoftIssuer(issuer))
{
throw new InvalidOperationException("Microsoft token has an unexpected issuer.");
}
var subject = principal.FindFirst("oid")?.Value?.Trim()
?? principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value?.Trim()
?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim();
if (string.IsNullOrWhiteSpace(subject))
{
throw new InvalidOperationException("Microsoft token is missing a subject.");
}
var email = principal.FindFirst("email")?.Value?.Trim()
?? principal.FindFirst(ClaimTypes.Email)?.Value?.Trim()
?? principal.FindFirst("preferred_username")?.Value?.Trim();
return new MicrosoftTokenPrincipal(
Subject: subject,
Email: email,
// Microsoft ID tokens don't carry an email_verified claim; presence of an email claim
// from a signature-validated token is treated as verified, same trust level Microsoft's
// own APIs give it.
EmailVerified: !string.IsNullOrWhiteSpace(email),
GivenName: principal.FindFirst("given_name")?.Value?.Trim(),
FamilyName: principal.FindFirst("family_name")?.Value?.Trim(),
Name: principal.FindFirst("name")?.Value?.Trim() ?? principal.Identity?.Name?.Trim()
);
}
private static bool IsMicrosoftIssuer(string issuer)
=> issuer.StartsWith("https://login.microsoftonline.com/", StringComparison.OrdinalIgnoreCase)
&& issuer.EndsWith("/v2.0", StringComparison.OrdinalIgnoreCase);
}
@@ -241,9 +241,6 @@ public static class StartupInitializationExtensions
`GoogleSubject` longtext NULL, `GoogleSubject` longtext NULL,
`GoogleEmail` longtext NULL, `GoogleEmail` longtext NULL,
`GoogleLinkedAt` datetime(6) NULL, `GoogleLinkedAt` datetime(6) NULL,
`MicrosoftSubject` longtext NULL,
`MicrosoftEmail` longtext NULL,
`MicrosoftLinkedAt` datetime(6) NULL,
PRIMARY KEY (`Id`) PRIMARY KEY (`Id`)
) CHARACTER SET=utf8mb4; ) CHARACTER SET=utf8mb4;
@@ -356,10 +353,7 @@ public static class StartupInitializationExtensions
"AvatarImageDataUrl" TEXT NULL, "AvatarImageDataUrl" TEXT NULL,
"GoogleSubject" TEXT NULL, "GoogleSubject" TEXT NULL,
"GoogleEmail" TEXT NULL, "GoogleEmail" TEXT NULL,
"GoogleLinkedAt" TEXT NULL, "GoogleLinkedAt" TEXT NULL
"MicrosoftSubject" TEXT NULL,
"MicrosoftEmail" TEXT NULL,
"MicrosoftLinkedAt" TEXT NULL
); );
"""); """);
@@ -437,9 +431,6 @@ public static class StartupInitializationExtensions
EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject 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", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "MicrosoftSubject", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftSubject TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftEmail TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "MicrosoftLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftLinkedAt TEXT NULL;");
static void EnsureUserRuleSettingsTable(DbConnection c) static void EnsureUserRuleSettingsTable(DbConnection c)
{ {
@@ -766,9 +757,6 @@ public static class StartupInitializationExtensions
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleSubject` longtext NULL;"); EnsureMySqlColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleSubject` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleEmail` longtext NULL;"); EnsureMySqlColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleEmail` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleLinkedAt` datetime NULL;"); EnsureMySqlColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleLinkedAt` datetime NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftSubject` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftEmail` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftLinkedAt` datetime NULL;");
if (!HasMySqlTable(conn, "RuleSettings")) if (!HasMySqlTable(conn, "RuleSettings"))
{ {
+1 -2
View File
@@ -26,8 +26,7 @@
"JwtExpiresMinutes": 720, "JwtExpiresMinutes": 720,
"AdminEmail": "admin@example.com", "AdminEmail": "admin@example.com",
"AdminPassword": "CHANGE_ME_STRONG_DEV_PASSWORD", "AdminPassword": "CHANGE_ME_STRONG_DEV_PASSWORD",
"GoogleClientId": "CHANGE_ME_GOOGLE_CLIENT_ID", "GoogleClientId": "CHANGE_ME_GOOGLE_CLIENT_ID"
"MicrosoftClientId": "CHANGE_ME_MICROSOFT_CLIENT_ID"
}, },
"App": { "App": {
"PublicBaseUrl": "https://jobs.cesnimda.uk" "PublicBaseUrl": "https://jobs.cesnimda.uk"
-3
View File
@@ -16,7 +16,4 @@ public sealed class ApplicationUser : IdentityUser
public string? GoogleSubject { get; set; } public string? GoogleSubject { get; set; }
public string? GoogleEmail { get; set; } public string? GoogleEmail { get; set; }
public DateTimeOffset? GoogleLinkedAt { get; set; } public DateTimeOffset? GoogleLinkedAt { get; set; }
public string? MicrosoftSubject { get; set; }
public string? MicrosoftEmail { get; set; }
public DateTimeOffset? MicrosoftLinkedAt { get; set; }
} }
+4 -4
View File
@@ -59,13 +59,13 @@ services:
frontend: frontend:
build: build:
context: ./job-tracker-ui context: ./job-tracker-ui
# fork-ts-checker (CRA's build type-checker) needs more than Docker's default # Next's build type-checker needs more than Docker's default 64MB /dev/shm; too little
# 64MB /dev/shm; too little causes a SIGSEGV during `npm run build`. # causes a SIGSEGV during `npm run build`.
shm_size: '1gb' shm_size: '1gb'
args: args:
- REACT_APP_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID} - NEXT_PUBLIC_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
# Optional override; default in production is `/api` # Optional override; default in production is `/api`
- REACT_APP_API_BASE_URL=${REACT_APP_API_BASE_URL} - NEXT_PUBLIC_API_BASE_URL=${REACT_APP_API_BASE_URL}
ports: ports:
- "3000:80" - "3000:80"
depends_on: depends_on:
+3
View File
@@ -10,6 +10,9 @@
# production # production
/build /build
/out
/.next
next-env.d.ts
# misc # misc
.DS_Store .DS_Store
+4
View File
@@ -0,0 +1,4 @@
# react-scripts (kept only as the Jest test runner, see package.json) still declares a
# typescript ^3.2.1||^4 peer constraint that's stale for our actual (Next.js-driven) TS 5.x --
# it doesn't type-check via that peer path, so the conflict is safe to relax.
legacy-peer-deps=true
+5 -5
View File
@@ -2,11 +2,11 @@ FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
ARG REACT_APP_GOOGLE_CLIENT_ID ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
ARG REACT_APP_API_BASE_URL ARG NEXT_PUBLIC_API_BASE_URL
ENV REACT_APP_GOOGLE_CLIENT_ID=$REACT_APP_GOOGLE_CLIENT_ID ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV REACT_APP_API_BASE_URL=$REACT_APP_API_BASE_URL ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
@@ -17,7 +17,7 @@ RUN npm run build
FROM nginx:1.29.8-alpine FROM nginx:1.29.8-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/build /usr/share/nginx/html COPY --from=build /app/out /usr/share/nginx/html
EXPOSE 80 EXPOSE 80
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata, Viewport } from "next";
import "../src/index.css";
export const metadata: Metadata = {
title: "Jobbjakt",
description: "Jobbjakt — track and manage job applications",
manifest: "/manifest.json",
icons: {
icon: [
{ url: "/favicon.svg", type: "image/svg+xml" },
{ url: "/favicon.ico" },
],
apple: "/logo192.png",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: "#15803d",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">{children}</div>
</body>
</html>
);
}
+12
View File
@@ -0,0 +1,12 @@
"use client";
import dynamic from "next/dynamic";
// The whole app is a client-side React Router SPA whose providers read window/localStorage
// during their initial render -- ssr:false keeps Next's static prerender from ever executing
// any of it on the server.
const ClientApp = dynamic(() => import("../src/ClientApp"), { ssr: false });
export default function Page() {
return <ClientApp />;
}
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// The whole app is client-rendered React Router behind auth (see app/page.tsx) -- static
// export keeps the same "one index.html + JS bundle, served by nginx" deploy as CRA had.
output: "export",
reactStrictMode: true,
};
module.exports = nextConfig;
+790 -27
View File
@@ -8,7 +8,6 @@
"name": "job-tracker-ui", "name": "job-tracker-ui",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@azure/msal-browser": "^5.17.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.9", "@mui/icons-material": "^7.3.9",
@@ -27,11 +26,12 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"axios": "^1.15.0", "axios": "^1.15.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"next": "^16.2.10",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-router-dom": "^6.30.3", "react-router-dom": "^6.30.3",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"typescript": "^4.9.5", "typescript": "^5.9.3",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
} }
}, },
@@ -53,27 +53,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@azure/msal-browser": {
"version": "5.17.0",
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.17.0.tgz",
"integrity": "sha512-/yTnW2TCk9Mh+2b/NOaHAN+MryUNxzRTaJD/YtrqOA9bpBWfTXn/iyReRbaLrK/btBo3stEzLyEvuWp2NZ5DuA==",
"license": "MIT",
"dependencies": {
"@azure/msal-common": "16.11.1"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-common": {
"version": "16.11.1",
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.11.1.tgz",
"integrity": "sha512-yPohvMwWLv1XnaWnIUyKUh8CvcVChCGqG/VluGwfGmaAfrZTNt5yQ+sIs462Sgw6+e2K83KGmMJ860p73ZSCrw==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -2423,6 +2402,16 @@
"postcss-selector-parser": "^6.0.10" "postcss-selector-parser": "^6.0.10"
} }
}, },
"node_modules/@emnapi/runtime": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emotion/babel-plugin": { "node_modules/@emotion/babel-plugin": {
"version": "11.13.5", "version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
@@ -2681,6 +2670,472 @@
"deprecated": "Use @eslint/object-schema instead", "deprecated": "Use @eslint/object-schema instead",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@istanbuljs/load-nyc-config": { "node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -3489,6 +3944,140 @@
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/@next/env": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz",
"integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz",
"integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz",
"integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz",
"integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz",
"integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz",
"integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz",
"integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz",
"integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz",
"integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1", "version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@@ -3979,6 +4568,15 @@
"url": "https://github.com/sponsors/gregberge" "url": "https://github.com/sponsors/gregberge"
} }
}, },
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tanstack/react-table": { "node_modules/@tanstack/react-table": {
"version": "8.21.3", "version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
@@ -6264,6 +6862,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/cliui": { "node_modules/cliui": {
"version": "7.0.4", "version": "7.0.4",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
@@ -7237,6 +7841,16 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/detect-newline": { "node_modules/detect-newline": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -12220,6 +12834,87 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/next": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz",
"integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==",
"license": "MIT",
"dependencies": {
"@next/env": "16.2.10",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.2.10",
"@next/swc-darwin-x64": "16.2.10",
"@next/swc-linux-arm64-gnu": "16.2.10",
"@next/swc-linux-arm64-musl": "16.2.10",
"@next/swc-linux-x64-gnu": "16.2.10",
"@next/swc-linux-x64-musl": "16.2.10",
"@next/swc-win32-arm64-msvc": "16.2.10",
"@next/swc-win32-x64-msvc": "16.2.10",
"sharp": "^0.34.5"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.51.1",
"babel-plugin-react-compiler": "*",
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@playwright/test": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"sass": {
"optional": true
}
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/no-case": { "node_modules/no-case": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
@@ -15541,6 +16236,51 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -16098,6 +16838,29 @@
"webpack": "^5.0.0" "webpack": "^5.0.0"
} }
}, },
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"license": "MIT",
"dependencies": {
"client-only": "0.0.1"
},
"engines": {
"node": ">= 12.0.0"
},
"peerDependencies": {
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/stylehacks": { "node_modules/stylehacks": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz",
@@ -16954,16 +17717,16 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "4.9.5", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
}, },
"engines": { "engines": {
"node": ">=4.2.0" "node": ">=14.17"
} }
}, },
"node_modules/unbox-primitive": { "node_modules/unbox-primitive": {
+6 -6
View File
@@ -3,7 +3,6 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@azure/msal-browser": "^5.17.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.9", "@mui/icons-material": "^7.3.9",
@@ -22,18 +21,19 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"axios": "^1.15.0", "axios": "^1.15.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"next": "^16.2.10",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-router-dom": "^6.30.3", "react-router-dom": "^6.30.3",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"typescript": "^4.9.5", "typescript": "^5.9.3",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "dev": "next dev",
"build": "node --max-old-space-size=4096 ./node_modules/react-scripts/bin/react-scripts.js build", "start": "next dev",
"test": "react-scripts test", "build": "node --max-old-space-size=4096 ./node_modules/next/dist/bin/next build",
"eject": "react-scripts eject" "test": "react-scripts test"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
-21
View File
@@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%PUBLIC_URL%/favicon.svg" type="image/svg+xml" />
<link rel="alternate icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<meta name="theme-color" content="#15803d" />
<meta name="description" content="Jobbjakt — track and manage job applications" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Jobbjakt</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
+12 -12
View File
@@ -27,11 +27,11 @@ import { PromptProvider } from "./prompt";
import JobTable from "./components/JobTable"; import JobTable from "./components/JobTable";
import type { JobTableColumns } from "./components/JobTable"; import type { JobTableColumns } from "./components/JobTable";
import { I18nProvider, useI18n } from "./i18n/I18nProvider"; import { I18nProvider, useI18n } from "./i18n/I18nProvider";
import LoginPage from "./pages/LoginPage"; import LoginPage from "./views/LoginPage";
import LandingPage from "./pages/LandingPage"; import LandingPage from "./views/LandingPage";
import ForgotPasswordPage from "./pages/ForgotPasswordPage"; import ForgotPasswordPage from "./views/ForgotPasswordPage";
import ResetPasswordPage from "./pages/ResetPasswordPage"; import ResetPasswordPage from "./views/ResetPasswordPage";
import RouteErrorPage from "./pages/RouteErrorPage"; import RouteErrorPage from "./views/RouteErrorPage";
import { api } from "./api"; import { api } from "./api";
import { resolveCaptureUrl } from "./captureUrl"; import { resolveCaptureUrl } from "./captureUrl";
import { clearAuthClientState, setAuthUserKey } from "./auth"; import { clearAuthClientState, setAuthUserKey } from "./auth";
@@ -45,13 +45,13 @@ const CompaniesTable = lazy(() => import("./components/CompaniesTable"));
const SettingsView = lazy(() => import("./components/SettingsView")); const SettingsView = lazy(() => import("./components/SettingsView"));
const RemindersView = lazy(() => import("./components/RemindersView")); const RemindersView = lazy(() => import("./components/RemindersView"));
const QuickCommandDialog = lazy(() => import("./components/QuickCommandDialog")); const QuickCommandDialog = lazy(() => import("./components/QuickCommandDialog"));
const ProfilePage = lazy(() => import("./pages/ProfilePage")); const ProfilePage = lazy(() => import("./views/ProfilePage"));
const AdminAuditPage = lazy(() => import("./pages/AdminAuditPage")); const AdminAuditPage = lazy(() => import("./views/AdminAuditPage"));
const AdminUsersPage = lazy(() => import("./pages/AdminUsersPage")); const AdminUsersPage = lazy(() => import("./views/AdminUsersPage"));
const AdminSystemPage = lazy(() => import("./pages/AdminSystemPage")); const AdminSystemPage = lazy(() => import("./views/AdminSystemPage"));
const CorrespondenceInboxPage = lazy(() => import("./pages/CorrespondenceInboxPage")); const CorrespondenceInboxPage = lazy(() => import("./views/CorrespondenceInboxPage"));
const GmailReviewPage = lazy(() => import("./pages/GmailReviewPage")); const GmailReviewPage = lazy(() => import("./views/GmailReviewPage"));
const NotFoundPage = lazy(() => import("./pages/NotFoundPage")); const NotFoundPage = lazy(() => import("./views/NotFoundPage"));
type AuthConfig = { requireAuth: boolean }; type AuthConfig = { requireAuth: boolean };
type MeResponse = { type MeResponse = {
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
import App from "./App";
import ErrorBoundary from "./components/ErrorBoundary";
import { I18nProvider } from "./i18n/I18nProvider";
export default function ClientApp() {
return (
<LocalizationProvider dateAdapter={AdapterDateFns}>
<I18nProvider>
<ErrorBoundary>
<App />
</ErrorBoundary>
</I18nProvider>
</LocalizationProvider>
);
}
@@ -4,7 +4,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { api } from "./api"; import { api } from "./api";
import LandingPage from "./pages/LandingPage"; import LandingPage from "./views/LandingPage";
jest.mock("./api", () => ({ jest.mock("./api", () => ({
api: { api: {
@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import AdminSystemPage from './pages/AdminSystemPage'; import AdminSystemPage from './views/AdminSystemPage';
import { I18nProvider } from './i18n/I18nProvider'; import { I18nProvider } from './i18n/I18nProvider';
import { api } from './api'; import { api } from './api';
+1 -1
View File
@@ -35,7 +35,7 @@ export function getApiErrorMessage(error: any, fallback = "Request failed.") {
return fallback; return fallback;
} }
const envBaseUrl = process.env.REACT_APP_API_BASE_URL; const envBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
const defaultBaseUrl = const defaultBaseUrl =
window.location.hostname === "localhost" window.location.hostname === "localhost"
? "http://localhost:5202/api" ? "http://localhost:5202/api"
@@ -0,0 +1,21 @@
import React from "react";
export default function JobbjaktMark(props: React.SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Job tracker" {...props}>
<defs>
<linearGradient id="briefcase-track" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="100%" stopColor="#14b8a6" />
</linearGradient>
</defs>
<rect x="8" y="12" width="48" height="40" rx="12" fill="#0f172a" />
<path d="M22 20v-2c0-3.3 2.7-6 6-6h8c3.3 0 6 2.7 6 6v2" fill="none" stroke="url(#briefcase-track)" strokeWidth="4" strokeLinecap="round" />
<rect x="14" y="22" width="36" height="26" rx="8" fill="none" stroke="url(#briefcase-track)" strokeWidth="4" />
<path d="M14 31h14" stroke="url(#briefcase-track)" strokeWidth="4" strokeLinecap="round" />
<path d="M36 31h14" stroke="url(#briefcase-track)" strokeWidth="4" strokeLinecap="round" />
<circle cx="32" cy="31" r="4.5" fill="#e2e8f0" />
<path d="M24 40l5 5 11-12" fill="none" stroke="#e2e8f0" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
@@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Job tracker">
<defs>
<linearGradient id="briefcase-track" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#3b82f6"/>
<stop offset="100%" stop-color="#14b8a6"/>
</linearGradient>
</defs>
<rect x="8" y="12" width="48" height="40" rx="12" fill="#0f172a"/>
<path d="M22 20v-2c0-3.3 2.7-6 6-6h8c3.3 0 6 2.7 6 6v2" fill="none" stroke="url(#briefcase-track)" stroke-width="4" stroke-linecap="round"/>
<rect x="14" y="22" width="36" height="26" rx="8" fill="none" stroke="url(#briefcase-track)" stroke-width="4"/>
<path d="M14 31h14" stroke="url(#briefcase-track)" stroke-width="4" stroke-linecap="round"/>
<path d="M36 31h14" stroke="url(#briefcase-track)" stroke-width="4" stroke-linecap="round"/>
<circle cx="32" cy="31" r="4.5" fill="#e2e8f0"/>
<path d="M24 40l5 5 11-12" fill="none" stroke="#e2e8f0" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1002 B

@@ -53,7 +53,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
const [working, setWorking] = useState(false); const [working, setWorking] = useState(false);
const hostRef = useRef<HTMLDivElement | null>(null); const hostRef = useRef<HTMLDivElement | null>(null);
const clientId = (process.env.REACT_APP_GOOGLE_CLIENT_ID || "").trim(); const clientId = (process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "").trim();
const signedIn = Boolean(me?.provider); const signedIn = Boolean(me?.provider);
const actionLabel = !signedIn const actionLabel = !signedIn
? t("continueWithGoogle") ? t("continueWithGoogle")
@@ -1,185 +0,0 @@
import React, { useEffect, useState } from "react";
import { Box, Button, Chip, Paper, Typography } from "@mui/material";
import { PublicClientApplication } from "@azure/msal-browser";
import { api, getApiErrorMessage } from "../api";
import { clearAuthClientState, getAuthPersistencePreference } from "../auth";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
type MeResponse = {
provider?: "local" | "google" | "microsoft" | "external";
email?: string;
userName?: string;
displayName?: string;
firstName?: string;
lastName?: string;
microsoftLink?: {
linked: boolean;
email?: string | null;
linkedAt?: string | null;
} | null;
};
let msalInstance: PublicClientApplication | null = null;
function getMsalInstance(clientId: string): PublicClientApplication {
msalInstance ??= new PublicClientApplication({
auth: { clientId, authority: "https://login.microsoftonline.com/common", redirectUri: window.location.origin },
});
return msalInstance;
}
export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => void }) {
const { toast } = useToast();
const { t } = useI18n();
const [me, setMe] = useState<MeResponse | null>(null);
const [working, setWorking] = useState(false);
const clientId = (process.env.REACT_APP_MICROSOFT_CLIENT_ID || "").trim();
const signedIn = Boolean(me?.provider);
const actionLabel = !signedIn
? t("continueWithMicrosoft")
: me?.provider === "local" && !me?.microsoftLink?.linked
? t("linkWithMicrosoft")
: t("signInWithMicrosoft");
async function refreshMe() {
try {
const res = await api.get<MeResponse>("/auth/me");
setMe(res.data);
} catch {
setMe(null);
}
}
useEffect(() => {
void refreshMe();
}, []);
useEffect(() => {
const onAuthChanged = () => { void refreshMe(); };
window.addEventListener("auth-changed", onAuthChanged);
return () => window.removeEventListener("auth-changed", onAuthChanged);
}, []);
async function handleSignIn() {
if (!clientId) return;
setWorking(true);
try {
const msal = getMsalInstance(clientId);
await msal.initialize();
const result = await msal.loginPopup({ scopes: ["openid", "profile", "email"] });
const idToken = result.idToken;
if (!idToken) throw new Error(t("microsoftAuthFailed"));
if (me?.provider === "local") {
const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/microsoft/link", { token: idToken, rememberMe: getAuthPersistencePreference() === "local" });
toast(res.data?.email ? t("microsoftLinkedSuccessWithEmail", { email: res.data.email }) : t("microsoftLinkedSuccess"), "success");
await refreshMe();
} else {
await api.post("/auth/microsoft/exchange", { token: idToken, rememberMe: getAuthPersistencePreference() === "local" });
window.dispatchEvent(new Event("auth-changed"));
toast(t("microsoftSignedIn"), "success");
onSignedIn?.();
}
} catch (e: any) {
toast(getApiErrorMessage(e, t("microsoftAuthFailed")), "error");
} finally {
setWorking(false);
}
}
const signedInName = me?.userName || me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email || "";
return (
<Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
{t("microsoftAccountTitle")}
</Typography>
{!clientId && (
<Typography sx={{ color: "text.secondary" }}>
{t("microsoftSetupHint")}
</Typography>
)}
{clientId && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip size="small" label={me?.microsoftLink?.linked ? t("microsoftLinked") : t("microsoftAvailableToLink")} color={me?.microsoftLink?.linked ? "success" : "default"} variant={me?.microsoftLink?.linked ? "filled" : "outlined"} />
{me?.microsoftLink?.linkedAt ? <Chip size="small" variant="outlined" label={t("microsoftLinkedDate", { date: new Date(me.microsoftLink.linkedAt).toLocaleDateString() })} /> : null}
</Box>
{!signedIn ? (
<Typography sx={{ color: "text.secondary" }}>
{t("microsoftSignInHint")}
</Typography>
) : me?.provider === "local" ? (
<Typography sx={{ color: "text.secondary" }}>
{me.microsoftLink?.linked
? t("microsoftLinkedTo", { email: me.microsoftLink.email || t("microsoftLinkedToYourAccount") })
: t("microsoftBindHint")}
</Typography>
) : (
<Typography sx={{ color: "text.secondary" }}>
{t("microsoftExchangeHint")}
</Typography>
)}
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 1 }}>
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700, letterSpacing: 0.4, textTransform: "uppercase" }}>
{actionLabel}
</Typography>
<Button variant="outlined" disabled={working} onClick={() => void handleSignIn()}>
{actionLabel}
</Button>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
{signedIn ? (
<Button
variant="outlined"
onClick={() => {
void api.post("/auth/logout").catch(() => undefined).finally(() => {
clearAuthClientState();
setMe(null);
toast(t("signedOut"), "info");
});
}}
>
{t("signOut")}
</Button>
) : null}
{me?.provider === "local" && me.microsoftLink?.linked ? (
<Button
variant="outlined"
color="warning"
disabled={working}
onClick={async () => {
try {
await api.delete("/auth/microsoft/link");
toast(t("microsoftUnlinked"), "info");
await refreshMe();
} catch (e: any) {
const msg = e?.response?.data || e?.message || t("microsoftUnlinkFailed");
toast(String(msg), "error");
}
}}
>
{t("unlinkMicrosoft")}
</Button>
) : null}
</Box>
{signedIn && me?.email ? (
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{t("signedInAs", { name: signedInName })}
</Typography>
) : null}
</Box>
)}
</Paper>
);
}
@@ -4,7 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from './toast'; import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider'; import { I18nProvider } from './i18n/I18nProvider';
import CorrespondenceInboxPage from './pages/CorrespondenceInboxPage'; import CorrespondenceInboxPage from './views/CorrespondenceInboxPage';
import { api } from './api'; import { api } from './api';
jest.mock('./api', () => ({ jest.mock('./api', () => ({
@@ -4,7 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from './toast'; import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider'; import { I18nProvider } from './i18n/I18nProvider';
import GmailReviewPage from './pages/GmailReviewPage'; import GmailReviewPage from './views/GmailReviewPage';
import { api } from './api'; import { api } from './api';
jest.mock('./api', () => ({ jest.mock('./api', () => ({
+2 -44
View File
@@ -608,7 +608,7 @@ export const translations = {
adminSystemCpuMode: "CPU mode", adminSystemCpuMode: "CPU mode",
adminSystemNoSmtpHost: "No SMTP host configured", adminSystemNoSmtpHost: "No SMTP host configured",
googleAccountTitle: "Google account", googleAccountTitle: "Google account",
googleSetupHint: "Set `REACT_APP_GOOGLE_CLIENT_ID` in your UI environment to enable Google sign-in and account linking.", googleSetupHint: "Set `NEXT_PUBLIC_GOOGLE_CLIENT_ID` in your UI environment to enable Google sign-in and account linking.",
googleLinked: "Linked", googleLinked: "Linked",
googleAvailableToLink: "Available to link", googleAvailableToLink: "Available to link",
googleLinkedDate: "Linked {date}", googleLinkedDate: "Linked {date}",
@@ -628,26 +628,6 @@ export const translations = {
googleScriptLoadFailed: "Google auth script failed to load.", googleScriptLoadFailed: "Google auth script failed to load.",
googleUnlinked: "Google account unlinked.", googleUnlinked: "Google account unlinked.",
googleUnlinkFailed: "Failed to unlink Google account.", googleUnlinkFailed: "Failed to unlink Google account.",
microsoftAccountTitle: "Microsoft account",
microsoftSetupHint: "Set `REACT_APP_MICROSOFT_CLIENT_ID` in your UI environment to enable Microsoft sign-in and account linking.",
microsoftLinked: "Linked",
microsoftAvailableToLink: "Available to link",
microsoftLinkedDate: "Linked {date}",
microsoftSignInHint: "Sign in with a Microsoft account that has already been linked to your Jobbjakt user.",
continueWithMicrosoft: "Continue with Microsoft",
signInWithMicrosoft: "Sign in with Microsoft",
linkWithMicrosoft: "Link with Microsoft",
microsoftLinkedTo: "Linked to {email}.",
microsoftLinkedToYourAccount: "Linked to your Microsoft account.",
microsoftBindHint: "Bind a Microsoft account to this user so you can sign in with Microsoft and still keep your normal app roles and data.",
microsoftExchangeHint: "Exchange your Microsoft sign-in for a normal Jobbjakt session.",
microsoftSignedIn: "Signed in with Microsoft.",
microsoftLinkedSuccess: "Microsoft account linked.",
microsoftLinkedSuccessWithEmail: "Linked Microsoft account {email}.",
microsoftAuthFailed: "Microsoft authentication failed.",
microsoftUnlinked: "Microsoft account unlinked.",
microsoftUnlinkFailed: "Failed to unlink Microsoft account.",
unlinkMicrosoft: "Unlink Microsoft",
signedOut: "Signed out.", signedOut: "Signed out.",
signedInAs: "Signed in as {name}.", signedInAs: "Signed in as {name}.",
unlinkGoogle: "Unlink Google", unlinkGoogle: "Unlink Google",
@@ -683,7 +663,6 @@ export const translations = {
authOptional: "Authentication is optional in this environment.", authOptional: "Authentication is optional in this environment.",
emailAndPassword: "Email & password", emailAndPassword: "Email & password",
google: "Google", google: "Google",
microsoft: "Microsoft",
createAccount: "Create account", createAccount: "Create account",
signedIn: "Signed in.", signedIn: "Signed in.",
rememberMe: "Remember me", rememberMe: "Remember me",
@@ -1573,7 +1552,7 @@ export const translations = {
adminSystemCpuMode: "CPU-modus", adminSystemCpuMode: "CPU-modus",
adminSystemNoSmtpHost: "Ingen SMTP-vert konfigurert", adminSystemNoSmtpHost: "Ingen SMTP-vert konfigurert",
googleAccountTitle: "Google-konto", googleAccountTitle: "Google-konto",
googleSetupHint: "Sett `REACT_APP_GOOGLE_CLIENT_ID` i UI-miljøet ditt for å aktivere Google-innlogging og kontokobling.", googleSetupHint: "Sett `NEXT_PUBLIC_GOOGLE_CLIENT_ID` i UI-miljøet ditt for å aktivere Google-innlogging og kontokobling.",
googleLinked: "Koblet", googleLinked: "Koblet",
googleAvailableToLink: "Tilgjengelig for kobling", googleAvailableToLink: "Tilgjengelig for kobling",
googleLinkedDate: "Koblet {date}", googleLinkedDate: "Koblet {date}",
@@ -1593,26 +1572,6 @@ export const translations = {
googleScriptLoadFailed: "Kunne ikke laste Google-autentiseringsskriptet.", googleScriptLoadFailed: "Kunne ikke laste Google-autentiseringsskriptet.",
googleUnlinked: "Google-konto koblet fra.", googleUnlinked: "Google-konto koblet fra.",
googleUnlinkFailed: "Kunne ikke koble fra Google-kontoen.", googleUnlinkFailed: "Kunne ikke koble fra Google-kontoen.",
microsoftAccountTitle: "Microsoft-konto",
microsoftSetupHint: "Sett `REACT_APP_MICROSOFT_CLIENT_ID` i UI-miljøet ditt for å aktivere Microsoft-innlogging og kontokobling.",
microsoftLinked: "Koblet",
microsoftAvailableToLink: "Tilgjengelig for kobling",
microsoftLinkedDate: "Koblet {date}",
microsoftSignInHint: "Logg inn med en Microsoft-konto som allerede er koblet til Jobbjakt-brukeren din.",
continueWithMicrosoft: "Fortsett med Microsoft",
signInWithMicrosoft: "Logg inn med Microsoft",
linkWithMicrosoft: "Koble til med Microsoft",
microsoftLinkedTo: "Koblet til {email}.",
microsoftLinkedToYourAccount: "Koblet til Microsoft-kontoen din.",
microsoftBindHint: "Koble en Microsoft-konto til denne brukeren slik at du kan logge inn med Microsoft og fortsatt beholde vanlige approller og data.",
microsoftExchangeHint: "Bytt Microsoft-innloggingen din mot en vanlig Jobbjakt-økt.",
microsoftSignedIn: "Logget inn med Microsoft.",
microsoftLinkedSuccess: "Microsoft-konto koblet.",
microsoftLinkedSuccessWithEmail: "Koblet Microsoft-konto {email}.",
microsoftAuthFailed: "Microsoft-autentisering mislyktes.",
microsoftUnlinked: "Microsoft-konto koblet fra.",
microsoftUnlinkFailed: "Kunne ikke koble fra Microsoft-kontoen.",
unlinkMicrosoft: "Koble fra Microsoft",
signedOut: "Logget ut.", signedOut: "Logget ut.",
signedInAs: "Logget inn som {name}.", signedInAs: "Logget inn som {name}.",
unlinkGoogle: "Koble fra Google", unlinkGoogle: "Koble fra Google",
@@ -1648,7 +1607,6 @@ export const translations = {
authOptional: "Autentisering er valgfri i dette miljøet.", authOptional: "Autentisering er valgfri i dette miljøet.",
emailAndPassword: "E-post og passord", emailAndPassword: "E-post og passord",
google: "Google", google: "Google",
microsoft: "Microsoft",
createAccount: "Opprett konto", createAccount: "Opprett konto",
signedIn: "Logget inn.", signedIn: "Logget inn.",
rememberMe: "Husk meg", rememberMe: "Husk meg",
-29
View File
@@ -1,29 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import ErrorBoundary from "./components/ErrorBoundary";
import { I18nProvider } from './i18n/I18nProvider';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<I18nProvider>
<ErrorBoundary>
<App />
</ErrorBoundary>
</I18nProvider>
</LocalizationProvider>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
+1 -1
View File
@@ -25,7 +25,7 @@ import MenuOpenIcon from "@mui/icons-material/MenuOpen";
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone"; import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined"; import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import { ReactComponent as JobbjaktMark } from "../assets/jobbbjakt-mark.svg"; import JobbjaktMark from "../assets/JobbjaktMark";
import { useI18n } from "../i18n/I18nProvider"; import { useI18n } from "../i18n/I18nProvider";
export type NavItem = { export type NavItem = {
+1 -1
View File
@@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import LoginPage from './pages/LoginPage'; import LoginPage from './views/LoginPage';
import { ToastProvider } from './toast'; import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider'; import { I18nProvider } from './i18n/I18nProvider';
import { api } from './api'; import { api } from './api';
+1 -1
View File
@@ -3,7 +3,7 @@ import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ToastProvider } from './toast'; import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider'; import { I18nProvider } from './i18n/I18nProvider';
import ProfilePage from './pages/ProfilePage'; import ProfilePage from './views/ProfilePage';
import { api } from './api'; import { api } from './api';
const createObjectURLMock = jest.fn(() => 'blob:mock-pdf'); const createObjectURLMock = jest.fn(() => 'blob:mock-pdf');
-1
View File
@@ -1 +0,0 @@
/// <reference types="react-scripts" />
-15
View File
@@ -1,15 +0,0 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
+1 -2
View File
@@ -29,13 +29,12 @@ jest.mock('./api', () => ({
})); }));
jest.mock('./components/GoogleAuthCard', () => () => null); jest.mock('./components/GoogleAuthCard', () => () => null);
jest.mock('./components/MicrosoftAuthCard', () => () => null);
beforeEach(() => { beforeEach(() => {
const { api } = require('./api'); const { api } = require('./api');
api.get.mockImplementation((url: string) => { api.get.mockImplementation((url: string) => {
if (url === '/auth/config') { if (url === '/auth/config') {
return Promise.resolve({ data: { requireAuth: false, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: false } }); return Promise.resolve({ data: { requireAuth: false, googleEnabled: false, localEnabled: true, allowRegistration: false } });
} }
if (url === '/auth/me') { if (url === '/auth/me') {
return Promise.resolve({ data: { roles: [], email: 'demo@example.com', userName: 'demo' } }); return Promise.resolve({ data: { roles: [], email: 'demo@example.com', userName: 'demo' } });
@@ -7,14 +7,12 @@ import { useLocation, useNavigate } from "react-router-dom";
import { api, getApiErrorMessage } from "../api"; import { api, getApiErrorMessage } from "../api";
import { getRememberMePref, setAuthPersistencePreference } from "../auth"; import { getRememberMePref, setAuthPersistencePreference } from "../auth";
import GoogleAuthCard from "../components/GoogleAuthCard"; import GoogleAuthCard from "../components/GoogleAuthCard";
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider"; import { useI18n } from "../i18n/I18nProvider";
type AuthConfig = { type AuthConfig = {
requireAuth: boolean; requireAuth: boolean;
googleEnabled: boolean; googleEnabled: boolean;
microsoftEnabled: boolean;
localEnabled: boolean; localEnabled: boolean;
allowRegistration: boolean; allowRegistration: boolean;
}; };
@@ -83,7 +81,6 @@ export default function LoginPage() {
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}> <Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
<Tab label={t("emailAndPassword")} /> <Tab label={t("emailAndPassword")} />
<Tab label={t("google")} /> <Tab label={t("google")} />
<Tab label={t("microsoft")} />
</Tabs> </Tabs>
{tab === 0 && ( {tab === 0 && (
@@ -126,7 +123,6 @@ export default function LoginPage() {
)} )}
{tab === 1 && <GoogleAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />} {tab === 1 && <GoogleAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
{tab === 2 && <MicrosoftAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
</Paper> </Paper>
</Box> </Box>
); );
@@ -9,7 +9,6 @@ import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
import { api, getApiErrorMessage } from "../api"; import { api, getApiErrorMessage } from "../api";
import GoogleAuthCard from "../components/GoogleAuthCard"; import GoogleAuthCard from "../components/GoogleAuthCard";
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
import CropImageDialog from "../components/CropImageDialog"; import CropImageDialog from "../components/CropImageDialog";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider"; import { useI18n } from "../i18n/I18nProvider";
@@ -563,7 +562,6 @@ export default function ProfilePage() {
</Box> </Box>
<GoogleAuthCard /> <GoogleAuthCard />
<MicrosoftAuthCard />
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "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" }}>
+17 -4
View File
@@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es2017",
"lib": [ "lib": [
"dom", "dom",
"dom.iterable", "dom.iterable",
@@ -14,13 +14,26 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"module": "esnext", "module": "esnext",
"moduleResolution": "node", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"incremental": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx" "jsx": "react-jsx",
"plugins": [
{
"name": "next"
}
]
}, },
"include": [ "include": [
"src" "src",
"app",
"next-env.d.ts",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
] ]
} }