Compare commits

...

16 Commits

Author SHA1 Message Date
cesnimda fb04088d62 fix(auth): fix SQLite DateTimeOffset comparison crash in trusted-device checks
The sessions unit's live smoke test caught the same bug it fixed in
SessionsController also present in TrustedDeviceService and
TwoFactorController's device list: SQLite/Pomelo's EF Core provider
cannot translate DateTimeOffset relational comparisons or ORDER BY to
SQL, so IsDeviceTrustedAsync (the check that skips 2FA for a trusted
browser) and ListTrustedDevices would 500 on real SQLite despite
passing on EF's InMemory test provider. Same fix: equality-only in
the DB query, expiry comparison and sort after materializing.
2026-07-13 01:49:25 +02:00
cesnimda c6918cbeea feat(auth): add server-tracked sessions with view/revoke
JWTs were previously fully stateless -- the token alone was the credential
until its own expiry, with no way to list or kill a session server-side. Add
a UserSession table alongside every JWT issued (AppSessionIssuer), embed its
id as a "sid" claim, and check that claim against the DB on every "local"
scheme request (Program.cs OnTokenValidated) so a session can actually be
revoked before its JWT naturally expires. New /api/auth/sessions endpoints
(list, revoke one, revoke-others) plus a Sessions card on the profile page.

Fails closed on a missing "sid" claim: every JWT issued going forward has
one, so a token without it is either pre-deploy (forces one re-login for
already-signed-in users at deploy time, same additive-forward cost the
2FA/trusted-device work on this branch already paid) or forged.
2026-07-13 01:47:31 +02:00
cesnimda 904f3a8ec8 feat(auth): add configurable email verification enforcement
Auth:RequireEmailVerification (default off) gates whether local
register requires confirming email before login. OAuth new-user paths
are untouched -- Google/Microsoft already assert a verified email.
Adds verify-email and resend-verification-email endpoints, mirroring
the existing reset-password enumeration-avoidance and rate-limiting
patterns, plus a login-embedded resend affordance and a verify-email
landing page on the frontend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 01:22:26 +02:00
cesnimda 0ca2f2b261 feat(auth): add trusted-device 30-day 2FA skip (frontend)
Adds a "Trust this device for 30 days" checkbox to the 2FA challenge step,
and a "Trusted devices" section to the 2FA settings card: list devices with
a "this device" badge, per-row revoke, and a confirm-gated "sign out all
other trusted devices" action. Both flows are opt-in and additive -- default
unchecked, so nothing changes for a user who never uses them.
2026-07-13 01:02:43 +02:00
cesnimda b914630657 feat(auth): add trusted-device 30-day 2FA skip (backend)
Adds a "trust this device" option to the 2FA challenge: on success, mints a
random token (only its SHA-256 hash is stored), sets it as a new httpOnly,
Secure, SameSite=Strict cookie, and records a TrustedDevice row. AuthController
checks that cookie for the exact signing-in user before gating on 2FA -- a
mismatched user, expired, or revoked device falls through to the normal 2FA
prompt, never errors. TwoFactorController also exposes list/revoke/revoke-all
endpoints for managing trusted devices, scoped to the owning user.

Schema added via the existing raw-SQL reconciler (SQLite + MySQL dialects),
not EF migrations, matching this repo's established pattern.
2026-07-13 01:02:35 +02:00
cesnimda b85dc1ffb7 feat(auth): add 2FA setup UI and login challenge step 2026-07-12 21:17:09 +02:00
cesnimda c68b49eda0 feat(auth): add per-account lockout and TOTP 2FA with recovery codes
Adds three layers of account-security hardening, all gated behind the
existing SignInWithAppSessionAsync-equivalent (now AppSessionIssuer) so
every sign-in path -- local, Google, Microsoft -- goes through the same
lockout/2FA checks:

- Per-account lockout: Identity's built-in lockout store (columns already
  provisioned, previously unused) is now wired up in AuthController.Login
  via IsLockedOutAsync/AccessFailedAsync/ResetAccessFailedCountAsync, 5
  failed attempts / 15 min, same generic 401 as wrong-password to avoid
  enumeration.

- RFC 6238 TOTP 2FA (Otp.NET) with QR-code setup (QRCoder, fully local/
  offline) on a new TwoFactorController: setup requires password
  re-confirmation and returns a pending (unconfirmed) secret + QR; the
  secret is only persisted as active once verify-setup checks a real
  code. Secrets are encrypted at rest via the same IDataProtector pattern
  already used for Gmail/Microsoft OAuth refresh tokens.

- Login/OAuth exchange now checks TwoFactorEnabled before issuing a real
  session. If enabled, it hands back an opaque, server-side (IMemoryCache)
  pending token via a new ITwoFactorPendingTokenService -- deliberately
  NOT a JWT, so it can never be presented as a bearer token to bypass the
  2FA check on any other endpoint. Only POST /api/auth/2fa/challenge can
  redeem it, rate-limited at 5/5min (tighter than password login, since a
  6-digit space is far more brute-forceable).

- One-time recovery codes (10 per enable/regenerate, SHA-256-hashed at
  rest, shown once in plaintext) accepted in the same challenge endpoint
  as an alternative to a TOTP code.

Schema: AspNetUsers gains TotpSecretEncrypted / TotpPendingSecretEncrypted
/ TotpEnabledAtUtc, plus a new TwoFactorRecoveryCodes table, added to both
the SQLite and MySQL dialect blocks in the startup schema reconciler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 20:48:09 +02:00
cesnimda 0cd1ba398e Merge pull request 'feat(ux): product/UX review implementation (onboarding, empty states, a11y, mobile kanban)' (#27) from feat/ux-review-quick-wins into main
CI and Deploy / test (push) Successful in 2m7s
CI and Deploy / deploy (push) Successful in 40s
2026-07-12 04:30:00 +02:00
cesnimda d5d82cb528 feat(ux): onboarding checklist, dashboard-first landing (fixed)
CI and Deploy / test (pull_request) Successful in 2m5s
CI and Deploy / deploy (pull_request) Has been skipped
Dashboard onboarding checklist: a dismissible card with 3 steps (add
CV, import first job, check match score), each linking straight to
where you'd do it. Auto-hides once both CV and a job exist; otherwise
persists per-user via localStorage until dismissed.

Fixes the actual authenticated-landing redirect to /dashboard: my
earlier commit changed App.tsx's inner Shell route for "/", which
turned out to be dead code -- the outer router claims "/" for
LandingPage first, so Shell's own "/" route is never reached on a
direct hit. The real redirect lives in LandingPage.tsx's post-auth-check
navigate() and LoginPage.tsx's post-login nextPath default; both now
point at /dashboard. Verified live: an authenticated visitor hitting
"/" now lands on Dashboard with the onboarding checklist visible,
confirmed via rendered page text and screenshot.
2026-07-12 04:26:17 +02:00
cesnimda 9615ee3f41 feat(ux): per-view subtitles, correspondence cross-links, mobile kanban, a11y
Continuing the product/UX review's deferred items:

- Every top-level view now gets a one-line subtitle under its title
  (Dashboard/Jobs/Kanban/Reminders/Correspondence/Gmail review) stating
  what that specific view is for, instead of navigation being the only
  signal of what each page does.
- Correspondence inbox and Gmail review queue cross-link to each other
  instead of being two unexplained flat sidebar items -- kept both nav
  entries (renaming/nesting risked breaking muscle memory) but made the
  relationship between them explicit in the UI itself.
- Kanban board switches to a horizontal scroll-snap row on phone-width
  viewports instead of stacking all 5 columns vertically, which meant
  a lot of scrolling to see anything past "Applied".
- Match-score ring gets an aria-label with the actual percentage --
  it was two nested decorative CircularProgress elements with no
  accessible text. (Keyboard-accessible status changes on kanban cards
  were already covered by the existing "..." menu -- no gap there.)
2026-07-12 04:14:37 +02:00
cesnimda 58868fc2b6 feat(ux): first-time onboarding, empty states, and copy fixes
Implements the six "propose first" items from the product/UX review:

- "/" now redirects to /dashboard instead of the empty /jobs table --
  a new user's first screen is now an overview with orientation, not
  a data table with zero rows and four filter dropdowns.
- Jobs table gets a real first-time empty state (distinct from "no
  results match your filters") pointing at Add Job and the bookmarklet,
  instead of a bare "No jobs found."
- Match Score card and Candidate Fit tab now each get a one-line
  caption explaining what they are and how they differ (deterministic
  keyword coverage vs. AI opinion) -- they previously sat side by side
  with no explanation of why there are two.
- Google sign-in hint now reflects self-serve signup when
  Auth:AllowRegistration is on, instead of always implying you need an
  existing linked account.
- Quick Search button now shows its keyboard shortcut (Ctrl+K / ⌘K)
  inline instead of being undiscoverable.
2026-07-12 04:05:32 +02:00
cesnimda 7dadf8dde4 Merge pull request 'fix(auth): Google Sign-In audience mismatch + remove per-user accent color' (#26) from fix/google-signin-and-theming-cleanup into main
CI and Deploy / test (push) Successful in 2m5s
CI and Deploy / deploy (push) Successful in 1m10s
2026-07-12 03:06:35 +02:00
cesnimda 33d899c243 fix(auth): Google Sign-In audience mismatch + remove per-user accent color
CI and Deploy / test (pull_request) Successful in 2m8s
CI and Deploy / deploy (pull_request) Has been skipped
Root cause of "Google authentication failed": appsettings.Development.json
had Auth:GoogleClientId set to the literal placeholder
"CHANGE_ME_GOOGLE_CLIENT_ID" while the frontend's .env.development had a
real (already-public, already-committed) client ID -- every Google ID
token's audience check failed against the backend's placeholder. Fixed
by setting the same real client ID on both sides (a client ID is a
public identifier, not a secret, safe to commit -- unlike a client
secret). Also enabled Auth:AllowRegistration in dev so the existing
Google-first self-serve-signup path (auto-create on unmatched verified
email, auto-link on matching verified email -- built during Wave 7) is
actually exercisable locally.

Wired the previously-missing Auth__MicrosoftClientId /
NEXT_PUBLIC_MICROSOFT_CLIENT_ID into docker-compose.yml/.env.example
(distinct from the existing MICROSOFT_CLIENT_ID used for Outlook mail
linking) -- Microsoft sign-in was never deployable, a leftover gap from
when it was built. Fixed a stale env-var name in the Microsoft setup
hint copy (still said REACT_APP_*, predates the Next.js migration).

Removed the per-user accent color picker entirely: it was purely
client-side (localStorage + theme.ts), never touched the backend/DB.
theme.ts now hardcodes a single ACCENT constant; themePrefs.ts drops
get/set/clearAccentColor; App.tsx and SettingsView.tsx drop the
accentColor prop threading. Dead accent-related i18n keys removed from
both locales.

Consolidated Settings' "Account" tab (duplicated GoogleAuthCard, which
already lives on the Profile page) into Profile: moved AuthStatusCard
and EmailProviderConnections there alongside the existing Google/
Microsoft auth cards, so identity/account-linking lives in one place.
Settings drops from 5 tabs to 4 and its General tab uses a consistent
SectionCard layout instead of ad-hoc per-card styling.

Verified: dotnet build/test (177/177) and npm build/test (57/57) both
green; confirmed live against a running dev server that /auth/config
now reports googleEnabled with the corrected client ID, Settings has
no accent controls, and Profile shows the consolidated auth section.
2026-07-12 02:43:10 +02:00
cesnimda b2e176940c Merge pull request 'fix(deploy): copy .npmrc before npm ci in frontend Dockerfile' (#25) from fix/docker-npmrc-not-copied into main
CI and Deploy / test (push) Successful in 2m1s
CI and Deploy / deploy (push) Successful in 2m13s
2026-07-12 02:18:47 +02:00
cesnimda 86cdafb3ef fix(deploy): copy .npmrc before npm ci in frontend Dockerfile
CI and Deploy / test (pull_request) Successful in 2m1s
CI and Deploy / deploy (pull_request) Has been skipped
Production deploy has been broken since the Next.js migration merged:
the Dockerfile ran `npm ci` right after COPY package*.json, before the
later `COPY . .` that would bring in .npmrc -- so the legacy-peer-deps
fix for react-scripts' stale TS ^4 peer constraint (added for CI in
dbb1580) never took effect in the actual deploy image, and every
deploy since has failed with the same ERESOLVE error CI hit before
that fix. Copy .npmrc alongside package*.json so npm ci sees it.
2026-07-12 02:15:49 +02:00
cesnimda 0e5845a95a Merge pull request 'feat(ui): circular match-score ring in job workspace' (#24) from feature/ui-rework-match-score-ring into main
CI and Deploy / test (push) Successful in 2m0s
CI and Deploy / deploy (push) Failing after 45s
2026-07-12 02:05:18 +02:00
51 changed files with 3611 additions and 369 deletions
+3
View File
@@ -5,6 +5,9 @@ AUTH_JWT_KEY=CHANGE_ME_LONG_RANDOM_SECRET
AUTH_ADMIN_EMAIL=admin@example.com
AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD
AUTH_GOOGLE_CLIENT_ID=CHANGE_ME_GOOGLE_CLIENT_ID
# Optional: enables the "Continue with Microsoft" sign-in tab (separate from the
# MICROSOFT_CLIENT_ID below, which is for Outlook mail linking, not sign-in).
AUTH_MICROSOFT_CLIENT_ID=
GOOGLE_GMAIL_CLIENT_SECRET=CHANGE_ME_GOOGLE_OAUTH_CLIENT_SECRET
# Optional. If omitted, the backend uses https://<your-domain>/api/gmail/oauth/callback
GOOGLE_GMAIL_REDIRECT_URI=
+36
View File
@@ -28,6 +28,9 @@ namespace JobTrackerApi.Data
public DbSet<CvUploadArtifact> CvUploadArtifacts => Set<CvUploadArtifact>();
public DbSet<CvExtractionRun> CvExtractionRuns => Set<CvExtractionRun>();
public DbSet<TailoredCvDraft> TailoredCvDrafts => Set<TailoredCvDraft>();
public DbSet<TwoFactorRecoveryCode> TwoFactorRecoveryCodes => Set<TwoFactorRecoveryCode>();
public DbSet<TrustedDevice> TrustedDevices => Set<TrustedDevice>();
public DbSet<UserSession> UserSessions => Set<UserSession>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -141,6 +144,39 @@ namespace JobTrackerApi.Data
.WithOne(j => j.TailoredCvDraft)
.HasForeignKey<TailoredCvDraft>(x => x.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade);
// No FK to AspNetUsers: the login-time challenge endpoint reads these rows before a
// session (and thus CurrentUserId) exists, via IgnoreQueryFilters() -- same convention
// as AdminAuditController's cross-cutting queries.
modelBuilder.Entity<TwoFactorRecoveryCode>()
.HasQueryFilter(x => CurrentUserId != null && x.UserId == CurrentUserId);
modelBuilder.Entity<TwoFactorRecoveryCode>()
.HasIndex(x => new { x.UserId, x.UsedAtUtc });
// No FK to AspNetUsers: the login-time trusted-device check reads these rows before a
// session (and thus CurrentUserId) exists, via IgnoreQueryFilters() -- same convention
// as TwoFactorRecoveryCode above.
modelBuilder.Entity<TrustedDevice>()
.HasQueryFilter(x => CurrentUserId != null && x.UserId == CurrentUserId);
modelBuilder.Entity<TrustedDevice>()
.HasIndex(x => x.UserId);
modelBuilder.Entity<TrustedDevice>()
.HasIndex(x => x.TokenHash);
// No FK to AspNetUsers, same convention as TrustedDevice/TwoFactorRecoveryCode above: the
// OnTokenValidated auth check reads this table before CurrentUserId is meaningfully set
// for the request being validated, via IgnoreQueryFilters().
modelBuilder.Entity<UserSession>()
.HasKey(x => x.Id);
modelBuilder.Entity<UserSession>()
.HasQueryFilter(x => CurrentUserId != null && x.UserId == CurrentUserId);
modelBuilder.Entity<UserSession>()
.HasIndex(x => x.UserId);
}
}
}
@@ -1,10 +1,12 @@
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
@@ -17,6 +19,388 @@ namespace JobTrackerApi.Tests;
public sealed class AuthAndSystemControllerTests
{
[Fact]
public async Task Login_locks_account_after_five_failed_attempts_and_rejects_sixth_even_with_correct_password()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var userManager = CreateUserManager();
userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user);
userManager.Setup(x => x.FindByNameAsync("person@example.com")).ReturnsAsync((ApplicationUser?)null);
var failedCount = 0;
var lockedOut = false;
userManager.Setup(x => x.IsLockedOutAsync(user)).Returns(() => Task.FromResult(lockedOut));
userManager.Setup(x => x.AccessFailedAsync(user))
.Callback(() =>
{
failedCount++;
if (failedCount >= 5) lockedOut = true;
})
.ReturnsAsync(IdentityResult.Success);
userManager.Setup(x => x.CheckPasswordAsync(user, "wrong-password")).ReturnsAsync(false);
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());
for (var i = 0; i < 5; i++)
{
var attempt = await controller.Login(new AuthController.LoginRequest("person@example.com", "wrong-password"), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(attempt);
}
Assert.True(lockedOut);
var sixthAttempt = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(sixthAttempt);
userManager.Verify(x => x.CheckPasswordAsync(user, "correct-password"), Times.Never);
}
[Fact]
public async Task Login_skips_two_factor_when_trusted_device_cookie_matches_current_user()
{
var dbName = Guid.NewGuid().ToString();
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "irrelevant" };
const string token = "trusted-device-token";
using (var seedDb = BuildDb(dbName, null))
{
seedDb.TrustedDevices.Add(new TrustedDevice
{
UserId = "user-1",
TokenHash = TrustedDeviceService.HashToken(token),
CreatedAtUtc = DateTimeOffset.UtcNow,
LastSeenAtUtc = DateTimeOffset.UtcNow,
ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(30),
});
seedDb.SaveChanges();
}
var controller = BuildLoginController(user, "correct-password", out var db, dbName, token);
using (db)
{
var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
var session = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
Assert.True(session.Authenticated);
}
}
[Fact]
public async Task Login_does_not_skip_two_factor_when_trusted_device_belongs_to_different_user()
{
var dbName = Guid.NewGuid().ToString();
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "irrelevant" };
const string token = "trusted-device-token";
using (var seedDb = BuildDb(dbName, null))
{
seedDb.TrustedDevices.Add(new TrustedDevice
{
UserId = "user-2", // a different account -- must not skip 2FA for user-1
TokenHash = TrustedDeviceService.HashToken(token),
CreatedAtUtc = DateTimeOffset.UtcNow,
LastSeenAtUtc = DateTimeOffset.UtcNow,
ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(30),
});
seedDb.SaveChanges();
}
var controller = BuildLoginController(user, "correct-password", out var db, dbName, token);
using (db)
{
var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
Assert.IsType<AuthController.TwoFactorRequiredResult>(ok.Value);
}
}
[Fact]
public async Task Login_does_not_skip_two_factor_when_trusted_device_is_expired()
{
var dbName = Guid.NewGuid().ToString();
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "irrelevant" };
const string token = "trusted-device-token";
using (var seedDb = BuildDb(dbName, null))
{
seedDb.TrustedDevices.Add(new TrustedDevice
{
UserId = "user-1",
TokenHash = TrustedDeviceService.HashToken(token),
CreatedAtUtc = DateTimeOffset.UtcNow.AddDays(-31),
LastSeenAtUtc = DateTimeOffset.UtcNow.AddDays(-31),
ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(-1), // expired yesterday
});
seedDb.SaveChanges();
}
var controller = BuildLoginController(user, "correct-password", out var db, dbName, token);
using (db)
{
var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
Assert.IsType<AuthController.TwoFactorRequiredResult>(ok.Value);
}
}
[Fact]
public async Task Login_does_not_skip_two_factor_when_no_trusted_device_cookie_present()
{
var dbName = Guid.NewGuid().ToString();
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "irrelevant" };
var controller = BuildLoginController(user, "correct-password", out var db, dbName, cookieToken: null);
using (db)
{
var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
Assert.IsType<AuthController.TwoFactorRequiredResult>(ok.Value);
}
}
[Fact]
public async Task Register_sets_EmailConfirmed_false_and_sends_verification_email_when_flag_on()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Auth:AllowRegistration"] = "true",
["Auth:RequireEmailVerification"] = "true",
})
.Build();
var userManager = CreateUserManager();
userManager.Setup(x => x.FindByEmailAsync("new.user@example.com")).ReturnsAsync((ApplicationUser?)null);
ApplicationUser? created = null;
userManager
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), "password123"))
.Callback<ApplicationUser, string>((u, _) => created = u)
.ReturnsAsync(IdentityResult.Success);
userManager.Setup(x => x.GenerateEmailConfirmationTokenAsync(It.IsAny<ApplicationUser>())).ReturnsAsync("confirm-token");
var tokenService = new Mock<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
var emailSender = new Mock<IAppEmailSender>();
var controller = new AuthController(config, userManager.Object, tokenService.Object, emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.Register(new AuthController.RegisterRequest("new.user@example.com", "password123"), CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
Assert.NotNull(created);
Assert.False(created!.EmailConfirmed);
emailSender.Verify(x => x.SendAsync("new.user@example.com", It.IsAny<string>(), It.Is<string>(b => b.Contains("verify-email")), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Register_is_unchanged_when_flag_off()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:AllowRegistration"] = "true" })
.Build();
var userManager = CreateUserManager();
userManager.Setup(x => x.FindByEmailAsync("new.user@example.com")).ReturnsAsync((ApplicationUser?)null);
ApplicationUser? created = null;
userManager
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), "password123"))
.Callback<ApplicationUser, string>((u, _) => created = u)
.ReturnsAsync(IdentityResult.Success);
var tokenService = new Mock<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
var emailSender = new Mock<IAppEmailSender>();
var controller = new AuthController(config, userManager.Object, tokenService.Object, emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.Register(new AuthController.RegisterRequest("new.user@example.com", "password123"), CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
Assert.NotNull(created);
Assert.True(created!.EmailConfirmed);
emailSender.Verify(x => x.SendAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Login_rejects_unconfirmed_local_account_when_flag_on()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:RequireEmailVerification"] = "true" })
.Build();
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", EmailConfirmed = false };
var userManager = CreateUserManager();
userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user);
userManager.Setup(x => x.FindByNameAsync("person@example.com")).ReturnsAsync((ApplicationUser?)null);
userManager.Setup(x => x.IsLockedOutAsync(user)).ReturnsAsync(false);
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success);
var controller = new AuthController(config, userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);
var obj = Assert.IsType<ObjectResult>(result);
Assert.Equal(StatusCodes.Status403Forbidden, obj.StatusCode);
}
[Fact]
public async Task Login_allows_unconfirmed_local_account_when_flag_off()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", EmailConfirmed = false };
var userManager = CreateUserManager();
userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user);
userManager.Setup(x => x.FindByNameAsync("person@example.com")).ReturnsAsync((ApplicationUser?)null);
userManager.Setup(x => x.IsLockedOutAsync(user)).ReturnsAsync(false);
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success);
var tokenService = new Mock<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
var session = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
Assert.True(session.Authenticated);
}
[Fact]
public async Task VerifyEmail_confirms_account_on_valid_token()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", EmailConfirmed = false };
var userManager = CreateUserManager();
userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user);
userManager.Setup(x => x.ConfirmEmailAsync(user, "good-token")).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, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());
var result = await controller.VerifyEmail(new AuthController.VerifyEmailRequest("user-1", "good-token"));
Assert.IsType<NoContentResult>(result);
}
[Fact]
public async Task ResendVerificationEmail_returns_identical_response_for_real_and_fake_accounts()
{
var user = new ApplicationUser { Id = "user-1", Email = "real@example.com", UserName = "real@example.com", EmailConfirmed = false };
var userManager = CreateUserManager();
userManager.Setup(x => x.FindByEmailAsync("real@example.com")).ReturnsAsync(user);
userManager.Setup(x => x.FindByEmailAsync("fake@example.com")).ReturnsAsync((ApplicationUser?)null);
userManager.Setup(x => x.HasPasswordAsync(user)).ReturnsAsync(true);
userManager.Setup(x => x.GenerateEmailConfirmationTokenAsync(user)).ReturnsAsync("confirm-token");
var emailSender = new Mock<IAppEmailSender>();
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var realResult = await controller.ResendVerificationEmail(new AuthController.ResendVerificationEmailRequest("real@example.com"), CancellationToken.None);
var fakeResult = await controller.ResendVerificationEmail(new AuthController.ResendVerificationEmailRequest("fake@example.com"), CancellationToken.None);
Assert.IsType<NoContentResult>(realResult);
Assert.IsType<NoContentResult>(fakeResult);
emailSender.Verify(x => x.SendAsync("real@example.com", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Exchange_google_token_new_user_stays_EmailConfirmed_true_even_when_verification_flag_is_on()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Auth:AllowRegistration"] = "true",
["Auth:RequireEmailVerification"] = "true",
})
.Build();
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);
var tokenService = new Mock<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
var googleValidator = new Mock<IGoogleTokenValidator>();
googleValidator
.Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GoogleTokenPrincipal("google-subject", "new.hire@example.com", true, "New", "Hire", "New Hire"));
var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), googleValidator.Object, Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
Assert.NotNull(created);
Assert.True(created!.EmailConfirmed);
}
private static JobTrackerContext BuildDb(string dbName, string? currentUserId)
{
var options = new DbContextOptionsBuilder<JobTrackerContext>().UseInMemoryDatabase(dbName).Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(x => x.UserId).Returns(currentUserId);
return new JobTrackerContext(options, currentUser.Object);
}
private static AuthController BuildLoginController(ApplicationUser user, string password, out JobTrackerContext db, string dbName, string? cookieToken)
{
var userManager = CreateUserManager();
userManager.Setup(x => x.FindByEmailAsync(user.Email!)).ReturnsAsync(user);
userManager.Setup(x => x.FindByNameAsync(user.Email!)).ReturnsAsync((ApplicationUser?)null);
userManager.Setup(x => x.IsLockedOutAsync(user)).ReturnsAsync(false);
userManager.Setup(x => x.CheckPasswordAsync(user, password)).ReturnsAsync(true);
userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success);
var tokenService = new Mock<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
db = BuildDb(dbName, null);
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), db)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
if (cookieToken is not null)
{
controller.Request.Headers["Cookie"] = $"{AuthSessionOptions.TrustedDeviceCookieName}={cookieToken}";
}
return controller;
}
[Fact]
public async Task Update_profile_applies_trimmed_profile_fields()
{
@@ -25,7 +409,7 @@ public sealed class AuthAndSystemControllerTests
userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
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>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());
var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(" new@example.com ", " newuser ", " Ada ", " Lovelace ", " Ada L. ", null, null));
@@ -50,7 +434,7 @@ public sealed class AuthAndSystemControllerTests
.Setup(x => x.SendAsync(user.Email!, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.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>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext
{
@@ -84,14 +468,14 @@ public sealed class AuthAndSystemControllerTests
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var tokenService = new Mock<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
var googleValidator = new Mock<IGoogleTokenValidator>();
googleValidator
.Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>()))
.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, Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext
{
@@ -101,7 +485,7 @@ public sealed class AuthAndSystemControllerTests
var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var ok = Assert.IsType<OkObjectResult>(result);
var payload = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
Assert.True(payload.Authenticated);
Assert.Equal("google", payload.Provider);
@@ -124,7 +508,7 @@ public sealed class AuthAndSystemControllerTests
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");
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
var microsoftValidator = new Mock<IMicrosoftTokenValidator>();
microsoftValidator
@@ -135,7 +519,7 @@ public sealed class AuthAndSystemControllerTests
.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)
var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext
{
@@ -145,7 +529,7 @@ public sealed class AuthAndSystemControllerTests
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var ok = Assert.IsType<OkObjectResult>(result);
var payload = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
Assert.True(payload.Authenticated);
Assert.Equal("microsoft", payload.Provider);
@@ -166,7 +550,7 @@ public sealed class AuthAndSystemControllerTests
.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)
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext
{
@@ -176,7 +560,7 @@ public sealed class AuthAndSystemControllerTests
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None);
Assert.IsType<UnauthorizedObjectResult>(result.Result);
Assert.IsType<UnauthorizedObjectResult>(result);
userManager.Verify(x => x.CreateAsync(It.IsAny<ApplicationUser>()), Times.Never);
}
@@ -51,7 +51,7 @@ public sealed class ClientErrorsControllerTests
var userManager = TestHostFactory.CreateUserManager();
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<IMicrosoftTokenValidator>(), Mock.Of<ILogger<AuthController>>(), Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext
{
@@ -0,0 +1,242 @@
using System.Security.Claims;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using OtpNet;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class SessionsControllerTests
{
private static IConfiguration BuildConfig() =>
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>()).Build();
private static SessionsController BuildController(JobTrackerContext db, ApplicationUser user, string? currentSid = null)
{
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, user.Id) };
if (currentSid is not null) claims.Add(new Claim("sid", currentSid));
return new SessionsController(userManager.Object, db)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(claims, "local")) }
}
};
}
private static UserSession NewSession(string id, string userId, DateTimeOffset? expiresAtUtc = null, DateTimeOffset? revokedAtUtc = null, DateTimeOffset? lastSeenAtUtc = null)
{
var now = DateTimeOffset.UtcNow;
return new UserSession
{
Id = id,
UserId = userId,
DeviceLabel = "Chrome on Windows",
CreatedAtUtc = now,
LastSeenAtUtc = lastSeenAtUtc ?? now,
ExpiresAtUtc = expiresAtUtc ?? now.AddHours(12),
RevokedAtUtc = revokedAtUtc,
};
}
// --- Session creation on every sign-in path -----------------------------------------------
[Fact]
public async Task Login_creates_a_user_session_row_and_threads_its_id_into_the_token()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user);
userManager.Setup(x => x.FindByNameAsync("person@example.com")).ReturnsAsync((ApplicationUser?)null);
userManager.Setup(x => x.IsLockedOutAsync(user)).ReturnsAsync(false);
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success);
string? sessionIdPassedToToken = null;
var tokenService = new Mock<ITokenService>();
tokenService
.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Callback<ApplicationUser, string?, CancellationToken>((_, sid, _) => sessionIdPassedToToken = sid)
.ReturnsAsync("app-token");
using var db = TestHostFactory.CreateInMemoryDb(null);
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), db)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);
var sessions = await db.UserSessions.IgnoreQueryFilters().Where(x => x.UserId == "user-1").ToListAsync();
var created = Assert.Single(sessions);
Assert.False(string.IsNullOrWhiteSpace(created.Id));
Assert.Equal(created.Id, sessionIdPassedToToken);
Assert.Null(created.RevokedAtUtc);
Assert.True(created.ExpiresAtUtc > DateTimeOffset.UtcNow);
}
[Fact]
public async Task Register_creates_a_user_session_row_when_it_completes_sign_in()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:AllowRegistration"] = "true" })
.Build();
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.FindByEmailAsync("new.user@example.com")).ReturnsAsync((ApplicationUser?)null);
ApplicationUser? created = null;
userManager
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), "password123"))
.Callback<ApplicationUser, string>((u, _) => { u.Id = "new-user-1"; created = u; })
.ReturnsAsync(IdentityResult.Success);
var tokenService = new Mock<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
using var db = TestHostFactory.CreateInMemoryDb(null);
var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), db)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
await controller.Register(new AuthController.RegisterRequest("new.user@example.com", "password123"), CancellationToken.None);
Assert.NotNull(created);
var sessions = await db.UserSessions.IgnoreQueryFilters().Where(x => x.UserId == created!.Id).ToListAsync();
Assert.Single(sessions);
}
[Fact]
public async Task TwoFactor_challenge_creates_a_user_session_row_on_completion()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user);
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var pending = new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
var protector = new EphemeralDataProtectionProvider();
var tokenService = new Mock<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = new TwoFactorController(userManager.Object, tokenService.Object, db, pending, protector, BuildConfig())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var setupResult = Assert.IsType<OkObjectResult>(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None));
var setup = Assert.IsType<TwoFactorController.SetupResult>(setupResult.Value);
var setupCode = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(setupCode), CancellationToken.None);
var pendingToken = pending.IssuePendingToken("user-1", rememberMe: false);
var challengeCode = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
Assert.IsType<OkObjectResult>(await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken, challengeCode), CancellationToken.None));
var sessions = await db.UserSessions.IgnoreQueryFilters().Where(x => x.UserId == "user-1").ToListAsync();
Assert.Single(sessions);
}
// --- List/revoke ----------------------------------------------------------------------------
[Fact]
public async Task List_returns_only_the_callers_own_active_sessions()
{
using var db = TestHostFactory.CreateInMemoryDb("user-1");
db.UserSessions.Add(NewSession("sid-mine", "user-1"));
db.UserSessions.Add(NewSession("sid-other-user", "user-2"));
db.UserSessions.Add(NewSession("sid-mine-expired", "user-1", expiresAtUtc: DateTimeOffset.UtcNow.AddHours(-1)));
db.UserSessions.Add(NewSession("sid-mine-revoked", "user-1", revokedAtUtc: DateTimeOffset.UtcNow.AddMinutes(-1)));
await db.SaveChangesAsync();
var controller = BuildController(db, new ApplicationUser { Id = "user-1" }, currentSid: "sid-mine");
var ok = Assert.IsType<OkObjectResult>(await controller.List(CancellationToken.None));
var list = Assert.IsType<List<SessionsController.SessionDto>>(ok.Value);
var only = Assert.Single(list);
Assert.Equal("sid-mine", only.Id);
Assert.True(only.IsCurrentSession);
}
[Fact]
public async Task Revoke_enforces_ownership_and_blocks_a_subsequent_request_using_that_sessions_token()
{
using var db = TestHostFactory.CreateInMemoryDb("user-1");
db.UserSessions.Add(NewSession("sid-mine", "user-1"));
db.UserSessions.Add(NewSession("sid-not-mine", "user-2"));
await db.SaveChangesAsync();
var controller = BuildController(db, new ApplicationUser { Id = "user-1" }, currentSid: "sid-mine");
// Can't revoke someone else's session.
var forbidden = await controller.Revoke("sid-not-mine", CancellationToken.None);
Assert.IsType<NotFoundResult>(forbidden);
Assert.True(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-not-mine"), DateTimeOffset.UtcNow));
// Revoking your own session actually blocks it going forward.
Assert.True(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-mine"), DateTimeOffset.UtcNow));
var ownResult = await controller.Revoke("sid-mine", CancellationToken.None);
Assert.IsType<NoContentResult>(ownResult);
Assert.False(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-mine"), DateTimeOffset.UtcNow));
}
[Fact]
public async Task RevokeOthers_revokes_every_other_session_but_leaves_the_current_one_usable()
{
using var db = TestHostFactory.CreateInMemoryDb("user-1");
db.UserSessions.Add(NewSession("sid-current", "user-1"));
db.UserSessions.Add(NewSession("sid-other-device", "user-1"));
db.UserSessions.Add(NewSession("sid-not-mine", "user-2"));
await db.SaveChangesAsync();
var controller = BuildController(db, new ApplicationUser { Id = "user-1" }, currentSid: "sid-current");
Assert.IsType<NoContentResult>(await controller.RevokeOthers(CancellationToken.None));
Assert.True(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-current"), DateTimeOffset.UtcNow));
Assert.False(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-other-device"), DateTimeOffset.UtcNow));
// Untouched: revoke-others must never reach across users.
Assert.True(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-not-mine"), DateTimeOffset.UtcNow));
}
[Fact]
public async Task LocalSessionValidator_rejects_an_expired_session()
{
using var db = TestHostFactory.CreateInMemoryDb("user-1");
db.UserSessions.Add(NewSession("sid-expired", "user-1", expiresAtUtc: DateTimeOffset.UtcNow.AddSeconds(-1)));
await db.SaveChangesAsync();
Assert.False(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-expired"), DateTimeOffset.UtcNow));
}
[Fact]
public async Task LocalSessionValidator_rejects_a_token_with_no_sid_claim()
{
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "user-1") }, "local"));
Assert.False(await LocalSessionValidator.IsValidAsync(db, principal, DateTimeOffset.UtcNow));
}
private static ClaimsPrincipal PrincipalWithSid(string sid) =>
new(new ClaimsIdentity(new[] { new Claim("sid", sid) }, "local"));
}
@@ -0,0 +1,280 @@
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Moq;
using OtpNet;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class TwoFactorControllerTests
{
private static TwoFactorController BuildController(Mock<UserManager<ApplicationUser>> userManager, JobTrackerApi.Data.JobTrackerContext db, ITwoFactorPendingTokenService? pending = null, ApplicationUser? currentUser = null)
{
if (currentUser is not null)
{
userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(currentUser);
}
var tokenService = new Mock<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
var controller = new TwoFactorController(
userManager.Object,
tokenService.Object,
db,
pending ?? new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions())),
new EphemeralDataProtectionProvider(),
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>()).Build())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
return controller;
}
[Fact]
public async Task Setup_then_verify_with_correct_code_enables_2fa_and_returns_recovery_codes()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, currentUser: user);
var setupResult = Assert.IsType<OkObjectResult>(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None));
var setup = Assert.IsType<TwoFactorController.SetupResult>(setupResult.Value);
Assert.NotNull(user.TotpPendingSecretEncrypted);
Assert.False(user.TwoFactorEnabled);
var code = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
var verifyResult = Assert.IsType<OkObjectResult>(await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(code), CancellationToken.None));
var verify = Assert.IsType<TwoFactorController.VerifySetupResult>(verifyResult.Value);
Assert.True(verify.Enabled);
Assert.Equal(10, verify.RecoveryCodes.Count);
Assert.True(user.TwoFactorEnabled);
Assert.Null(user.TotpPendingSecretEncrypted);
Assert.NotNull(user.TotpSecretEncrypted);
Assert.NotNull(user.TotpEnabledAtUtc);
}
[Fact]
public async Task Verify_setup_with_wrong_code_is_rejected_and_does_not_enable_2fa()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, currentUser: user);
await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None);
var result = await controller.VerifySetup(new TwoFactorController.VerifySetupRequest("000000"), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(result);
Assert.False(user.TwoFactorEnabled);
}
[Fact]
public async Task Disable_with_wrong_password_is_rejected()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "irrelevant" };
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.CheckPasswordAsync(user, "wrong-password")).ReturnsAsync(false);
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, currentUser: user);
var result = await controller.Disable(new TwoFactorController.PasswordConfirmRequest("wrong-password"), CancellationToken.None);
Assert.IsType<BadRequestObjectResult>(result);
Assert.True(user.TwoFactorEnabled);
Assert.NotNull(user.TotpSecretEncrypted);
}
[Fact]
public async Task Challenge_with_valid_totp_code_completes_sign_in()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user);
var pending = new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, pending, currentUser: user);
var setupResult = Assert.IsType<OkObjectResult>(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None));
var setup = Assert.IsType<TwoFactorController.SetupResult>(setupResult.Value);
var code = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(code), CancellationToken.None);
var pendingToken = pending.IssuePendingToken("user-1", rememberMe: false);
var challengeCode = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
var challengeResult = Assert.IsType<OkObjectResult>(await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken, challengeCode), CancellationToken.None));
var session = Assert.IsType<AuthController.AuthSessionResult>(challengeResult.Value);
Assert.True(session.Authenticated);
// The pending token is single-use.
var reuse = await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken, challengeCode), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(reuse);
}
[Fact]
public async Task Challenge_with_recovery_code_consumes_it_and_rejects_reuse()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user);
var pending = new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, pending, currentUser: user);
var setupResult = Assert.IsType<OkObjectResult>(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None));
var setup = Assert.IsType<TwoFactorController.SetupResult>(setupResult.Value);
var code = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
var verifyResult = Assert.IsType<OkObjectResult>(await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(code), CancellationToken.None));
var verify = Assert.IsType<TwoFactorController.VerifySetupResult>(verifyResult.Value);
var recoveryCode = verify.RecoveryCodes[0];
var pendingToken1 = pending.IssuePendingToken("user-1", rememberMe: false);
var challengeResult = Assert.IsType<OkObjectResult>(await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken1, recoveryCode), CancellationToken.None));
Assert.True(Assert.IsType<AuthController.AuthSessionResult>(challengeResult.Value).Authenticated);
// Same recovery code can't be used a second time, even against a fresh pending token.
var pendingToken2 = pending.IssuePendingToken("user-1", rememberMe: false);
var reuse = await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken2, recoveryCode), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(reuse);
}
[Fact]
public async Task Challenge_with_trust_device_true_creates_trusted_device_and_sets_cookie()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user);
var pending = new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, pending, currentUser: user);
var setupResult = Assert.IsType<OkObjectResult>(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None));
var setup = Assert.IsType<TwoFactorController.SetupResult>(setupResult.Value);
var code = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(code), CancellationToken.None);
var pendingToken = pending.IssuePendingToken("user-1", rememberMe: false);
var challengeCode = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
var challengeResult = Assert.IsType<OkObjectResult>(await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken, challengeCode, TrustDevice: true), CancellationToken.None));
Assert.True(Assert.IsType<AuthController.AuthSessionResult>(challengeResult.Value).Authenticated);
var device = Assert.Single(db.TrustedDevices.IgnoreQueryFilters().Where(x => x.UserId == "user-1"));
Assert.NotNull(device.TokenHash);
var setCookieHeaders = controller.Response.Headers["Set-Cookie"];
var trustedDeviceCookie = Assert.Single(setCookieHeaders, h => h!.StartsWith($"{AuthSessionOptions.TrustedDeviceCookieName}=", StringComparison.Ordinal))!;
Assert.Contains("httponly", trustedDeviceCookie, StringComparison.OrdinalIgnoreCase);
Assert.Contains("samesite=strict", trustedDeviceCookie, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Challenge_without_trust_device_does_not_create_trusted_device()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user);
var pending = new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, pending, currentUser: user);
var setupResult = Assert.IsType<OkObjectResult>(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None));
var setup = Assert.IsType<TwoFactorController.SetupResult>(setupResult.Value);
var code = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(code), CancellationToken.None);
var pendingToken = pending.IssuePendingToken("user-1", rememberMe: false);
var challengeCode = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken, challengeCode), CancellationToken.None);
Assert.Empty(db.TrustedDevices.IgnoreQueryFilters().Where(x => x.UserId == "user-1"));
}
[Fact]
public async Task Trusted_devices_can_be_listed_revoked_and_revoked_in_bulk_scoped_to_owner()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var otherUser = new ApplicationUser { Id = "user-2", Email = "other@example.com", UserName = "other@example.com" };
var userManager = TestHostFactory.CreateUserManager();
using var db = TestHostFactory.CreateInMemoryDb("user-1");
db.TrustedDevices.Add(new TrustedDevice { UserId = "user-1", TokenHash = "hash-1", DeviceLabel = "Chrome on Windows", CreatedAtUtc = DateTimeOffset.UtcNow, LastSeenAtUtc = DateTimeOffset.UtcNow, ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(30) });
db.TrustedDevices.Add(new TrustedDevice { UserId = "user-1", TokenHash = "hash-2", DeviceLabel = "Safari on Mac", CreatedAtUtc = DateTimeOffset.UtcNow, LastSeenAtUtc = DateTimeOffset.UtcNow, ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(30) });
db.TrustedDevices.Add(new TrustedDevice { UserId = "user-2", TokenHash = "hash-3", DeviceLabel = "Someone else's device", CreatedAtUtc = DateTimeOffset.UtcNow, LastSeenAtUtc = DateTimeOffset.UtcNow, ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(30) });
db.SaveChanges();
var otherDeviceId = db.TrustedDevices.IgnoreQueryFilters().Single(x => x.UserId == "user-2").Id;
var controller = BuildController(userManager, db, currentUser: user);
var listResult = Assert.IsType<OkObjectResult>(await controller.ListTrustedDevices(CancellationToken.None));
var list = Assert.IsType<List<TwoFactorController.TrustedDeviceDto>>(listResult.Value);
Assert.Equal(2, list.Count);
// Can't revoke another user's device, even by guessing its id.
var revokeOther = await controller.RevokeTrustedDevice(otherDeviceId, CancellationToken.None);
Assert.IsType<NotFoundResult>(revokeOther);
Assert.NotNull(db.TrustedDevices.IgnoreQueryFilters().SingleOrDefault(x => x.Id == otherDeviceId));
var ownDeviceId = list[0].Id;
var revokeOwn = await controller.RevokeTrustedDevice(ownDeviceId, CancellationToken.None);
Assert.IsType<NoContentResult>(revokeOwn);
Assert.Null(db.TrustedDevices.IgnoreQueryFilters().SingleOrDefault(x => x.Id == ownDeviceId));
var revokeAll = await controller.RevokeAllTrustedDevices(CancellationToken.None);
Assert.IsType<NoContentResult>(revokeAll);
Assert.Empty(db.TrustedDevices.IgnoreQueryFilters().Where(x => x.UserId == "user-1"));
Assert.NotNull(db.TrustedDevices.IgnoreQueryFilters().SingleOrDefault(x => x.UserId == "user-2"));
}
[Fact]
public async Task Challenge_with_unknown_pending_token_is_rejected()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var userManager = TestHostFactory.CreateUserManager();
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, currentUser: user);
var result = await controller.Challenge(new TwoFactorController.ChallengeRequest("not-a-real-token", "123456"), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(result);
}
}
+153 -21
View File
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Security.Claims;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
@@ -21,8 +22,10 @@ public sealed class AuthController : ControllerBase
private readonly IGoogleTokenValidator _googleTokens;
private readonly IMicrosoftTokenValidator _microsoftTokens;
private readonly ILogger<AuthController> _logger;
private readonly ITwoFactorPendingTokenService _twoFactorPending;
private readonly JobTrackerContext _db;
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, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db)
{
_cfg = cfg;
_users = users;
@@ -31,6 +34,8 @@ public sealed class AuthController : ControllerBase
_googleTokens = googleTokens;
_microsoftTokens = microsoftTokens;
_logger = logger;
_twoFactorPending = twoFactorPending;
_db = db;
}
[HttpGet("config")]
@@ -41,6 +46,7 @@ public sealed class AuthController : ControllerBase
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 requireEmailVerification = _cfg.GetValue("Auth:RequireEmailVerification", false);
return Ok(new
{
@@ -49,12 +55,14 @@ public sealed class AuthController : ControllerBase
microsoftEnabled,
localEnabled = true,
allowRegistration,
requireEmailVerification,
});
}
public sealed record LoginRequest(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 TwoFactorRequiredResult(bool RequiresTwoFactor, string PendingToken);
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
public sealed record MicrosoftLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
public sealed record MeResult(
@@ -83,7 +91,7 @@ public sealed class AuthController : ControllerBase
[HttpPost("login")]
[AllowAnonymous]
[EnableRateLimiting("auth-login")]
public async Task<ActionResult<AuthSessionResult>> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
public async Task<IActionResult> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
{
var email = (request.Email ?? string.Empty).Trim();
var password = request.Password ?? string.Empty;
@@ -94,17 +102,34 @@ public sealed class AuthController : ControllerBase
var user = await _users.FindByEmailAsync(email) ?? await _users.FindByNameAsync(email);
if (user is null) return Unauthorized();
var ok = await _users.CheckPasswordAsync(user, password);
if (!ok) return Unauthorized();
// Same generic 401 whether the account doesn't exist, is locked out, or the password is
// wrong -- don't let a client distinguish "locked" from "wrong password" (enumeration).
if (await _users.IsLockedOutAsync(user)) return Unauthorized();
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
return Ok(new AuthSessionResult(true, "local"));
var ok = await _users.CheckPasswordAsync(user, password);
if (!ok)
{
await _users.AccessFailedAsync(user);
return Unauthorized();
}
await _users.ResetAccessFailedCountAsync(user);
// Same enumeration-avoidance discipline as the password-check branch above: this only
// runs once the password is already confirmed correct, so it can never be used to probe
// whether an email is registered.
if (_cfg.GetValue("Auth:RequireEmailVerification", false) && !user.EmailConfirmed)
{
return StatusCode(StatusCodes.Status403Forbidden, new { error = "email_not_verified" });
}
return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken);
}
[HttpPost("register")]
[AllowAnonymous]
[EnableRateLimiting("auth-login")]
public async Task<ActionResult<AuthSessionResult>> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken)
public async Task<IActionResult> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken)
{
var allow = _cfg.GetValue("Auth:AllowRegistration", false);
if (!allow) return StatusCode(403, "Registration is disabled.");
@@ -118,21 +143,35 @@ public sealed class AuthController : ControllerBase
var existing = await _users.FindByEmailAsync(email);
if (existing is not null) return BadRequest("User already exists.");
var user = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = true };
var requireEmailVerification = _cfg.GetValue("Auth:RequireEmailVerification", false);
var user = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = !requireEmailVerification };
var res = await _users.CreateAsync(user, password);
if (!res.Succeeded)
{
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
}
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
return Ok(new AuthSessionResult(true, "local"));
if (requireEmailVerification)
{
try
{
await SendVerificationEmailAsync(user, cancellationToken);
}
catch (Exception ex)
{
// ponytail: don't fail registration over a flaky email send -- the account is
// created either way, the user can request a fresh link via resend-verification-email.
_logger.LogError(ex, "Failed to send verification email to {Email}", user.Email);
}
}
return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken);
}
[HttpPost("google/exchange")]
[AllowAnonymous]
[EnableRateLimiting("auth-login")]
public async Task<ActionResult<AuthSessionResult>> ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
public async Task<IActionResult> ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
{
var token = (request.Token ?? string.Empty).Trim();
if (token.Length == 0) return BadRequest("Google token is required.");
@@ -193,14 +232,13 @@ public sealed class AuthController : ControllerBase
await _users.UpdateAsync(user);
}
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
return Ok(new AuthSessionResult(true, "google"));
return await CompleteSignInAsync(user, request.RememberMe, "google", cancellationToken);
}
[HttpPost("microsoft/exchange")]
[AllowAnonymous]
[EnableRateLimiting("auth-login")]
public async Task<ActionResult<AuthSessionResult>> ExchangeMicrosoftToken([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken)
public async Task<IActionResult> ExchangeMicrosoftToken([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken)
{
var token = (request.Token ?? string.Empty).Trim();
if (token.Length == 0) return BadRequest("Microsoft token is required.");
@@ -261,8 +299,7 @@ public sealed class AuthController : ControllerBase
await _users.UpdateAsync(user);
}
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
return Ok(new AuthSessionResult(true, "microsoft"));
return await CompleteSignInAsync(user, request.RememberMe, "microsoft", cancellationToken);
}
[HttpPost("logout")]
@@ -650,17 +687,112 @@ public sealed class AuthController : ControllerBase
return NoContent();
}
public sealed record VerifyEmailRequest(string UserId, string Token);
[HttpPost("verify-email")]
[AllowAnonymous]
[EnableRateLimiting("auth-email")]
public async Task<IActionResult> VerifyEmail([FromBody] VerifyEmailRequest request)
{
var userId = (request.UserId ?? string.Empty).Trim();
var token = request.Token ?? string.Empty;
if (userId.Length == 0) return BadRequest("UserId is required.");
if (token.Length == 0) return BadRequest("Token is required.");
var user = await _users.FindByIdAsync(userId);
if (user is null) return BadRequest("Invalid or expired link.");
var res = await _users.ConfirmEmailAsync(user, token);
if (!res.Succeeded)
{
return BadRequest("Invalid or expired link.");
}
return NoContent();
}
public sealed record ResendVerificationEmailRequest(string Email);
[HttpPost("resend-verification-email")]
[AllowAnonymous]
[EnableRateLimiting("auth-email")]
public async Task<IActionResult> ResendVerificationEmail([FromBody] ResendVerificationEmailRequest request, CancellationToken cancellationToken)
{
var email = (request.Email ?? string.Empty).Trim();
if (email.Length == 0) return NoContent();
// Mirrors request-password-reset's enumeration-avoidance: always NoContent, only actually
// send when there's a matching local account that still needs verifying.
var user = await _users.FindByEmailAsync(email);
if (user is null || user.EmailConfirmed || string.IsNullOrWhiteSpace(user.Email) || !await _users.HasPasswordAsync(user))
{
return NoContent();
}
try
{
await SendVerificationEmailAsync(user, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send verification email to {Email}", user.Email);
return EmailDeliveryUnavailable("Verification email could not be sent right now. Please try again later.");
}
return NoContent();
}
private async Task SendVerificationEmailAsync(ApplicationUser user, CancellationToken cancellationToken)
{
var token = await _users.GenerateEmailConfirmationTokenAsync(user);
var baseUrl = (_cfg["App:PublicBaseUrl"] ?? string.Empty).Trim().TrimEnd('/');
if (string.IsNullOrWhiteSpace(baseUrl))
{
baseUrl = $"{Request.Scheme}://{Request.Host}";
}
var link = $"{baseUrl}/verify-email?userId={Uri.EscapeDataString(user.Id)}&token={Uri.EscapeDataString(token)}";
await _email.SendAsync(
user.Email!,
"Verify your email",
$"Welcome to Jobbjakt! Please verify your email address to finish setting up your account.\n\nVerification link:\n{link}\n\nIf you did not create this account, you can ignore this email.",
cancellationToken
);
}
private IActionResult EmailDeliveryUnavailable(string detail)
{
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: detail);
}
private async Task SignInWithAppSessionAsync(ApplicationUser user, bool rememberMe, CancellationToken cancellationToken)
// Shared by local/Google/Microsoft sign-in. If the account has TOTP 2FA enabled, this does
// NOT issue the real session -- it hands back a short-lived opaque pending token that only
// POST /api/auth/2fa/challenge can redeem, after the caller proves they hold the TOTP device
// (or a recovery code). This is the gate that makes 2FA actually mandatory rather than
// decorative: skipping straight to AppSessionIssuer here would defeat the whole feature.
private async Task<IActionResult> CompleteSignInAsync(ApplicationUser user, bool rememberMe, string provider, CancellationToken cancellationToken)
{
var token = await _tokens.CreateAccessTokenAsync(user, cancellationToken);
var secure = Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
Response.Cookies.Append(AuthSessionOptions.SessionCookieName, token, AuthSessionOptions.BuildSessionCookie(rememberMe, secure));
EnsureCsrfCookie(rememberMe, secure);
// "Trust this device" cookie check happens BEFORE the 2FA gate: if it matches a
// non-expired row for this exact user, skip straight to a real session, same as if 2FA
// weren't required at all. Falls through to the normal gate for any other outcome
// (no cookie, wrong user, expired, revoked) -- never errors, just doesn't skip.
if (user.TwoFactorEnabled && await TrustedDeviceService.IsDeviceTrustedAsync(_db, Request, user.Id, cancellationToken))
{
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, cancellationToken);
return Ok(new AuthSessionResult(true, provider));
}
if (user.TwoFactorEnabled)
{
var pendingToken = _twoFactorPending.IssuePendingToken(user.Id, rememberMe);
return Ok(new TwoFactorRequiredResult(true, pendingToken));
}
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, cancellationToken);
return Ok(new AuthSessionResult(true, provider));
}
private void EnsureCsrfCookie(bool persistent, bool? secureOverride = null)
@@ -0,0 +1,104 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers;
// List/revoke the server-tracked UserSession rows behind the JWTs AppSessionIssuer hands out.
// Not 2FA-specific (any local-auth user has sessions, 2FA or not), hence its own small controller
// rather than folding into TwoFactorController.
[ApiController]
[Route("api/auth/sessions")]
[Authorize(AuthenticationSchemes = "local")]
public sealed class SessionsController : ControllerBase
{
private readonly UserManager<ApplicationUser> _users;
private readonly JobTrackerContext _db;
public SessionsController(UserManager<ApplicationUser> users, JobTrackerContext db)
{
_users = users;
_db = db;
}
public sealed record SessionDto(string Id, string? DeviceLabel, DateTimeOffset CreatedAtUtc, DateTimeOffset LastSeenAtUtc, DateTimeOffset ExpiresAtUtc, bool IsCurrentSession);
private string? CurrentSid => User.FindFirst("sid")?.Value;
[HttpGet]
public async Task<IActionResult> List(CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var now = DateTimeOffset.UtcNow;
var currentSid = CurrentSid;
// IgnoreQueryFilters + an explicit UserId filter, same convention as
// TrustedDeviceService/TwoFactorController's device-list queries.
// Equality-only in the DB query, then filter/sort DateTimeOffset client-side: SQLite's EF
// Core provider cannot translate ">" or ORDER BY over DateTimeOffset to SQL ("SQLite does
// not support expressions of type 'DateTimeOffset' in ORDER BY clauses"), so ExpiresAtUtc
// comparison and the LastSeenAtUtc sort have to happen after materializing the (small,
// per-user) row set.
var candidates = await _db.UserSessions.IgnoreQueryFilters()
.Where(x => x.UserId == user.Id && x.RevokedAtUtc == null)
.ToListAsync(cancellationToken);
var sessions = candidates
.Where(x => x.ExpiresAtUtc > now)
.OrderByDescending(x => x.LastSeenAtUtc)
.Select(x => new SessionDto(x.Id, x.DeviceLabel, x.CreatedAtUtc, x.LastSeenAtUtc, x.ExpiresAtUtc, x.Id == currentSid))
.ToList();
return Ok(sessions);
}
[HttpDelete("{id}")]
public async Task<IActionResult> Revoke(string id, CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var session = await _db.UserSessions.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id && x.UserId == user.Id, cancellationToken);
if (session is null) return NotFound();
session.RevokedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
if (string.Equals(id, CurrentSid, StringComparison.Ordinal))
{
var secure = Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(secure));
}
return NoContent();
}
[HttpPost("revoke-others")]
public async Task<IActionResult> RevokeOthers(CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var currentSid = CurrentSid;
var now = DateTimeOffset.UtcNow;
var others = await _db.UserSessions.IgnoreQueryFilters()
.Where(x => x.UserId == user.Id && x.RevokedAtUtc == null && x.Id != currentSid)
.ToListAsync(cancellationToken);
foreach (var session in others)
{
session.RevokedAtUtc = now;
}
if (others.Count > 0)
{
await _db.SaveChangesAsync(cancellationToken);
}
return NoContent();
}
}
@@ -0,0 +1,341 @@
using System.Security.Cryptography;
using System.Text;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using OtpNet;
using QRCoder;
namespace JobTrackerApi.Controllers;
// TOTP 2FA (RFC 6238) + recovery codes. Split out from AuthController (already 700+ lines)
// rather than growing it further; shares the session cookie logic via AppSessionIssuer and the
// pending-token handoff via ITwoFactorPendingTokenService.
[ApiController]
[Route("api/auth/2fa")]
public sealed class TwoFactorController : ControllerBase
{
private const int RecoveryCodeCount = 10;
private readonly UserManager<ApplicationUser> _users;
private readonly ITokenService _tokens;
private readonly JobTrackerContext _db;
private readonly ITwoFactorPendingTokenService _pending;
private readonly IDataProtector _protector;
private readonly IConfiguration _cfg;
public TwoFactorController(UserManager<ApplicationUser> users, ITokenService tokens, JobTrackerContext db, ITwoFactorPendingTokenService pending, IDataProtectionProvider protectionProvider, IConfiguration cfg)
{
_users = users;
_tokens = tokens;
_db = db;
_pending = pending;
_protector = protectionProvider.CreateProtector("totp-secret-v1");
_cfg = cfg;
}
public sealed record PasswordConfirmRequest(string CurrentPassword);
public sealed record SetupResult(string ManualEntryKey, string QrCodeDataUrl);
public sealed record VerifySetupRequest(string Code);
public sealed record VerifySetupResult(bool Enabled, IReadOnlyList<string> RecoveryCodes);
public sealed record StatusResult(bool Enabled, DateTimeOffset? EnabledAtUtc);
public sealed record RecoveryCodesResult(IReadOnlyList<string> RecoveryCodes);
public sealed record ChallengeRequest(string PendingToken, string Code, bool TrustDevice = false);
public sealed record TrustedDeviceDto(int Id, string? DeviceLabel, DateTimeOffset CreatedAtUtc, DateTimeOffset LastSeenAtUtc, DateTimeOffset ExpiresAtUtc, bool IsCurrentDevice);
[HttpPost("setup")]
[Authorize(AuthenticationSchemes = "local")]
[EnableRateLimiting("auth-login")]
public async Task<IActionResult> Setup([FromBody] PasswordConfirmRequest request, CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (!await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
{
return BadRequest("Current password is incorrect.");
}
var secretBytes = KeyGeneration.GenerateRandomKey(20);
var base32Secret = Base32Encoding.ToString(secretBytes);
user.TotpPendingSecretEncrypted = _protector.Protect(base32Secret);
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
var issuer = "JobTracker";
var label = Uri.EscapeDataString($"{issuer}:{user.Email}");
var otpauthUri = $"otpauth://totp/{label}?secret={base32Secret}&issuer={Uri.EscapeDataString(issuer)}&digits=6&period=30";
using var qrGenerator = new QRCodeGenerator();
using var qrData = qrGenerator.CreateQrCode(otpauthUri, QRCodeGenerator.ECCLevel.Q);
var pngQr = new PngByteQRCode(qrData);
var qrPngBytes = pngQr.GetGraphic(10);
var qrDataUrl = $"data:image/png;base64,{Convert.ToBase64String(qrPngBytes)}";
return Ok(new SetupResult(base32Secret, qrDataUrl));
}
[HttpPost("verify-setup")]
[Authorize(AuthenticationSchemes = "local")]
[EnableRateLimiting("auth-login")]
public async Task<IActionResult> VerifySetup([FromBody] VerifySetupRequest request, CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (string.IsNullOrWhiteSpace(user.TotpPendingSecretEncrypted))
{
return BadRequest("No pending 2FA setup. Call setup first.");
}
var base32Secret = _protector.Unprotect(user.TotpPendingSecretEncrypted);
if (!VerifyCode(base32Secret, request.Code))
{
return Unauthorized();
}
user.TotpSecretEncrypted = user.TotpPendingSecretEncrypted;
user.TotpPendingSecretEncrypted = null;
user.TwoFactorEnabled = true;
user.TotpEnabledAtUtc = DateTimeOffset.UtcNow;
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
var codes = await RegenerateRecoveryCodesAsync(user.Id, cancellationToken);
return Ok(new VerifySetupResult(true, codes));
}
[HttpPost("disable")]
[Authorize(AuthenticationSchemes = "local")]
[EnableRateLimiting("auth-login")]
public async Task<IActionResult> Disable([FromBody] PasswordConfirmRequest request, CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (!await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
{
return BadRequest("Current password is incorrect.");
}
user.TotpSecretEncrypted = null;
user.TotpPendingSecretEncrypted = null;
user.TwoFactorEnabled = false;
user.TotpEnabledAtUtc = null;
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
await RemoveAllRecoveryCodesAsync(user.Id, cancellationToken);
return NoContent();
}
[HttpGet("status")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> Status()
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
return Ok(new StatusResult(user.TwoFactorEnabled, user.TotpEnabledAtUtc));
}
[HttpPost("recovery-codes/regenerate")]
[Authorize(AuthenticationSchemes = "local")]
[EnableRateLimiting("auth-login")]
public async Task<IActionResult> RegenerateRecoveryCodes([FromBody] PasswordConfirmRequest request, CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (!user.TwoFactorEnabled)
{
return BadRequest("Two-factor authentication is not enabled.");
}
if (!await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
{
return BadRequest("Current password is incorrect.");
}
var codes = await RegenerateRecoveryCodesAsync(user.Id, cancellationToken);
return Ok(new RecoveryCodesResult(codes));
}
[HttpPost("challenge")]
[AllowAnonymous]
[EnableRateLimiting("auth-2fa-challenge")]
public async Task<IActionResult> Challenge([FromBody] ChallengeRequest request, CancellationToken cancellationToken)
{
var pendingToken = (request.PendingToken ?? string.Empty).Trim();
var code = (request.Code ?? string.Empty).Trim();
if (pendingToken.Length == 0 || code.Length == 0) return Unauthorized();
// Peek without consuming: only burn the pending token once the code actually checks out,
// so a mistyped code doesn't force the user back through password login.
var session = _pending.Resolve(pendingToken, consume: false);
if (session is null) return Unauthorized();
var user = await _users.FindByIdAsync(session.UserId);
if (user is null || !user.TwoFactorEnabled || string.IsNullOrWhiteSpace(user.TotpSecretEncrypted))
{
return Unauthorized();
}
var base32Secret = _protector.Unprotect(user.TotpSecretEncrypted);
var verified = VerifyCode(base32Secret, code) || await TryConsumeRecoveryCodeAsync(user.Id, code, cancellationToken);
if (!verified) return Unauthorized();
_pending.Resolve(pendingToken, consume: true);
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, session.RememberMe, cancellationToken);
if (request.TrustDevice)
{
await TrustedDeviceService.IssueAsync(_db, Request, Response, user.Id, cancellationToken);
}
return Ok(new AuthController.AuthSessionResult(true, "local"));
}
[HttpGet("trusted-devices")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> ListTrustedDevices(CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var currentHash = TrustedDeviceService.CurrentDeviceTokenHash(Request);
// SQLite/Pomelo cannot translate DateTimeOffset ORDER BY to SQL (same issue as the
// expiry check in TrustedDeviceService), so sort after materializing.
var devices = await _db.TrustedDevices
.Where(x => x.UserId == user.Id)
.Select(x => new TrustedDeviceDto(x.Id, x.DeviceLabel, x.CreatedAtUtc, x.LastSeenAtUtc, x.ExpiresAtUtc, currentHash != null && x.TokenHash == currentHash))
.ToListAsync(cancellationToken);
return Ok(devices.OrderByDescending(x => x.LastSeenAtUtc).ToList());
}
[HttpDelete("trusted-devices/{id:int}")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> RevokeTrustedDevice(int id, CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var device = await _db.TrustedDevices.FirstOrDefaultAsync(x => x.Id == id && x.UserId == user.Id, cancellationToken);
if (device is null) return NotFound();
var currentHash = TrustedDeviceService.CurrentDeviceTokenHash(Request);
var isCurrentDevice = currentHash != null && string.Equals(device.TokenHash, currentHash, StringComparison.Ordinal);
_db.TrustedDevices.Remove(device);
await _db.SaveChangesAsync(cancellationToken);
if (isCurrentDevice)
{
TrustedDeviceService.ClearCookie(Request, Response);
}
return NoContent();
}
[HttpPost("trusted-devices/revoke-all")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> RevokeAllTrustedDevices(CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var devices = await _db.TrustedDevices.Where(x => x.UserId == user.Id).ToListAsync(cancellationToken);
if (devices.Count > 0)
{
_db.TrustedDevices.RemoveRange(devices);
await _db.SaveChangesAsync(cancellationToken);
}
TrustedDeviceService.ClearCookie(Request, Response);
return NoContent();
}
private static bool VerifyCode(string base32Secret, string? code)
{
code = (code ?? string.Empty).Trim();
if (code.Length == 0) return false;
var totp = new Totp(Base32Encoding.ToBytes(base32Secret));
// +-1 step (30s) of drift, the RFC 6238 standard tolerance for clock skew between the
// authenticator app and the server.
return totp.VerifyTotp(code, out _, new VerificationWindow(1, 1));
}
private async Task<bool> TryConsumeRecoveryCodeAsync(string userId, string code, CancellationToken cancellationToken)
{
var hash = HashRecoveryCode(code);
var match = await _db.TwoFactorRecoveryCodes
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.UserId == userId && x.CodeHash == hash && x.UsedAtUtc == null, cancellationToken);
if (match is null) return false;
match.UsedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
return true;
}
private async Task<IReadOnlyList<string>> RegenerateRecoveryCodesAsync(string userId, CancellationToken cancellationToken)
{
await RemoveAllRecoveryCodesAsync(userId, cancellationToken);
var now = DateTimeOffset.UtcNow;
var plainCodes = new List<string>(RecoveryCodeCount);
var rows = new List<TwoFactorRecoveryCode>(RecoveryCodeCount);
for (var i = 0; i < RecoveryCodeCount; i++)
{
var plain = GenerateRecoveryCode();
plainCodes.Add(plain);
rows.Add(new TwoFactorRecoveryCode { UserId = userId, CodeHash = HashRecoveryCode(plain), CreatedAtUtc = now });
}
_db.TwoFactorRecoveryCodes.AddRange(rows);
await _db.SaveChangesAsync(cancellationToken);
return plainCodes;
}
private async Task RemoveAllRecoveryCodesAsync(string userId, CancellationToken cancellationToken)
{
var existing = await _db.TwoFactorRecoveryCodes.IgnoreQueryFilters().Where(x => x.UserId == userId).ToListAsync(cancellationToken);
if (existing.Count == 0) return;
_db.TwoFactorRecoveryCodes.RemoveRange(existing);
await _db.SaveChangesAsync(cancellationToken);
}
private static string GenerateRecoveryCode()
{
var hex = Convert.ToHexString(RandomNumberGenerator.GetBytes(5)).ToLowerInvariant(); // 10 hex chars, 40 bits
return $"{hex[..5]}-{hex[5..]}";
}
// ponytail: recovery codes are already random high-entropy tokens (not user-chosen
// passwords), so a plain SHA-256 hash is sufficient -- no per-code salt or PBKDF2 needed.
private static string HashRecoveryCode(string code)
{
var normalized = code.Trim().ToLowerInvariant();
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))).ToLowerInvariant();
}
}
+35 -5
View File
@@ -183,12 +183,16 @@ builder.Services.AddIdentityCore<ApplicationUser>(options =>
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.AllowedForNewUsers = true;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<JobTrackerContext>()
.AddSignInManager();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddSingleton<ITwoFactorPendingTokenService, TwoFactorPendingTokenService>();
builder.Services.AddSingleton<UniversalJobParser>();
builder.Services.AddSingleton<IHostAddressResolver, DnsHostAddressResolver>();
@@ -282,16 +286,29 @@ builder.Services.AddAuthentication(options =>
return Task.CompletedTask;
},
OnTokenValidated = context =>
OnTokenValidated = async context =>
{
var userId = LocalAuthIdentity.GetRequiredUserId(context.Principal);
if (userId is not null)
if (userId is null)
{
return Task.CompletedTask;
context.Fail("Local tokens must include a subject/nameidentifier claim.");
return;
}
context.Fail("Local tokens must include a subject/nameidentifier claim.");
return Task.CompletedTask;
// Resolve a fresh scoped JobTrackerContext for this one lookup -- OnTokenValidated
// runs outside the request's normal DI-constructor scope, so RequestServices (the
// per-request scope) must be used directly rather than a captured/singleton one.
// Fail closed if the session row is missing/revoked/expired (including tokens
// with no "sid" claim at all -- see LocalSessionValidator for why: every JWT
// issued going forward carries one, so a token without it is either pre-deploy
// (forces a single re-login for anyone already signed in when this ships --
// acceptable, same additive-forward cost the 2FA/trusted-device features on this
// branch already paid) or forged, and either way isn't proof of a live session.
var db = context.HttpContext.RequestServices.GetRequiredService<JobTrackerContext>();
if (!await LocalSessionValidator.IsValidAsync(db, context.Principal, DateTimeOffset.UtcNow))
{
context.Fail("Session has been revoked or expired.");
}
}
};
options.TokenValidationParameters = new TokenValidationParameters
@@ -378,6 +395,19 @@ builder.Services.AddRateLimiter(options =>
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0,
}));
// Brute-forcing a 6-digit TOTP code (1e6 space) is far more feasible than a password, so
// this gets a tighter window than auth-login.
options.AddPolicy("auth-2fa-challenge", context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"2fa:{context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(5),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0,
}));
});
var app = builder.Build();
@@ -0,0 +1,41 @@
using System.Security.Cryptography;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
namespace JobTrackerApi.Services;
// Shared by AuthController (local/Google/Microsoft sign-in) and TwoFactorController (the
// post-challenge sign-in) so the httpOnly session cookie + readable CSRF cookie are always
// issued the same way, from one place. Also the single place a UserSession row is created, so
// every JWT this app ever hands out has a matching server-side row Program.cs can revoke.
public static class AppSessionIssuer
{
public static async Task IssueAsync(HttpRequest request, HttpResponse response, ITokenService tokens, JobTrackerContext db, IConfiguration cfg, ApplicationUser user, bool rememberMe, CancellationToken cancellationToken)
{
var minutes = cfg.GetValue("Auth:JwtExpiresMinutes", 60 * 12);
if (minutes < 5) minutes = 5;
if (minutes > 60 * 24 * 30) minutes = 60 * 24 * 30;
var now = DateTimeOffset.UtcNow;
var session = new UserSession
{
Id = Guid.NewGuid().ToString("N"),
UserId = user.Id,
DeviceLabel = TrustedDeviceService.DescribeUserAgent(request.Headers["User-Agent"].ToString()),
CreatedAtUtc = now,
LastSeenAtUtc = now,
ExpiresAtUtc = now.AddMinutes(minutes),
};
db.UserSessions.Add(session);
await db.SaveChangesAsync(cancellationToken);
var token = await tokens.CreateAccessTokenAsync(user, session.Id, cancellationToken);
var secure = request.IsHttps || string.Equals(request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
response.Cookies.Append(AuthSessionOptions.SessionCookieName, token, AuthSessionOptions.BuildSessionCookie(rememberMe, secure));
var csrf = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
response.Cookies.Append(AuthSessionOptions.CsrfCookieName, csrf, AuthSessionOptions.BuildCsrfCookie(rememberMe, secure));
}
}
@@ -7,6 +7,7 @@ public static class AuthSessionOptions
public const string SessionCookieName = "jobtracker_auth";
public const string CsrfCookieName = "XSRF-TOKEN";
public const string CsrfHeaderName = "X-CSRF-TOKEN";
public const string TrustedDeviceCookieName = "jobtracker_td";
public static CookieOptions BuildSessionCookie(bool persistent, bool secure)
{
@@ -75,4 +76,35 @@ public static class AuthSessionOptions
MaxAge = TimeSpan.Zero,
};
}
// Stricter than the session cookie (SameSite=Strict, never HttpOnly=false): this cookie's
// only job is "skip the 2FA prompt", so it must never be readable by JS and should not even
// be sent on cross-site navigations.
public static CookieOptions BuildTrustedDeviceCookie(bool secure)
{
return new CookieOptions
{
HttpOnly = true,
IsEssential = true,
SameSite = SameSiteMode.Strict,
Secure = secure,
Path = "/",
Expires = DateTimeOffset.UtcNow.AddDays(30),
MaxAge = TimeSpan.FromDays(30),
};
}
public static CookieOptions BuildExpiredTrustedDeviceCookie(bool secure)
{
return new CookieOptions
{
HttpOnly = true,
IsEssential = true,
SameSite = SameSiteMode.Strict,
Secure = secure,
Path = "/",
Expires = DateTimeOffset.UnixEpoch,
MaxAge = TimeSpan.Zero,
};
}
}
@@ -0,0 +1,30 @@
using System.Security.Claims;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
// The actual revocation check behind Program.cs's "local" JwtBearer OnTokenValidated. Pulled out
// of Program.cs so it's unit-testable without standing up a full TestServer/HTTP pipeline.
public static class LocalSessionValidator
{
public static async Task<bool> IsValidAsync(JobTrackerContext db, ClaimsPrincipal? principal, DateTimeOffset now, CancellationToken cancellationToken = default)
{
var sid = principal?.FindFirst("sid")?.Value;
// Fail closed: see the comment on the OnTokenValidated wiring in Program.cs for why a
// missing sid is rejected rather than grandfathered in.
if (string.IsNullOrWhiteSpace(sid)) return false;
var session = await db.UserSessions.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.Id == sid, cancellationToken);
if (session is null || session.RevokedAtUtc is not null || session.ExpiresAtUtc <= now) return false;
if (now - session.LastSeenAtUtc > TimeSpan.FromMinutes(5))
{
session.LastSeenAtUtc = now;
await db.SaveChangesAsync(cancellationToken);
}
return true;
}
}
@@ -244,6 +244,9 @@ public static class StartupInitializationExtensions
`MicrosoftSubject` longtext NULL,
`MicrosoftEmail` longtext NULL,
`MicrosoftLinkedAt` datetime(6) NULL,
`TotpSecretEncrypted` longtext NULL,
`TotpPendingSecretEncrypted` longtext NULL,
`TotpEnabledAtUtc` datetime(6) NULL,
PRIMARY KEY (`Id`)
) CHARACTER SET=utf8mb4;
@@ -359,7 +362,10 @@ public static class StartupInitializationExtensions
"GoogleLinkedAt" TEXT NULL,
"MicrosoftSubject" TEXT NULL,
"MicrosoftEmail" TEXT NULL,
"MicrosoftLinkedAt" TEXT NULL
"MicrosoftLinkedAt" TEXT NULL,
"TotpSecretEncrypted" TEXT NULL,
"TotpPendingSecretEncrypted" TEXT NULL,
"TotpEnabledAtUtc" TEXT NULL
);
""");
@@ -440,6 +446,9 @@ public static class StartupInitializationExtensions
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;");
EnsureColumn(conn, "AspNetUsers", "TotpSecretEncrypted", "ALTER TABLE AspNetUsers ADD COLUMN TotpSecretEncrypted TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "TotpPendingSecretEncrypted", "ALTER TABLE AspNetUsers ADD COLUMN TotpPendingSecretEncrypted TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "TotpEnabledAtUtc", "ALTER TABLE AspNetUsers ADD COLUMN TotpEnabledAtUtc TEXT NULL;");
static void EnsureUserRuleSettingsTable(DbConnection c)
{
@@ -623,10 +632,63 @@ public static class StartupInitializationExtensions
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");""");
}
static void EnsureTwoFactorRecoveryCodesTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "TwoFactorRecoveryCodes" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_TwoFactorRecoveryCodes" PRIMARY KEY AUTOINCREMENT,
"UserId" TEXT NOT NULL,
"CodeHash" TEXT NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"UsedAtUtc" TEXT NULL
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc" ON "TwoFactorRecoveryCodes" ("UserId", "UsedAtUtc");""");
}
static void EnsureTrustedDevicesTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "TrustedDevices" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_TrustedDevices" PRIMARY KEY AUTOINCREMENT,
"UserId" TEXT NOT NULL,
"TokenHash" TEXT NOT NULL,
"DeviceLabel" TEXT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"LastSeenAtUtc" TEXT NOT NULL,
"ExpiresAtUtc" TEXT NOT NULL
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TrustedDevices_UserId" ON "TrustedDevices" ("UserId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TrustedDevices_TokenHash" ON "TrustedDevices" ("TokenHash");""");
}
static void EnsureUserSessionsTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "UserSessions" (
"Id" TEXT NOT NULL CONSTRAINT "PK_UserSessions" PRIMARY KEY,
"UserId" TEXT NOT NULL,
"DeviceLabel" TEXT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"LastSeenAtUtc" TEXT NOT NULL,
"ExpiresAtUtc" TEXT NOT NULL,
"RevokedAtUtc" TEXT NULL
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_UserSessions_UserId" ON "UserSessions" ("UserId");""");
}
EnsureGmailConnectionsTable(conn);
EnsureMicrosoftGraphConnectionsTable(conn);
EnsureImapConnectionsTable(conn);
EnsureCvTables(conn);
EnsureTwoFactorRecoveryCodesTable(conn);
EnsureTrustedDevicesTable(conn);
EnsureUserSessionsTable(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
// and at least one of the new columns already exists.
@@ -769,6 +831,9 @@ public static class StartupInitializationExtensions
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;");
EnsureMySqlColumn(conn, "AspNetUsers", "TotpSecretEncrypted", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpSecretEncrypted` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "TotpPendingSecretEncrypted", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpPendingSecretEncrypted` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "TotpEnabledAtUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpEnabledAtUtc` datetime NULL;");
if (!HasMySqlTable(conn, "RuleSettings"))
{
@@ -977,6 +1042,84 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "TwoFactorRecoveryCodes"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TwoFactorRecoveryCodes` (
`Id` int NOT NULL AUTO_INCREMENT,
`UserId` varchar(255) NOT NULL,
`CodeHash` varchar(255) NOT NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`UsedAtUtc` datetime(6) NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "TwoFactorRecoveryCodes", "Id");
if (!MySqlIndexExists(conn, "TwoFactorRecoveryCodes", "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc` ON `TwoFactorRecoveryCodes` (`UserId`, `UsedAtUtc`);";
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "TrustedDevices"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TrustedDevices` (
`Id` int NOT NULL AUTO_INCREMENT,
`UserId` varchar(255) NOT NULL,
`TokenHash` varchar(255) NOT NULL,
`DeviceLabel` varchar(255) NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`LastSeenAtUtc` datetime(6) NOT NULL,
`ExpiresAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "TrustedDevices", "Id");
if (!MySqlIndexExists(conn, "TrustedDevices", "IX_TrustedDevices_UserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_TrustedDevices_UserId` ON `TrustedDevices` (`UserId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "TrustedDevices", "IX_TrustedDevices_TokenHash"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_TrustedDevices_TokenHash` ON `TrustedDevices` (`TokenHash`);";
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "UserSessions"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `UserSessions` (
`Id` varchar(64) NOT NULL,
`UserId` varchar(255) NOT NULL,
`DeviceLabel` varchar(255) NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`LastSeenAtUtc` datetime(6) NOT NULL,
`ExpiresAtUtc` datetime(6) NOT NULL,
`RevokedAtUtc` datetime(6) NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "UserSessions", "IX_UserSessions_UserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_UserSessions_UserId` ON `UserSessions` (`UserId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
+5 -2
View File
@@ -10,7 +10,7 @@ namespace JobTrackerApi.Services;
public interface ITokenService
{
Task<string> CreateAccessTokenAsync(ApplicationUser user, CancellationToken cancellationToken = default);
Task<string> CreateAccessTokenAsync(ApplicationUser user, string? sessionId = null, CancellationToken cancellationToken = default);
}
public sealed class TokenService : ITokenService
@@ -24,7 +24,7 @@ public sealed class TokenService : ITokenService
_users = users;
}
public async Task<string> CreateAccessTokenAsync(ApplicationUser user, CancellationToken cancellationToken = default)
public async Task<string> CreateAccessTokenAsync(ApplicationUser user, string? sessionId = null, CancellationToken cancellationToken = default)
{
var jwtKey = (_cfg["Auth:JwtKey"] ?? "").Trim();
if (string.IsNullOrWhiteSpace(jwtKey))
@@ -57,6 +57,9 @@ public sealed class TokenService : ITokenService
foreach (var r in roles)
claims.Add(new Claim(ClaimTypes.Role, r));
if (!string.IsNullOrWhiteSpace(sessionId))
claims.Add(new Claim("sid", sessionId));
var now = DateTime.UtcNow;
var token = new JwtSecurityToken(
@@ -0,0 +1,115 @@
using System.Security.Cryptography;
using System.Text;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
// "Trust this device for 30 days" -- lets a 2FA challenge be skipped on the same browser for a
// month. Shared by AuthController (checks the cookie before gating on 2FA) and
// TwoFactorController (issues/lists/revokes the cookie's backing row). Never stores the
// plaintext token, only its SHA-256 hash.
public static class TrustedDeviceService
{
private static readonly TimeSpan Lifetime = TimeSpan.FromDays(30);
// Only returns true (and thus skips 2FA) when the cookie's hash matches a non-expired row
// for THIS SPECIFIC user -- UserId is part of the DB query itself, not a check applied
// after the fact, so a trusted-device cookie minted for user A can never skip 2FA for user
// B even if somehow presented on their request.
public static async Task<bool> IsDeviceTrustedAsync(JobTrackerContext db, HttpRequest request, string userId, CancellationToken cancellationToken)
{
var token = request.Cookies[AuthSessionOptions.TrustedDeviceCookieName];
if (string.IsNullOrWhiteSpace(token)) return false;
var hash = HashToken(token);
var now = DateTimeOffset.UtcNow;
// SQLite/Pomelo cannot translate DateTimeOffset relational comparisons (>) to SQL, so the
// expiry check has to happen after materializing the row -- fine here since the equality
// filters (UserId, TokenHash) already narrow this to at most one row.
var match = await db.TrustedDevices
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.UserId == userId && x.TokenHash == hash, cancellationToken);
if (match is null || match.ExpiresAtUtc <= now) return false;
match.LastSeenAtUtc = now;
await db.SaveChangesAsync(cancellationToken);
return true;
}
public static async Task IssueAsync(JobTrackerContext db, HttpRequest request, HttpResponse response, string userId, CancellationToken cancellationToken)
{
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
var now = DateTimeOffset.UtcNow;
db.TrustedDevices.Add(new TrustedDevice
{
UserId = userId,
TokenHash = HashToken(token),
DeviceLabel = DescribeUserAgent(request.Headers["User-Agent"].ToString()),
CreatedAtUtc = now,
LastSeenAtUtc = now,
ExpiresAtUtc = now.Add(Lifetime),
});
await db.SaveChangesAsync(cancellationToken);
var secure = request.IsHttps || string.Equals(request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
response.Cookies.Append(AuthSessionOptions.TrustedDeviceCookieName, token, AuthSessionOptions.BuildTrustedDeviceCookie(secure));
}
public static void ClearCookie(HttpRequest request, HttpResponse response)
{
var secure = request.IsHttps || string.Equals(request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
response.Cookies.Delete(AuthSessionOptions.TrustedDeviceCookieName, AuthSessionOptions.BuildExpiredTrustedDeviceCookie(secure));
}
// Used to flag "this device" in the trusted-devices list without ever sending a token or
// hash to the client -- just a boolean per row.
public static string? CurrentDeviceTokenHash(HttpRequest request)
{
var token = request.Cookies[AuthSessionOptions.TrustedDeviceCookieName];
return string.IsNullOrWhiteSpace(token) ? null : HashToken(token);
}
public static string HashToken(string token)
{
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token.Trim()))).ToLowerInvariant();
}
private static readonly (string Needle, string Label)[] BrowserMarkers =
{
("Edg/", "Edge"),
("OPR/", "Opera"),
("Chrome/", "Chrome"),
("Firefox/", "Firefox"),
("Safari/", "Safari"),
};
private static readonly (string Needle, string Label)[] OsMarkers =
{
("Windows", "Windows"),
("Mac OS X", "Mac"),
("iPhone", "iOS"),
("iPad", "iOS"),
("Android", "Android"),
("Linux", "Linux"),
};
// ponytail: substring sniffing, not a real UA parser -- this only feeds a display label in
// a security-settings list ("Chrome on Windows"), nothing security-relevant depends on it.
// Public: also reused by AppSessionIssuer for UserSession device labels.
public static string? DescribeUserAgent(string? userAgent)
{
if (string.IsNullOrWhiteSpace(userAgent)) return null;
var browser = BrowserMarkers.FirstOrDefault(m => userAgent.Contains(m.Needle, StringComparison.Ordinal)).Label;
var os = OsMarkers.FirstOrDefault(m => userAgent.Contains(m.Needle, StringComparison.Ordinal)).Label;
if (browser is null && os is null) return userAgent.Length > 80 ? userAgent[..80] : userAgent;
if (browser is null) return os;
if (os is null) return browser;
return $"{browser} on {os}";
}
}
@@ -0,0 +1,47 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Caching.Memory;
namespace JobTrackerApi.Services;
public sealed record PendingTwoFactorSession(string UserId, bool RememberMe);
public interface ITwoFactorPendingTokenService
{
string IssuePendingToken(string userId, bool rememberMe);
PendingTwoFactorSession? Resolve(string pendingToken, bool consume);
}
// ponytail: server-side opaque token in IMemoryCache, deliberately NOT a JWT. A JWT signed
// with the app's normal signing key would be accepted by the "local" JWT bearer auth scheme
// for every other endpoint unless its issuer/audience/claims were carefully kept out of that
// scheme's validation -- an opaque cache-backed token can never be presented as a bearer
// token, so it structurally cannot grant a real session by itself. Single instance is fine:
// this is a short-lived (5 min), single-process dev/prod deployment, same as the rest of this
// app's in-memory state (rate limiter, IMemoryCache already registered in Program.cs).
public sealed class TwoFactorPendingTokenService : ITwoFactorPendingTokenService
{
private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(5);
private readonly IMemoryCache _cache;
public TwoFactorPendingTokenService(IMemoryCache cache)
{
_cache = cache;
}
public string IssuePendingToken(string userId, bool rememberMe)
{
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
_cache.Set(CacheKey(token), new PendingTwoFactorSession(userId, rememberMe), Ttl);
return token;
}
public PendingTwoFactorSession? Resolve(string pendingToken, bool consume)
{
var key = CacheKey(pendingToken);
if (!_cache.TryGetValue(key, out PendingTwoFactorSession? session)) return null;
if (consume) _cache.Remove(key);
return session;
}
private static string CacheKey(string token) => $"2fa-pending:{token}";
}
+3 -2
View File
@@ -19,14 +19,15 @@
},
"Auth": {
"Require": true,
"AllowRegistration": false,
"AllowRegistration": true,
"RequireEmailVerification": false,
"JwtKey": "CHANGE_ME_DEV_ONLY_LONG_RANDOM_SECRET",
"JwtIssuer": "JobTrackerApi",
"JwtAudience": "job-tracker-ui",
"JwtExpiresMinutes": 720,
"AdminEmail": "admin@example.com",
"AdminPassword": "CHANGE_ME_STRONG_DEV_PASSWORD",
"GoogleClientId": "CHANGE_ME_GOOGLE_CLIENT_ID",
"GoogleClientId": "723556162227-llqucvpog2esn1dutmtvuul1lv374or6.apps.googleusercontent.com",
"MicrosoftClientId": "CHANGE_ME_MICROSOFT_CLIENT_ID"
},
"App": {
@@ -27,6 +27,8 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.14" />
<PackageReference Include="Otp.NET" Version="1.4.1" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
<PackageReference Include="MailKit" Version="4.17.0" />
+3
View File
@@ -19,4 +19,7 @@ public sealed class ApplicationUser : IdentityUser
public string? MicrosoftSubject { get; set; }
public string? MicrosoftEmail { get; set; }
public DateTimeOffset? MicrosoftLinkedAt { get; set; }
public string? TotpSecretEncrypted { get; set; }
public string? TotpPendingSecretEncrypted { get; set; }
public DateTimeOffset? TotpEnabledAtUtc { get; set; }
}
+16
View File
@@ -0,0 +1,16 @@
namespace JobTrackerApi.Models;
// "Trust this device for 30 days" -- lets a browser skip the 2FA code step after one successful
// challenge. Never store the plaintext token, only its SHA-256 hash, same rationale as
// TwoFactorRecoveryCode.CodeHash: a DB read (backup, replica, leaked snapshot) can't be turned
// into a working cookie.
public sealed class TrustedDevice
{
public int Id { get; set; }
public string UserId { get; set; } = "";
public string TokenHash { get; set; } = "";
public string? DeviceLabel { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public DateTimeOffset LastSeenAtUtc { get; set; }
public DateTimeOffset ExpiresAtUtc { get; set; }
}
+12
View File
@@ -0,0 +1,12 @@
namespace JobTrackerApi.Models;
// One-time-use 2FA recovery codes. Plaintext is shown once at generation time and never
// persisted -- only the SHA-256 hash is stored so a DB read can't recover usable codes.
public sealed class TwoFactorRecoveryCode
{
public int Id { get; set; }
public string UserId { get; set; } = "";
public string CodeHash { get; set; } = "";
public DateTimeOffset CreatedAtUtc { get; set; }
public DateTimeOffset? UsedAtUtc { get; set; }
}
+17
View File
@@ -0,0 +1,17 @@
namespace JobTrackerApi.Models;
// Server-side record of a JWT issued via AppSessionIssuer. The JWT carries this row's Id as its
// "sid" claim; Program.cs's "local" JwtBearer OnTokenValidated looks the row up on every request
// so a session can actually be revoked before its JWT naturally expires (previously the JWT alone
// was the credential -- see AppSessionIssuer). Same shape/rationale as TrustedDevice, but this
// tracks the *session* itself rather than a "skip 2FA" cookie.
public sealed class UserSession
{
public string Id { get; set; } = "";
public string UserId { get; set; } = "";
public string? DeviceLabel { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public DateTimeOffset LastSeenAtUtc { get; set; }
public DateTimeOffset ExpiresAtUtc { get; set; }
public DateTimeOffset? RevokedAtUtc { get; set; }
}
+3 -1
View File
@@ -19,8 +19,9 @@ services:
- Auth__JwtKey=${AUTH_JWT_KEY}
- Auth__AdminEmail=${AUTH_ADMIN_EMAIL}
- Auth__AdminPassword=${AUTH_ADMIN_PASSWORD}
# Optional: allow Google ID-token bearer auth
# Optional: allow Google / Microsoft ID-token bearer auth (sign-in, not mail access)
- Auth__GoogleClientId=${AUTH_GOOGLE_CLIENT_ID}
- Auth__MicrosoftClientId=${AUTH_MICROSOFT_CLIENT_ID}
- Google__GmailClientSecret=${GOOGLE_GMAIL_CLIENT_SECRET}
- Google__GmailRedirectUri=${GOOGLE_GMAIL_REDIRECT_URI}
# Optional: Outlook / Microsoft 365 mail linking via Microsoft Graph
@@ -64,6 +65,7 @@ services:
shm_size: '1gb'
args:
- NEXT_PUBLIC_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
- NEXT_PUBLIC_MICROSOFT_CLIENT_ID=${AUTH_MICROSOFT_CLIENT_ID}
# Optional override; default in production is `/api`
- NEXT_PUBLIC_API_BASE_URL=${REACT_APP_API_BASE_URL}
ports:
+3 -1
View File
@@ -3,12 +3,14 @@ FROM node:20-alpine AS build
WORKDIR /app
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
ARG NEXT_PUBLIC_MICROSOFT_CLIENT_ID
ARG NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV NEXT_PUBLIC_MICROSOFT_CLIENT_ID=$NEXT_PUBLIC_MICROSOFT_CLIENT_ID
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
COPY package*.json ./
COPY package*.json .npmrc ./
RUN npm ci
COPY . .
+33 -14
View File
@@ -31,12 +31,13 @@ import LoginPage from "./views/LoginPage";
import LandingPage from "./views/LandingPage";
import ForgotPasswordPage from "./views/ForgotPasswordPage";
import ResetPasswordPage from "./views/ResetPasswordPage";
import VerifyEmailPage from "./views/VerifyEmailPage";
import RouteErrorPage from "./views/RouteErrorPage";
import { api } from "./api";
import { resolveCaptureUrl } from "./captureUrl";
import { clearAuthClientState, setAuthUserKey } from "./auth";
import AppShell, { NavItem } from "./layout/AppShell";
import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs";
import { getThemeModePref, setThemeModePref, ThemeModePref } from "./themePrefs";
const AddJobModal = lazy(() => import("./components/AddJobModal"));
const KanbanBoard = lazy(() => import("./components/KanbanBoard"));
@@ -100,11 +101,21 @@ function titleFor(path: string, t: (k: any) => string): string {
return t("appTitle");
}
function subtitleFor(path: string, t: (k: any) => string): string | undefined {
if (path === "/dashboard") return t("dashboardPageSubtitle");
if (path.startsWith("/jobs")) return t("jobsPageSubtitle");
if (path.startsWith("/kanban")) return t("kanbanPageSubtitle");
if (path.startsWith("/reminders")) return t("remindersPageSubtitle");
if (path.startsWith("/correspondence/review")) return t("gmailReviewPageSubtitle");
if (path.startsWith("/correspondence")) return t("correspondencePageSubtitle");
return undefined;
}
function PageLoader() {
return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>;
}
function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMode, onThemeModeChange, accentColor, onAccentColorChange, onResetAccentColor }: { jobPageSize: 15 | 20 | 25; setJobPageSize: (n: 15 | 20 | 25) => void; jobColumns: JobTableColumns; setJobColumns: (c: JobTableColumns) => void; themeMode: ThemeModePref; onThemeModeChange: (v: ThemeModePref) => void; accentColor: string; onAccentColorChange: (v: string) => void; onResetAccentColor: () => void; }) {
function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMode, onThemeModeChange }: { jobPageSize: 15 | 20 | 25; setJobPageSize: (n: 15 | 20 | 25) => void; jobColumns: JobTableColumns; setJobColumns: (c: JobTableColumns) => void; themeMode: ThemeModePref; onThemeModeChange: (v: ThemeModePref) => void; }) {
const location = useLocation();
const navigate = useNavigate();
const { t } = useI18n();
@@ -123,6 +134,9 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
const path = location.pathname;
const isJobs = path.startsWith("/jobs");
const shortcutHint = useMemo(() => (
typeof navigator !== "undefined" && /Mac|iPhone|iPod|iPad/.test(navigator.platform) ? "⌘K" : "Ctrl+K"
), []);
useEffect(() => {
api.get<AuthConfig>("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false));
@@ -206,6 +220,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
if (requireAuth && !me) return <Navigate to="/" replace state={{ from: path }} />;
const pageTitle = titleFor(path, t);
const pageSubtitle = subtitleFor(path, t);
const breadcrumbs = breadcrumbsFor(path, t);
const setAndPersistPageSize = (n: 15 | 20 | 25) => { setJobPageSize(n); window.localStorage.setItem("jobPageSize", String(n)); };
const setAndPersistColumns = (next: JobTableColumns) => { setJobColumns(next); window.localStorage.setItem("jobColumns", JSON.stringify(next)); };
@@ -246,14 +261,19 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<IconButton
color="secondary"
size="small"
title={t("quickSearch")}
title={`${t("quickSearch")} (${shortcutHint})`}
onClick={() => setQuickOpen(true)}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42, flex: "0 0 auto" }}
>
<SearchIcon fontSize="small" />
</IconButton>
) : (
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)}>{t("quickSearch")}</Button>
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)} sx={{ gap: 0.5 }}>
{t("quickSearch")}
<Box component="span" sx={{ ml: 0.75, px: 0.75, py: 0.125, borderRadius: 1, border: "1px solid", borderColor: "divider", fontSize: 11, fontWeight: 700, color: "text.secondary", lineHeight: 1.6 }}>
{shortcutHint}
</Box>
</Button>
)}
{isJobs ? (
<Button variant="contained" onClick={() => setAddOpen(true)} sx={{ flex: { xs: 1, sm: "0 0 auto" }, minHeight: 42 }}>
@@ -267,6 +287,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<>
<AppShell
pageTitle={pageTitle}
pageSubtitle={pageSubtitle}
breadcrumbs={breadcrumbs}
pathname={path}
nav={nav}
@@ -284,7 +305,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Navigate to="/jobs" replace />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardView />} />
<Route path="/jobs" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="jobs" />} />
<Route path="/reminders" element={<RemindersView />} />
@@ -297,7 +318,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/admin/system" element={<AdminSystemPage />} />
<Route path="/trash" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="trash" />} />
<Route path="/settings" element={<SettingsView pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} accentColor={accentColor} onAccentColorChange={onAccentColorChange} onResetAccentColor={onResetAccentColor} />} />
<Route path="/settings" element={<SettingsView pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
@@ -314,19 +335,16 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
export default function App() {
const systemPrefersDark = useMediaQuery("(prefers-color-scheme: dark)", { defaultMatches: true, noSsr: true });
const [themeMode, setThemeMode] = useState<ThemeModePref>(() => getThemeModePref());
const [accentColor, setAccentColorState] = useState<string>(() => getAccentColor());
const effectiveMode: "light" | "dark" = themeMode === "light" ? "light" : themeMode === "dark" ? "dark" : systemPrefersDark ? "dark" : "light";
const theme = useMemo(() => getTheme(effectiveMode, accentColor), [effectiveMode, accentColor]);
const theme = useMemo(() => getTheme(effectiveMode), [effectiveMode]);
useEffect(() => {
const sync = () => { setThemeMode(getThemeModePref()); setAccentColorState(getAccentColor()); };
const sync = () => { setThemeMode(getThemeModePref()); };
window.addEventListener("auth-changed", sync);
return () => window.removeEventListener("auth-changed", sync);
}, []);
const onThemeModeChange = (v: ThemeModePref) => { setThemeModePref(v); setThemeMode(v); };
const onAccentColorChange = (v: string) => { setAccentColor(v); setAccentColorState(getAccentColor()); };
const onResetAccentColor = () => { clearAccentColor(); setAccentColorState(getAccentColor()); };
const [jobPageSize, setJobPageSize] = useState<15 | 20 | 25>(() => {
const raw = window.localStorage.getItem("jobPageSize");
@@ -349,14 +367,15 @@ export default function App() {
{ path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> },
{ path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/reset-password", element: <ResetPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} accentColor={accentColor} onAccentColorChange={onAccentColorChange} onResetAccentColor={onResetAccentColor} />, errorElement: <RouteErrorPage /> },
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode, accentColor]);
{ path: "/verify-email", element: <VerifyEmailPage />, errorElement: <RouteErrorPage /> },
{ path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} />, errorElement: <RouteErrorPage /> },
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode]);
return (
<ToastProvider>
<ConfirmProvider>
<PromptProvider>
<CssVarsProvider key={`${effectiveMode}:${accentColor}`} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange>
<CssVarsProvider key={effectiveMode} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange>
<CssBaseline enableColorScheme />
<I18nProvider>
<RouterProvider router={router} future={{ v7_startTransition: true }} />
@@ -23,6 +23,7 @@ import AutoGraphIcon from "@mui/icons-material/AutoGraph";
import { api } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import OnboardingChecklist from "./OnboardingChecklist";
import { getUserKeyFromToken } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
import { statusLabel } from "../pipeline";
@@ -287,6 +288,7 @@ export default function DashboardView() {
return (
<Box>
<OnboardingChecklist hasJobs={(stats?.total ?? 0) > 0} />
<SectionCard
sx={{
backgroundColor: "background.paper",
@@ -4,6 +4,7 @@ import { Box, Button, Chip, Paper, Typography } from "@mui/material";
import { api, getApiErrorMessage } from "../api";
import { clearAuthClientState, getAuthPersistencePreference } from "../auth";
import TwoFactorChallenge from "./TwoFactorChallenge";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
@@ -51,6 +52,8 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
const { t } = useI18n();
const [me, setMe] = useState<MeResponse | null>(null);
const [working, setWorking] = useState(false);
const [allowRegistration, setAllowRegistration] = useState(false);
const [pendingToken, setPendingToken] = useState<string | null>(null);
const hostRef = useRef<HTMLDivElement | null>(null);
const clientId = (process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "").trim();
@@ -72,6 +75,9 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
useEffect(() => {
void refreshMe();
api.get<{ allowRegistration: boolean }>("/auth/config").then((res) => {
setAllowRegistration(Boolean(res.data?.allowRegistration));
}).catch(() => setAllowRegistration(false));
}, []);
useEffect(() => {
@@ -105,11 +111,15 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
toast(res.data?.email ? t("googleLinkedSuccessWithEmail", { email: res.data.email }) : t("googleLinkedSuccess"), "success");
await refreshMe();
} else {
await api.post("/auth/google/exchange", { token: credential, rememberMe: getAuthPersistencePreference() === "local" });
const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string }>("/auth/google/exchange", { token: credential, rememberMe: getAuthPersistencePreference() === "local" });
if (res.data?.requiresTwoFactor && res.data.pendingToken) {
setPendingToken(res.data.pendingToken);
} else {
window.dispatchEvent(new Event("auth-changed"));
toast(t("googleSignedIn"), "success");
onSignedIn?.();
}
}
} catch (e: any) {
toast(getApiErrorMessage(e, t("googleAuthFailed")), "error");
} finally {
@@ -147,7 +157,20 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
</Typography>
)}
{clientId && (
{clientId && pendingToken && (
<TwoFactorChallenge
pendingToken={pendingToken}
onCancel={() => setPendingToken(null)}
onSuccess={() => {
setPendingToken(null);
window.dispatchEvent(new Event("auth-changed"));
toast(t("googleSignedIn"), "success");
onSignedIn?.();
}}
/>
)}
{clientId && !pendingToken && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip size="small" label={me?.googleLink?.linked ? t("googleLinked") : t("googleAvailableToLink")} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
@@ -156,7 +179,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
{!signedIn ? (
<Typography sx={{ color: "text.secondary" }}>
{t("googleSignInHint")}
{allowRegistration ? t("googleSignInHintSelfServe") : t("googleSignInHint")}
</Typography>
) : me?.provider === "local" ? (
<Typography sx={{ color: "text.secondary" }}>
@@ -1132,6 +1132,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: -1 }}>{t("jobDetailsAiFitHint")}</Typography>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Box><Typography variant="overline">{t("jobDetailsHowYouMatch")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{candidateFit.matchSummary}</Typography></Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
@@ -1230,13 +1231,14 @@ function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading:
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", gap: 2.5, alignItems: "center", flexWrap: "wrap", mb: 1.5 }}>
{score.hasEnoughSignal ? (
<Box sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
<CircularProgress variant="determinate" value={100} size={92} thickness={4} sx={{ color: "divider", position: "absolute" }} />
<Box role="img" aria-label={`${t("matchScoreTitle")}: ${score.score}%`} sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
<CircularProgress variant="determinate" value={100} size={92} thickness={4} aria-hidden="true" sx={{ color: "divider", position: "absolute" }} />
<CircularProgress
variant="determinate"
value={score.score}
size={92}
thickness={4}
aria-hidden="true"
color={color === "inherit" ? "primary" : color}
sx={{ position: "absolute", "& .MuiCircularProgress-circle": { strokeLinecap: "round" } }}
/>
+25 -2
View File
@@ -110,6 +110,19 @@ function parseTags(raw?: string | null): string[] {
}
function EmptyJobsState({ firstTime, onOpenSettings, t }: { firstTime: boolean; onOpenSettings: () => void; t: (key: any) => string }) {
if (!firstTime) {
return <Typography sx={{ py: 2, textAlign: "center", color: "text.secondary" }}>{t("jobTableNoJobsFound")}</Typography>;
}
return (
<Box sx={{ py: 4, textAlign: "center" }}>
<Typography sx={{ fontWeight: 800, mb: 0.5 }}>{t("jobTableEmptyFirstTimeTitle")}</Typography>
<Typography sx={{ color: "text.secondary", mb: 1.5, maxWidth: 440, mx: "auto" }}>{t("jobTableEmptyFirstTimeBody")}</Typography>
<Button variant="text" onClick={onOpenSettings}>{t("jobTableEmptyFirstTimeBookmarklet")}</Button>
</Box>
);
}
function generateOverview(job: JobApplication): string {
if (job.fullSummary) return job.fullSummary;
if (job.shortSummary) return job.shortSummary;
@@ -220,6 +233,12 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
return jobs.filter((job) => needsWorkflowWork(job));
}, [jobs, readinessFilter]);
// Distinguishes "you have zero jobs, period" from "no results match your filters" so the
// empty state can actually help a first-time user instead of just saying "nothing here".
const noFiltersActive = !debouncedSearch.trim() && statusFilter === "All" && companyFilterId === "All"
&& !debouncedLocation.trim() && !needsFollowUpOnly && readinessFilter === "all";
const isFirstTimeEmpty = mode === "jobs" && total === 0 && noFiltersActive;
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const selectedAllOnPage = filteredJobs.length > 0 && filteredJobs.every((job) => selectedIdSet.has(job.id));
@@ -629,7 +648,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Paper>
);
})}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography> : null}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? (
<EmptyJobsState firstTime={isFirstTimeEmpty} onOpenSettings={() => navigate("/settings")} t={t} />
) : null}
</Stack>
) : (
<Box sx={{ overflowX: "auto" }}>
@@ -721,7 +742,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</React.Fragment>
);
})}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></TableCell></TableRow> : null}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? (
<TableRow><TableCell colSpan={visibleDesktopColumns}><EmptyJobsState firstTime={isFirstTimeEmpty} onOpenSettings={() => navigate("/settings")} t={t} /></TableCell></TableRow>
) : null}
</TableBody>
</Table>
</Box>
+14 -1
View File
@@ -101,7 +101,18 @@ export default function KanbanBoard() {
/>
{!jobsResource.loading && !jobsResource.error ? (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" }, gap: 2, alignItems: "start" }}>
<Box
sx={{
display: { xs: "flex", md: "grid" },
gridTemplateColumns: { md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" },
gap: 2,
alignItems: "start",
overflowX: { xs: "auto", md: "visible" },
scrollSnapType: { xs: "x mandatory", md: "none" },
pb: { xs: 1, md: 0 },
"-webkit-overflow-scrolling": "touch",
}}
>
{STATUSES.map((status) => {
const c = toneColor(theme, status);
const list = groups.get(status) ?? [];
@@ -114,6 +125,8 @@ export default function KanbanBoard() {
p: 1.5,
borderRadius: 3,
minHeight: 220,
flex: { xs: "0 0 85vw", md: "none" },
scrollSnapAlign: { xs: "start", md: "none" },
border: "1px solid",
borderColor: "divider",
background: theme.palette.mode === "dark" ? alpha(theme.palette.common.white, 0.02) : alpha(theme.palette.text.primary, 0.015),
@@ -5,6 +5,7 @@ import { PublicClientApplication } from "@azure/msal-browser";
import { api, getApiErrorMessage } from "../api";
import { clearAuthClientState, getAuthPersistencePreference } from "../auth";
import TwoFactorChallenge from "./TwoFactorChallenge";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
@@ -35,6 +36,7 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v
const { t } = useI18n();
const [me, setMe] = useState<MeResponse | null>(null);
const [working, setWorking] = useState(false);
const [pendingToken, setPendingToken] = useState<string | null>(null);
const clientId = (process.env.REACT_APP_MICROSOFT_CLIENT_ID || "").trim();
const signedIn = Boolean(me?.provider);
@@ -78,11 +80,15 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v
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" });
const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string }>("/auth/microsoft/exchange", { token: idToken, rememberMe: getAuthPersistencePreference() === "local" });
if (res.data?.requiresTwoFactor && res.data.pendingToken) {
setPendingToken(res.data.pendingToken);
} else {
window.dispatchEvent(new Event("auth-changed"));
toast(t("microsoftSignedIn"), "success");
onSignedIn?.();
}
}
} catch (e: any) {
toast(getApiErrorMessage(e, t("microsoftAuthFailed")), "error");
} finally {
@@ -104,7 +110,20 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v
</Typography>
)}
{clientId && (
{clientId && pendingToken && (
<TwoFactorChallenge
pendingToken={pendingToken}
onCancel={() => setPendingToken(null)}
onSuccess={() => {
setPendingToken(null);
window.dispatchEvent(new Event("auth-changed"));
toast(t("microsoftSignedIn"), "success");
onSignedIn?.();
}}
/>
)}
{clientId && !pendingToken && (
<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"} />
@@ -0,0 +1,81 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Button, IconButton, Paper, Stack, Typography } from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked";
import CloseIcon from "@mui/icons-material/Close";
import { alpha, useTheme } from "@mui/material/styles";
import { api } from "../api";
import { getUserKeyFromToken } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
function dismissKey() {
return `onboardingChecklistDismissed:${getUserKeyFromToken()}`;
}
type MeResponse = { profileCvText?: string | null };
export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
const theme = useTheme();
const navigate = useNavigate();
const { t } = useI18n();
const [hasCv, setHasCv] = useState<boolean | null>(null);
const [dismissed, setDismissed] = useState(() => window.localStorage.getItem(dismissKey()) === "1");
useEffect(() => {
let active = true;
api.get<MeResponse>("/auth/me")
.then((r) => { if (active) setHasCv(Boolean(r.data?.profileCvText?.trim())); })
.catch(() => { if (active) setHasCv(false); });
return () => { active = false; };
}, []);
const allDone = hasCv === true && hasJobs;
if (dismissed || allDone || hasCv === null) return null;
const dismiss = () => {
window.localStorage.setItem(dismissKey(), "1");
setDismissed(true);
};
const steps = [
{ done: hasCv, label: t("onboardingStepCv"), action: () => navigate("/profile"), actionLabel: t("onboardingStepCvAction") },
{ done: hasJobs, label: t("onboardingStepJob"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepJobAction") },
{ done: hasCv === true && hasJobs, label: t("onboardingStepMatch"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepMatchAction") },
];
return (
<Paper
sx={{
p: 2.25,
mb: 2,
borderRadius: 4,
border: "1px solid",
borderColor: alpha(theme.palette.primary.main, 0.25),
background: alpha(theme.palette.primary.main, 0.04),
position: "relative",
}}
>
<IconButton size="small" onClick={dismiss} aria-label={t("onboardingDismiss")} sx={{ position: "absolute", top: 8, right: 8 }}>
<CloseIcon fontSize="small" />
</IconButton>
<Typography sx={{ fontWeight: 900, mb: 0.25 }}>{t("onboardingTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("onboardingBody")}</Typography>
<Stack spacing={1}>
{steps.map((step) => (
<Box key={step.label} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1.5, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{step.done ? <CheckCircleIcon fontSize="small" color="success" /> : <RadioButtonUncheckedIcon fontSize="small" sx={{ color: "text.secondary" }} />}
<Typography variant="body2" sx={{ fontWeight: step.done ? 400 : 700, color: step.done ? "text.secondary" : "text.primary", textDecoration: step.done ? "line-through" : "none" }}>
{step.label}
</Typography>
</Box>
{!step.done ? <Button size="small" variant="text" onClick={step.action}>{step.actionLabel}</Button> : null}
</Box>
))}
</Stack>
</Paper>
);
}
@@ -0,0 +1,148 @@
import React, { useEffect, useState } from "react";
import {
Alert,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
List,
ListItem,
ListItemSecondaryAction,
ListItemText,
Paper,
Typography,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { clearAuthClientState } from "../auth";
type Session = {
id: string;
deviceLabel: string | null;
createdAtUtc: string;
lastSeenAtUtc: string;
expiresAtUtc: string;
isCurrentSession: boolean;
};
function apiErrorMessage(e: any, t: (k: any) => string) {
return getApiErrorMessage(e, t("twoFactorGenericError"));
}
export default function SessionsSettingsCard() {
const { toast } = useToast();
const { t } = useI18n();
const [sessions, setSessions] = useState<Session[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmRevokeOthers, setConfirmRevokeOthers] = useState(false);
const loadSessions = () => {
setLoading(true);
setError(null);
api
.get<Session[]>("/auth/sessions")
.then((r) => setSessions(r.data))
.catch((e) => setError(apiErrorMessage(e, t)))
.finally(() => setLoading(false));
};
useEffect(() => { loadSessions(); }, []);
async function revokeSession(id: string, isCurrentSession: boolean) {
try {
await api.delete(`/auth/sessions/${id}`);
if (isCurrentSession) {
// Same pattern as AuthStatusCard's sign-out: clearing local auth state emits
// "auth-changed", which App.tsx's listener picks up to refetch /auth/me (now 401,
// since the server already deleted the session cookie) and redirect to /login.
clearAuthClientState();
return;
}
toast(t("sessionsRevoked"), "success");
loadSessions();
} catch (e: any) {
setError(apiErrorMessage(e, t));
}
}
async function revokeOthers() {
try {
await api.post("/auth/sessions/revoke-others");
toast(t("sessionsRevokedOthers"), "success");
setConfirmRevokeOthers(false);
loadSessions();
} catch (e: any) {
setError(apiErrorMessage(e, t));
}
}
return (
<Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
{t("sessionsSectionTitle")}
</Typography>
{error ? <Alert severity="error" role="alert" sx={{ mb: 1 }}>{error}</Alert> : null}
{!loading && sessions.length === 0 && !error ? (
<Typography sx={{ color: "text.secondary", mb: 1 }}>{t("sessionsEmpty")}</Typography>
) : null}
{sessions.length > 0 ? (
<List dense disablePadding>
{sessions.map((s) => (
<ListItem key={s.id} divider>
<ListItemText
primary={
<>
{s.deviceLabel || t("sessionsUnknownDevice")}
{s.isCurrentSession ? (
<Typography component="span" variant="caption" sx={{ ml: 1, color: "primary.main", fontWeight: 700 }}>
{t("sessionsCurrentDevice")}
</Typography>
) : null}
</>
}
secondary={t("sessionsMeta", {
lastSeen: new Date(s.lastSeenAtUtc).toLocaleString(),
expires: new Date(s.expiresAtUtc).toLocaleDateString(),
})}
/>
<ListItemSecondaryAction>
<IconButton edge="end" aria-label={t("sessionsRevoke")} onClick={() => revokeSession(s.id, s.isCurrentSession)}>
<DeleteIcon fontSize="small" />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
) : null}
{sessions.length > 1 ? (
<Button variant="text" color="warning" size="small" sx={{ mt: 1 }} onClick={() => setConfirmRevokeOthers(true)}>
{t("sessionsRevokeOthers")}
</Button>
) : null}
<Dialog open={confirmRevokeOthers} onClose={() => setConfirmRevokeOthers(false)} maxWidth="sm" fullWidth>
<DialogTitle>{t("sessionsRevokeOthersConfirmTitle")}</DialogTitle>
<DialogContent>
<Typography>{t("sessionsRevokeOthersConfirmBody")}</Typography>
</DialogContent>
<DialogActions>
<Button type="button" onClick={() => setConfirmRevokeOthers(false)}>{t("cancel")}</Button>
<Button variant="contained" color="warning" onClick={revokeOthers}>
{t("sessionsRevokeOthers")}
</Button>
</DialogActions>
</Dialog>
</Paper>
);
}
+32 -145
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useState } from "react";
import {
Box,
@@ -9,11 +9,9 @@ import {
InputLabel,
MenuItem,
Paper,
Popover,
Select,
Tab,
Tabs,
TextField,
Typography,
} from "@mui/material";
@@ -21,12 +19,9 @@ import { useNavigate } from "react-router-dom";
import { JobTableColumns } from "./JobTable";
import ImportExportJobs from "./ImportExportJobs";
import GoogleAuthCard from "./GoogleAuthCard";
import EmailProviderConnections from "./EmailProviderConnections";
import RulesSettingsCard from "./RulesSettingsCard";
import BackupCard from "./BackupCard";
import QuickCaptureCard from "./QuickCaptureCard";
import AuthStatusCard from "./AuthStatusCard";
import { ThemeModePref } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
@@ -37,17 +32,23 @@ interface Props {
onColumnsChange: (next: JobTableColumns) => void;
themeMode: ThemeModePref;
onThemeModeChange: (v: ThemeModePref) => void;
accentColor: string;
onAccentColorChange: (v: string) => void;
onResetAccentColor: () => void;
}
function TabPanel({ value, index, children }: { value: number; index: number; children: React.ReactNode }) {
if (value !== index) return null;
return <Box sx={{ mt: 2 }}>{children}</Box>;
return <Box sx={{ mt: 2.5 }}>{children}</Box>;
}
function SectionCard({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<Paper sx={{ p: 2.5 }}>
<Typography variant="overline" sx={{ color: "text.secondary", fontWeight: 800 }}>{title}</Typography>
{subtitle ? <Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, mb: 1.5 }}>{subtitle}</Typography> : <Box sx={{ mb: 1.5 }} />}
{children}
</Paper>
);
}
const ACCENTS = ["#6366f1", "#22d3ee", "#2563eb", "#8b5cf6", "#15803d", "#16a34a", "#0f766e", "#f97316"];
const NOTIFICATION_PREFS_KEY = "settings.notificationPrefs";
type NotificationPrefs = {
@@ -88,43 +89,19 @@ export default function SettingsView({
onColumnsChange,
themeMode,
onThemeModeChange,
accentColor,
onAccentColorChange,
onResetAccentColor,
}: Props) {
const navigate = useNavigate();
const [tab, setTab] = useState(0);
const { language, setLanguage, t } = useI18n();
const [accentAnchor, setAccentAnchor] = useState<HTMLElement | null>(null);
const [accentDraft, setAccentDraft] = useState(accentColor);
const [notificationPrefs, setNotificationPrefs] = useState<NotificationPrefs>(() => loadNotificationPrefs());
const accentOk = useMemo(() => /^#[0-9a-fA-F]{6}$/.test(accentColor), [accentColor]);
const accentDraftOk = useMemo(() => /^#[0-9a-fA-F]{6}$/.test(accentDraft), [accentDraft]);
useEffect(() => {
setAccentDraft(accentOk ? accentColor : "#15803d");
}, [accentColor, accentOk]);
useEffect(() => {
window.localStorage.setItem(NOTIFICATION_PREFS_KEY, JSON.stringify(notificationPrefs));
}, [notificationPrefs]);
const applyAccent = () => {
if (!accentDraftOk) return;
onAccentColorChange(accentDraft);
setAccentAnchor(null);
};
const resetAccent = () => {
onResetAccentColor();
setAccentDraft("#15803d");
setAccentAnchor(null);
};
return (
<Paper sx={{ mt: 0, p: 2 }}>
<Typography variant="h5" sx={{ mb: 1, fontWeight: 900 }}>
<Paper sx={{ mt: 0, p: 2.5 }}>
<Typography variant="h5" sx={{ mb: 0.5, fontWeight: 900 }}>
{t("settingsTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
@@ -135,16 +112,14 @@ export default function SettingsView({
<Tab label={t("settingsTabGeneral")} />
<Tab label={t("settingsTabFollowUps")} />
<Tab label={t("settingsTabNotifications")} />
<Tab label={t("settingsTabAccount")} />
<Tab label={t("settingsTabBackup")} />
</Tabs>
<TabPanel value={tab} index={0}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<Paper sx={{ p: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsAppearance")}</Typography>
<FormControl fullWidth sx={{ mb: 2 }}>
<Box sx={{ display: "grid", gap: 2.5 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2.5 }}>
<SectionCard title={t("settingsAppearance")}>
<FormControl fullWidth>
<InputLabel id="theme-mode-label">{t("settingsTheme")}</InputLabel>
<Select
labelId="theme-mode-label"
@@ -157,85 +132,10 @@ export default function SettingsView({
<MenuItem value="light">{t("settingsThemeLight")}</MenuItem>
</Select>
</FormControl>
</SectionCard>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<Box>
<Typography variant="caption" sx={{ mb: 0.75, display: "block" }}>{t("settingsAccent")}</Typography>
<Button
variant="outlined"
onClick={(e) => setAccentAnchor(e.currentTarget)}
sx={{ gap: 1.25, justifyContent: "flex-start", minWidth: 180 }}
>
<Box sx={{ width: 20, height: 20, borderRadius: 999, bgcolor: accentOk ? accentColor : "#15803d", border: "1px solid", borderColor: "divider" }} />
{accentOk ? accentColor.toUpperCase() : "#15803D"}
</Button>
</Box>
<Button variant="outlined" onClick={resetAccent}>
{t("settingsReset")}
</Button>
</Box>
<Popover
open={Boolean(accentAnchor)}
anchorEl={accentAnchor}
onClose={() => setAccentAnchor(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
>
<Box sx={{ p: 2, width: 280, display: "grid", gap: 1.5 }}>
<Typography sx={{ fontWeight: 900 }}>{t("settingsAccent")}</Typography>
<input
aria-label={t("settingsAccent")}
type="color"
value={accentDraftOk ? accentDraft : "#15803d"}
onChange={(e) => setAccentDraft(e.target.value)}
style={{ width: "100%", height: 52, border: "none", background: "transparent", padding: 0, cursor: "pointer" }}
/>
<TextField
label={t("settingsAccent")}
value={accentDraft}
onChange={(e) => setAccentDraft(e.target.value)}
error={!accentDraftOk}
helperText={accentDraftOk ? t("settingsAccentHelp") : t("settingsAccentInvalid")}
fullWidth
/>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{ACCENTS.map((c) => (
<button
key={c}
type="button"
onClick={() => setAccentDraft(c)}
title={c}
aria-label={`${t("settingsAccent")} ${c}`}
style={{
width: 28,
height: 28,
borderRadius: 999,
border: c.toLowerCase() === accentDraft.toLowerCase() ? "2px solid rgba(15,23,42,0.9)" : "1px solid rgba(148,163,184,0.35)",
background: c,
cursor: "pointer",
}}
/>
))}
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1 }}>
<Button variant="text" onClick={() => setAccentAnchor(null)}>{t("cancel")}</Button>
<Button variant="contained" onClick={applyAccent} disabled={!accentDraftOk}>{t("save")}</Button>
</Box>
</Box>
</Popover>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1 }}>
{t("settingsSavedPerUser")}
</Typography>
</Paper>
<Paper sx={{ p: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsLanguageTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
{t("settingsLanguageBody")}
</Typography>
<FormControl fullWidth sx={{ mb: 2 }}>
<SectionCard title={t("settingsLanguageTitle")} subtitle={t("settingsLanguageBody")}>
<FormControl fullWidth>
<InputLabel id="language-label">{t("settingsPreferredLanguage")}</InputLabel>
<Select
labelId="language-label"
@@ -247,18 +147,13 @@ export default function SettingsView({
<MenuItem value="no">{t("settingsNorwegian")}</MenuItem>
</Select>
</FormControl>
</SectionCard>
</Box>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{t("settingsMorePagesSoon")}
</Typography>
</Paper>
<Paper sx={{ p: 2, gridColumn: { xs: "1 / -1", md: "1 / -1" } }}>
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsJobs")}</Typography>
<Box sx={{ display: "flex", gap: 3, flexWrap: "wrap" }}>
<SectionCard title={t("settingsJobs")}>
<Box sx={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
<Box sx={{ minWidth: 240 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
{t("settingsPagination")}
</Typography>
<FormControl fullWidth>
@@ -277,7 +172,7 @@ export default function SettingsView({
</Box>
<Box sx={{ minWidth: 240 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
{t("settingsColumns")}
</Typography>
{(
@@ -297,8 +192,10 @@ export default function SettingsView({
</Box>
</Box>
<Box sx={{ mt: 2 }}>
<ImportExportJobs />
</Paper>
</Box>
</SectionCard>
<QuickCaptureCard />
</Box>
@@ -309,9 +206,7 @@ export default function SettingsView({
</TabPanel>
<TabPanel value={tab} index={2}>
<Paper sx={{ p: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsNotificationsTitle")}</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>{t("settingsNotificationsBody")}</Typography>
<SectionCard title={t("settingsNotificationsTitle")} subtitle={t("settingsNotificationsBody")}>
<Box sx={{ display: "grid", gap: 1 }}>
<FormControlLabel
control={<Checkbox checked={notificationPrefs.emailFollowUpReminders} onChange={(e) => setNotificationPrefs((prev) => ({ ...prev, emailFollowUpReminders: e.target.checked }))} />}
@@ -333,18 +228,10 @@ export default function SettingsView({
<Button variant="outlined" onClick={() => navigate("/reminders")}>{t("settingsOpenReminderInbox")}</Button>
<Button variant="text" onClick={() => navigate("/admin/system")}>{t("settingsCheckSystemStatus")}</Button>
</Box>
</Paper>
</SectionCard>
</TabPanel>
<TabPanel value={tab} index={3}>
<AuthStatusCard />
<GoogleAuthCard />
<Box sx={{ mt: 2 }}>
<EmailProviderConnections />
</Box>
</TabPanel>
<TabPanel value={tab} index={4}>
<BackupCard />
</TabPanel>
</Paper>
@@ -0,0 +1,83 @@
import React, { useState } from "react";
import { Alert, Box, Button, Checkbox, FormControlLabel, TextField, Typography } from "@mui/material";
import { api, getApiErrorMessage } from "../api";
import { useI18n } from "../i18n/I18nProvider";
type ChallengeResponse = { authenticated: true; provider: "local" };
export default function TwoFactorChallenge({
pendingToken,
onSuccess,
onCancel,
}: {
pendingToken: string;
onSuccess: (data: ChallengeResponse) => void;
onCancel: () => void;
}) {
const { t } = useI18n();
const [code, setCode] = useState("");
const [trustDevice, setTrustDevice] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit() {
setLoading(true);
setError(null);
try {
const res = await api.post<ChallengeResponse>("/auth/2fa/challenge", { pendingToken, code, trustDevice });
onSuccess(res.data);
} catch (e: any) {
if (e?.response?.status === 429) {
setError(t("twoFactorRateLimited"));
} else {
setError(getApiErrorMessage(e, t("twoFactorInvalidCode")));
}
} finally {
setLoading(false);
}
}
return (
<Box
component="form"
onSubmit={(e) => { e.preventDefault(); void submit(); }}
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
role="group"
aria-label={t("twoFactorTitle")}
>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
{t("twoFactorTitle")}
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{t("twoFactorHint")}
</Typography>
{error ? <Alert severity="error" role="alert">{error}</Alert> : null}
<TextField
label={t("twoFactorCodeLabel")}
value={code}
onChange={(e) => setCode(e.target.value)}
autoComplete="one-time-code"
autoFocus
fullWidth
/>
<FormControlLabel
control={<Checkbox checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />}
label={t("twoFactorTrustDevice")}
/>
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end" }}>
<Button type="button" variant="text" disabled={loading} onClick={onCancel}>
{t("twoFactorBack")}
</Button>
<Button type="submit" variant="contained" disabled={loading || !code.trim()}>
{loading ? t("twoFactorVerifying") : t("twoFactorVerify")}
</Button>
</Box>
</Box>
);
}
@@ -0,0 +1,390 @@
import React, { useEffect, useState } from "react";
import {
Alert,
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
FormControlLabel,
IconButton,
List,
ListItem,
ListItemSecondaryAction,
ListItemText,
Paper,
TextField,
Typography,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
type Status = { enabled: boolean; enabledAtUtc: string | null };
type SetupResponse = { manualEntryKey: string; qrCodeDataUrl: string };
type RecoveryCodesResponse = { recoveryCodes: string[] };
type TrustedDevice = {
id: number;
deviceLabel: string | null;
createdAtUtc: string;
lastSeenAtUtc: string;
expiresAtUtc: string;
isCurrentDevice: boolean;
};
type Flow =
| "closed"
| "enable-password"
| "enable-qr"
| "enable-recovery"
| "disable-password"
| "regenerate-password"
| "regenerate-recovery"
| "revoke-all-confirm";
function apiErrorMessage(e: any, t: (k: any) => string) {
if (e?.response?.status === 429) return t("twoFactorRateLimited");
return getApiErrorMessage(e, t("twoFactorGenericError"));
}
export default function TwoFactorSettingsCard() {
const { toast } = useToast();
const { t } = useI18n();
const [status, setStatus] = useState<Status | null>(null);
const [flow, setFlow] = useState<Flow>("closed");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [setup, setSetup] = useState<SetupResponse | null>(null);
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
const [savedConfirmed, setSavedConfirmed] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [devices, setDevices] = useState<TrustedDevice[]>([]);
const [devicesLoading, setDevicesLoading] = useState(false);
const [devicesError, setDevicesError] = useState<string | null>(null);
const loadStatus = () => {
api.get<Status>("/auth/2fa/status").then((r) => setStatus(r.data)).catch(() => setStatus(null));
};
const loadDevices = () => {
setDevicesLoading(true);
setDevicesError(null);
api
.get<TrustedDevice[]>("/auth/2fa/trusted-devices")
.then((r) => setDevices(r.data))
.catch((e) => setDevicesError(apiErrorMessage(e, t)))
.finally(() => setDevicesLoading(false));
};
useEffect(() => { loadStatus(); loadDevices(); }, []);
async function revokeDevice(id: number) {
try {
await api.delete(`/auth/2fa/trusted-devices/${id}`);
loadDevices();
} catch (e: any) {
setDevicesError(apiErrorMessage(e, t));
}
}
async function revokeAllDevices() {
try {
await api.post("/auth/2fa/trusted-devices/revoke-all");
toast(t("twoFactorTrustedDevicesRevokedAll"), "success");
closeFlow();
loadDevices();
} catch (e: any) {
setError(apiErrorMessage(e, t));
}
}
function closeFlow() {
setFlow("closed");
setPassword("");
setCode("");
setSetup(null);
setRecoveryCodes([]);
setSavedConfirmed(false);
setError(null);
}
async function submitPassword() {
setLoading(true);
setError(null);
try {
if (flow === "enable-password") {
const res = await api.post<SetupResponse>("/auth/2fa/setup", { currentPassword: password });
setSetup(res.data);
setPassword("");
setFlow("enable-qr");
} else if (flow === "disable-password") {
await api.post("/auth/2fa/disable", { currentPassword: password });
toast(t("twoFactorDisabledSuccess"), "success");
closeFlow();
loadStatus();
} else if (flow === "regenerate-password") {
const res = await api.post<RecoveryCodesResponse>("/auth/2fa/recovery-codes/regenerate", { currentPassword: password });
setRecoveryCodes(res.data.recoveryCodes);
setPassword("");
setFlow("regenerate-recovery");
}
} catch (e: any) {
setError(e?.response?.status === 400 || e?.response?.status === 401 ? t("twoFactorWrongPassword") : apiErrorMessage(e, t));
} finally {
setLoading(false);
}
}
async function submitCode() {
setLoading(true);
setError(null);
try {
const res = await api.post<RecoveryCodesResponse>("/auth/2fa/verify-setup", { code });
setRecoveryCodes(res.data.recoveryCodes);
setCode("");
setFlow("enable-recovery");
} catch (e: any) {
setError(e?.response?.status === 401 ? t("twoFactorInvalidCode") : apiErrorMessage(e, t));
} finally {
setLoading(false);
}
}
function finishRecovery() {
toast(flow === "enable-recovery" ? t("twoFactorEnabledSuccess") : t("twoFactorRegenerateSuccess"), "success");
closeFlow();
loadStatus();
}
function copyRecoveryCodes() {
void navigator.clipboard.writeText(recoveryCodes.join("\n"));
toast(t("twoFactorCodesCopied"), "info");
}
function downloadRecoveryCodes() {
const blob = new Blob([recoveryCodes.join("\n") + "\n"], { type: "text/plain" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "jobbjakt-recovery-codes.txt";
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
}
const isPasswordStep = flow === "enable-password" || flow === "disable-password" || flow === "regenerate-password";
const isRecoveryStep = flow === "enable-recovery" || flow === "regenerate-recovery";
return (
<Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
{t("twoFactorSectionTitle")}
</Typography>
{status ? (
<Typography sx={{ color: "text.secondary", mb: 1.5 }}>
{status.enabled
? t("twoFactorStatusEnabled", { date: status.enabledAtUtc ? new Date(status.enabledAtUtc).toLocaleDateString() : "" })
: t("twoFactorStatusDisabled")}
</Typography>
) : null}
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{!status?.enabled ? (
<Button variant="contained" onClick={() => setFlow("enable-password")}>
{t("twoFactorEnableButton")}
</Button>
) : (
<>
<Button variant="outlined" color="warning" onClick={() => setFlow("disable-password")}>
{t("twoFactorDisableButton")}
</Button>
<Button variant="outlined" onClick={() => setFlow("regenerate-password")}>
{t("twoFactorRegenerateButton")}
</Button>
</>
)}
</Box>
{status?.enabled ? (
<Box sx={{ mt: 2 }}>
<Divider sx={{ mb: 1.5 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 0.5 }}>
{t("twoFactorTrustedDevicesTitle")}
</Typography>
{devicesError ? <Alert severity="error" role="alert" sx={{ mb: 1 }}>{devicesError}</Alert> : null}
{!devicesLoading && devices.length === 0 && !devicesError ? (
<Typography sx={{ color: "text.secondary", mb: 1 }}>{t("twoFactorTrustedDevicesEmpty")}</Typography>
) : null}
{devices.length > 0 ? (
<List dense disablePadding>
{devices.map((d) => (
<ListItem key={d.id} divider>
<ListItemText
primary={
<>
{d.deviceLabel || t("twoFactorTrustedDeviceUnknown")}
{d.isCurrentDevice ? (
<Typography component="span" variant="caption" sx={{ ml: 1, color: "primary.main", fontWeight: 700 }}>
{t("twoFactorTrustedDeviceCurrent")}
</Typography>
) : null}
</>
}
secondary={t("twoFactorTrustedDeviceMeta", {
lastSeen: new Date(d.lastSeenAtUtc).toLocaleDateString(),
expires: new Date(d.expiresAtUtc).toLocaleDateString(),
})}
/>
<ListItemSecondaryAction>
<IconButton edge="end" aria-label={t("twoFactorRevokeDevice")} onClick={() => revokeDevice(d.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
) : null}
{devices.length > 0 ? (
<Button variant="text" color="warning" size="small" sx={{ mt: 1 }} onClick={() => setFlow("revoke-all-confirm")}>
{t("twoFactorRevokeAllDevices")}
</Button>
) : null}
</Box>
) : null}
<Dialog open={flow !== "closed"} onClose={isRecoveryStep ? undefined : closeFlow} maxWidth="sm" fullWidth>
{isPasswordStep && (
<Box component="form" onSubmit={(e) => { e.preventDefault(); void submitPassword(); }}>
<DialogTitle>{t("twoFactorPasswordPrompt")}</DialogTitle>
<DialogContent>
{flow === "disable-password" ? <Alert severity="warning" sx={{ mb: 2 }}>{t("twoFactorDisableWarning")}</Alert> : null}
{flow === "regenerate-password" ? <Alert severity="warning" sx={{ mb: 2 }}>{t("twoFactorRegenerateWarning")}</Alert> : null}
{error ? <Alert severity="error" role="alert" sx={{ mb: 2 }}>{error}</Alert> : null}
<TextField
label={t("twoFactorPasswordLabel")}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
autoFocus
fullWidth
/>
</DialogContent>
<DialogActions>
<Button type="button" onClick={closeFlow} disabled={loading}>{t("cancel")}</Button>
<Button type="submit" variant="contained" disabled={loading || !password}>
{t("twoFactorContinue")}
</Button>
</DialogActions>
</Box>
)}
{flow === "enable-qr" && setup && (
<Box component="form" onSubmit={(e) => { e.preventDefault(); void submitCode(); }}>
<DialogTitle>{t("twoFactorSetupTitle")}</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
{t("twoFactorSetupHint")}
</Typography>
<Box sx={{ display: "flex", justifyContent: "center", mb: 2 }}>
<img src={setup.qrCodeDataUrl} alt={t("twoFactorSetupTitle")} width={200} height={200} />
</Box>
<TextField
label={t("twoFactorManualKeyLabel")}
value={setup.manualEntryKey}
fullWidth
sx={{ mb: 1 }}
InputProps={{
readOnly: true,
endAdornment: (
<Button
size="small"
onClick={() => {
void navigator.clipboard.writeText(setup.manualEntryKey);
toast(t("twoFactorKeyCopied"), "info");
}}
>
{t("twoFactorCopyKey")}
</Button>
),
}}
/>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 2, mb: 1 }}>
{t("twoFactorConfirmCodeHint")}
</Typography>
{error ? <Alert severity="error" role="alert" sx={{ mb: 2 }}>{error}</Alert> : null}
<TextField
label={t("twoFactorConfirmCodeLabel")}
value={code}
onChange={(e) => setCode(e.target.value)}
autoComplete="one-time-code"
fullWidth
/>
</DialogContent>
<DialogActions>
<Button type="button" onClick={closeFlow} disabled={loading}>{t("cancel")}</Button>
<Button type="submit" variant="contained" disabled={loading || !code.trim()}>
{t("twoFactorConfirmButton")}
</Button>
</DialogActions>
</Box>
)}
{isRecoveryStep && (
<>
<DialogTitle role="alert">{t("twoFactorRecoveryTitle")}</DialogTitle>
<DialogContent>
<Alert severity="warning" sx={{ mb: 2 }}>{t("twoFactorRecoveryHint")}</Alert>
<Box
component="ul"
sx={{ fontFamily: "monospace", fontSize: 16, p: 1.5, borderRadius: 2, backgroundColor: "background.default", border: "1px solid", borderColor: "divider", listStyle: "none", m: 0, mb: 2 }}
>
{recoveryCodes.map((rc) => (
<li key={rc}>{rc}</li>
))}
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}>
<Button variant="outlined" onClick={copyRecoveryCodes}>{t("twoFactorCopyAll")}</Button>
<Button variant="outlined" onClick={downloadRecoveryCodes}>{t("twoFactorDownload")}</Button>
</Box>
<FormControlLabel
control={<Checkbox checked={savedConfirmed} onChange={(e) => setSavedConfirmed(e.target.checked)} />}
label={t("twoFactorSavedConfirm")}
/>
</DialogContent>
<DialogActions>
<Button variant="contained" disabled={!savedConfirmed} onClick={finishRecovery}>
{t("twoFactorDone")}
</Button>
</DialogActions>
</>
)}
{flow === "revoke-all-confirm" && (
<>
<DialogTitle>{t("twoFactorRevokeAllConfirmTitle")}</DialogTitle>
<DialogContent>
{error ? <Alert severity="error" role="alert" sx={{ mb: 2 }}>{error}</Alert> : null}
<Typography>{t("twoFactorRevokeAllConfirmBody")}</Typography>
</DialogContent>
<DialogActions>
<Button type="button" onClick={closeFlow}>{t("cancel")}</Button>
<Button variant="contained" color="warning" onClick={revokeAllDevices}>
{t("twoFactorRevokeAllDevices")}
</Button>
</DialogActions>
</>
)}
</Dialog>
</Paper>
);
}
+184 -18
View File
@@ -18,6 +18,12 @@ export const translations = {
home: "Home",
analytics: "Analytics",
overview: "Overview",
dashboardPageSubtitle: "Your search at a glance — response rate, funnel, and what needs attention.",
jobsPageSubtitle: "Filter, search, and manage every application in one table.",
kanbanPageSubtitle: "Drag a card between stages to update its status.",
remindersPageSubtitle: "Everything due for follow-up, soonest first.",
correspondencePageSubtitle: "Every message linked to a job, in one inbox.",
gmailReviewPageSubtitle: "Review Gmail threads before linking them to a job.",
account: "Account",
profile: "Profile",
admin: "Admin",
@@ -128,22 +134,17 @@ export const translations = {
settingsTabGeneral: "General",
settingsTabFollowUps: "Follow-ups",
settingsTabNotifications: "Notifications",
settingsTabAccount: "Account",
settingsTabBackup: "Backup",
settingsAppearance: "Appearance",
settingsTheme: "Theme",
settingsThemeSystem: "System",
settingsThemeDark: "Dark",
settingsThemeLight: "Light",
settingsAccent: "Accent",
settingsReset: "Reset",
settingsSavedPerUser: "Saved per user on this browser.",
settingsLanguageTitle: "Language and localization",
settingsLanguageBody: "Set your preferred app language. This is also the language used when deciding whether imported job descriptions should show translated text.",
settingsPreferredLanguage: "Preferred language",
settingsEnglish: "English",
settingsNorwegian: "Norwegian Bokmål",
settingsMorePagesSoon: "More pages will be moved onto this translation system as the UI cleanup continues.",
settingsJobs: "Jobs",
settingsPagination: "Pagination",
settingsRowsPerPage: "Rows per page",
@@ -167,8 +168,6 @@ export const translations = {
settingsNotificationsFollowUpReminders: "Email reminders for follow-ups",
settingsNotificationsGhostedJobs: "Email alerts for ghosted jobs",
settingsNotificationsInAppReminders: "Highlight reminders in the app",
settingsAccentHelp: "Drag in the color picker, then save when it looks right.",
settingsAccentInvalid: "Use a full hex color like #15803D.",
settingsCheckSystemStatus: "Check system status",
profileTitle: "Profile",
profileHeadlinePlaceholder: "Add a short headline to personalize your account view.",
@@ -307,12 +306,82 @@ export const translations = {
profileUpdatePassword: "Update password",
profilePasswordUpdated: "Password updated.",
profilePasswordUpdateFailed: "Failed to change password.",
twoFactorTitle: "Two-factor verification",
twoFactorHint: "Enter the 6-digit code from your authenticator app, or a recovery code.",
twoFactorCodeLabel: "Code",
twoFactorVerify: "Verify",
twoFactorVerifying: "Verifying...",
twoFactorBack: "Back",
twoFactorInvalidCode: "Invalid code. Please try again.",
twoFactorRateLimited: "Too many attempts. Please wait a few minutes and try again.",
twoFactorGenericError: "Something went wrong. Please try again.",
twoFactorSectionTitle: "Two-factor authentication",
twoFactorStatusEnabled: "Enabled since {date}",
twoFactorStatusDisabled: "Not enabled",
twoFactorEnableButton: "Enable 2FA",
twoFactorDisableButton: "Disable 2FA",
twoFactorRegenerateButton: "Regenerate recovery codes",
twoFactorPasswordPrompt: "Confirm your password to continue",
twoFactorPasswordLabel: "Current password",
twoFactorContinue: "Continue",
twoFactorWrongPassword: "Incorrect password.",
twoFactorSetupTitle: "Scan this QR code",
twoFactorSetupHint: "Scan with your authenticator app, or enter the key manually.",
twoFactorManualKeyLabel: "Manual entry key",
twoFactorCopyKey: "Copy key",
twoFactorKeyCopied: "Key copied to clipboard.",
twoFactorConfirmCodeLabel: "6-digit code",
twoFactorConfirmCodeHint: "Enter the code shown by your authenticator app to confirm setup.",
twoFactorConfirmButton: "Confirm",
twoFactorRecoveryTitle: "Save your recovery codes",
twoFactorRecoveryHint: "Each code can be used once if you lose access to your authenticator app. This is the only time these codes will be shown.",
twoFactorCopyAll: "Copy all codes",
twoFactorCodesCopied: "Recovery codes copied.",
twoFactorDownload: "Download as .txt",
twoFactorSavedConfirm: "I've saved my recovery codes",
twoFactorDone: "Done",
twoFactorDisableWarning: "Disabling 2FA will also invalidate your recovery codes.",
twoFactorRegenerateWarning: "This will invalidate your existing recovery codes.",
twoFactorEnabledSuccess: "Two-factor authentication enabled.",
twoFactorDisabledSuccess: "Two-factor authentication disabled.",
twoFactorRegenerateSuccess: "Recovery codes regenerated.",
twoFactorTrustDevice: "Trust this device for 30 days",
twoFactorTrustedDevicesTitle: "Trusted devices",
twoFactorTrustedDevicesEmpty: "No trusted devices yet.",
twoFactorTrustedDeviceUnknown: "Unknown device",
twoFactorTrustedDeviceCurrent: "This device",
twoFactorTrustedDeviceMeta: "Last used {lastSeen} · Expires {expires}",
twoFactorRevokeDevice: "Revoke",
twoFactorRevokeAllDevices: "Sign out all other trusted devices",
twoFactorTrustedDevicesRevokedAll: "All trusted devices have been signed out.",
twoFactorRevokeAllConfirmTitle: "Sign out all trusted devices?",
twoFactorRevokeAllConfirmBody: "You'll be asked for a 2FA code the next time you sign in on any device, including this one.",
sessionsSectionTitle: "Sessions",
sessionsEmpty: "No active sessions.",
sessionsUnknownDevice: "Unknown device",
sessionsCurrentDevice: "This device",
sessionsMeta: "Last active {lastSeen} · Expires {expires}",
sessionsRevoke: "Sign out",
sessionsRevoked: "Session signed out.",
sessionsRevokeOthers: "Sign out all other devices",
sessionsRevokedOthers: "All other sessions have been signed out.",
sessionsRevokeOthersConfirmTitle: "Sign out all other devices?",
sessionsRevokeOthersConfirmBody: "Every other session for your account will be signed out immediately. This device stays signed in.",
cropDialogTitle: "Crop profile image",
cropDialogBody: "Position and zoom your image. The saved avatar will be exported as a 512×512 square.",
cropDialogZoom: "Zoom",
cropDialogSave: "Save image",
dashboardOverviewTitle: "Dashboard overview",
dashboardHeroLabel: "Job search overview",
onboardingTitle: "Get set up",
onboardingBody: "A few steps to get the most out of Jobbjakt.",
onboardingDismiss: "Dismiss",
onboardingStepCv: "Add your CV",
onboardingStepCvAction: "Add CV",
onboardingStepJob: "Import your first job",
onboardingStepJobAction: "Add job",
onboardingStepMatch: "Check your CV match score on a job",
onboardingStepMatchAction: "Open jobs",
dashboardResponseRate: "{rate}% response rate",
dashboardMonthsShort: "{count} mo",
dashboardAppliedCount: "{count} applied",
@@ -613,6 +682,7 @@ export const translations = {
googleAvailableToLink: "Available to link",
googleLinkedDate: "Linked {date}",
googleSignInHint: "Sign in with a Google account that has already been linked to your Jobbjakt user.",
googleSignInHintSelfServe: "Continue with Google. New here? We'll create your account automatically.",
continueWithGoogle: "Continue with Google",
signInWithGoogle: "Sign in with Google",
linkWithGoogle: "Link with Google",
@@ -629,7 +699,7 @@ export const translations = {
googleUnlinked: "Google account unlinked.",
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.",
microsoftSetupHint: "Set `NEXT_PUBLIC_MICROSOFT_CLIENT_ID` in your UI environment to enable Microsoft sign-in and account linking.",
microsoftLinked: "Linked",
microsoftAvailableToLink: "Available to link",
microsoftLinkedDate: "Linked {date}",
@@ -705,6 +775,15 @@ export const translations = {
resetFailed: "Reset failed.",
backToLogin: "Back to login",
updatePassword: "Update password",
emailNotVerified: "Please verify your email address before signing in.",
resendVerificationEmail: "Resend verification email",
verificationEmailResent: "Verification email sent. Check your inbox.",
registerCheckEmailForVerification: "Check your email to verify your account.",
verifyEmailTitle: "Verify your email",
verifyEmailVerifying: "Verifying your email...",
verifyEmailSuccess: "Your email has been verified. You can now sign in.",
verifyEmailFailed: "This verification link is invalid or has expired.",
missingVerifyLinkInfo: "Missing user/token in link.",
jobTableSearch: "Search",
jobTableSearchPlaceholder: "Title, company, notes, messages",
jobTableStatus: "Status",
@@ -757,6 +836,9 @@ export const translations = {
jobTableOverview: "Overview",
jobTableNoSummaryYet: "No summary yet.",
jobTableNoJobsFound: "No jobs found.",
jobTableEmptyFirstTimeTitle: "No jobs yet — let's fix that.",
jobTableEmptyFirstTimeBody: "Click \"Add job\" above to add one manually, or paste a job posting URL. There's also a one-click bookmarklet that captures a posting straight from the page you're viewing.",
jobTableEmptyFirstTimeBookmarklet: "Set up the bookmarklet",
jobTableSetStatus: "Set {status}",
editJobTitle: "Edit job",
editJobIntro: "Update job details, timeline status, documents, and notes from one editing workspace.",
@@ -901,6 +983,7 @@ export const translations = {
jobDetailsFollowUpSent: "Follow-up sent and logged.",
jobDetailsFollowUpSendFailed: "Failed to send follow-up.",
jobDetailsHowYouMatch: "How you match",
jobDetailsAiFitHint: "AI opinion — strengths, gaps, and a tailored pitch based on your CV and this posting.",
matchScoreTitle: "Match score",
matchScoreLoading: "Scoring your CV against this role…",
matchScoreBand_Strong: "Strong match",
@@ -909,7 +992,7 @@ export const translations = {
matchScoreBand_Unknown: "Not enough signal",
matchScoreKeywordsCovered: "{matched}/{total} keywords",
matchScoreNoSignal: "Add more CV detail or a fuller job description to get a reliable score.",
matchScoreDeterministicHint: "Deterministic keyword coverage — no AI, so the score is stable and repeatable.",
matchScoreDeterministicHint: "Deterministic keyword coverage — no AI, so the score is stable and repeatable. For a written opinion on strengths and gaps, see the AI section below.",
matchScoreMatched: "Matched keywords",
matchScoreMissing: "Missing keywords",
matchScoreNoneYet: "No matches found yet.",
@@ -983,6 +1066,12 @@ export const translations = {
home: "Hjem",
analytics: "Analyse",
overview: "Oversikt",
dashboardPageSubtitle: "Søket ditt i korte trekk — svarrate, trakt og hva som trenger oppmerksomhet.",
jobsPageSubtitle: "Filtrer, søk og administrer alle søknader i én tabell.",
kanbanPageSubtitle: "Dra et kort mellom stadier for å oppdatere status.",
remindersPageSubtitle: "Alt som trenger oppfølging, snarest først.",
correspondencePageSubtitle: "Alle meldinger koblet til en jobb, i én innboks.",
gmailReviewPageSubtitle: "Se gjennom Gmail-tråder før du kobler dem til en jobb.",
account: "Konto",
profile: "Profil",
admin: "Admin",
@@ -1093,22 +1182,17 @@ export const translations = {
settingsTabGeneral: "Generelt",
settingsTabFollowUps: "Oppfølging",
settingsTabNotifications: "Varsler",
settingsTabAccount: "Konto",
settingsTabBackup: "Sikkerhetskopi",
settingsAppearance: "Utseende",
settingsTheme: "Tema",
settingsThemeSystem: "System",
settingsThemeDark: "Mørkt",
settingsThemeLight: "Lyst",
settingsAccent: "Aksent",
settingsReset: "Tilbakestill",
settingsSavedPerUser: "Lagres per bruker i denne nettleseren.",
settingsLanguageTitle: "Språk og lokalisering",
settingsLanguageBody: "Velg foretrukket språk i appen. Dette brukes også når appen avgjør om importerte stillingsbeskrivelser skal vise oversatt tekst.",
settingsPreferredLanguage: "Foretrukket språk",
settingsEnglish: "Engelsk",
settingsNorwegian: "Norsk Bokmål",
settingsMorePagesSoon: "Flere sider flyttes til dette oversettelsessystemet etter hvert som UI-oppryddingen fortsetter.",
settingsJobs: "Jobber",
settingsPagination: "Paginering",
settingsRowsPerPage: "Rader per side",
@@ -1132,8 +1216,6 @@ export const translations = {
settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger",
settingsNotificationsGhostedJobs: "E-postvarsler for ghostede jobber",
settingsNotificationsInAppReminders: "Fremhev påminnelser i appen",
settingsAccentHelp: "Dra i fargevelgeren, og lagre når den ser riktig ut.",
settingsAccentInvalid: "Bruk en full hex-farge som #15803D.",
settingsCheckSystemStatus: "Sjekk systemstatus",
profileTitle: "Profil",
profileHeadlinePlaceholder: "Legg til en kort overskrift for å gjøre kontovisningen mer personlig.",
@@ -1272,12 +1354,82 @@ export const translations = {
profileUpdatePassword: "Oppdater passord",
profilePasswordUpdated: "Passord oppdatert.",
profilePasswordUpdateFailed: "Kunne ikke endre passord.",
twoFactorTitle: "Topunkts bekreftelse",
twoFactorHint: "Skriv inn 6-sifret kode fra autentiseringsappen din, eller en gjenopprettingskode.",
twoFactorCodeLabel: "Kode",
twoFactorVerify: "Bekreft",
twoFactorVerifying: "Bekrefter...",
twoFactorBack: "Tilbake",
twoFactorInvalidCode: "Ugyldig kode. Prøv igjen.",
twoFactorRateLimited: "For mange forsøk. Vent noen minutter og prøv igjen.",
twoFactorGenericError: "Noe gikk galt. Prøv igjen.",
twoFactorSectionTitle: "Topunkts autentisering",
twoFactorStatusEnabled: "Aktivert siden {date}",
twoFactorStatusDisabled: "Ikke aktivert",
twoFactorEnableButton: "Aktiver 2FA",
twoFactorDisableButton: "Deaktiver 2FA",
twoFactorRegenerateButton: "Generer nye gjenopprettingskoder",
twoFactorPasswordPrompt: "Bekreft passordet ditt for å fortsette",
twoFactorPasswordLabel: "Nåværende passord",
twoFactorContinue: "Fortsett",
twoFactorWrongPassword: "Feil passord.",
twoFactorSetupTitle: "Skann denne QR-koden",
twoFactorSetupHint: "Skann med autentiseringsappen din, eller skriv inn nøkkelen manuelt.",
twoFactorManualKeyLabel: "Manuell registreringsnøkkel",
twoFactorCopyKey: "Kopier nøkkel",
twoFactorKeyCopied: "Nøkkel kopiert til utklippstavlen.",
twoFactorConfirmCodeLabel: "6-sifret kode",
twoFactorConfirmCodeHint: "Skriv inn koden som vises i autentiseringsappen din for å bekrefte oppsettet.",
twoFactorConfirmButton: "Bekreft",
twoFactorRecoveryTitle: "Lagre gjenopprettingskodene dine",
twoFactorRecoveryHint: "Hver kode kan brukes én gang hvis du mister tilgang til autentiseringsappen din. Dette er eneste gang disse kodene vises.",
twoFactorCopyAll: "Kopier alle koder",
twoFactorCodesCopied: "Gjenopprettingskoder kopiert.",
twoFactorDownload: "Last ned som .txt",
twoFactorSavedConfirm: "Jeg har lagret gjenopprettingskodene mine",
twoFactorDone: "Ferdig",
twoFactorDisableWarning: "Deaktivering av 2FA vil også ugyldiggjøre gjenopprettingskodene dine.",
twoFactorRegenerateWarning: "Dette vil ugyldiggjøre eksisterende gjenopprettingskoder.",
twoFactorEnabledSuccess: "Topunkts autentisering aktivert.",
twoFactorDisabledSuccess: "Topunkts autentisering deaktivert.",
twoFactorRegenerateSuccess: "Gjenopprettingskoder generert på nytt.",
twoFactorTrustDevice: "Stol på denne enheten i 30 dager",
twoFactorTrustedDevicesTitle: "Betrodde enheter",
twoFactorTrustedDevicesEmpty: "Ingen betrodde enheter ennå.",
twoFactorTrustedDeviceUnknown: "Ukjent enhet",
twoFactorTrustedDeviceCurrent: "Denne enheten",
twoFactorTrustedDeviceMeta: "Sist brukt {lastSeen} · Utløper {expires}",
twoFactorRevokeDevice: "Fjern tilgang",
twoFactorRevokeAllDevices: "Logg ut alle andre betrodde enheter",
twoFactorTrustedDevicesRevokedAll: "Alle betrodde enheter er logget ut.",
twoFactorRevokeAllConfirmTitle: "Logg ut alle betrodde enheter?",
twoFactorRevokeAllConfirmBody: "Du vil bli bedt om en 2FA-kode neste gang du logger inn på en enhet, inkludert denne.",
sessionsSectionTitle: "Økter",
sessionsEmpty: "Ingen aktive økter.",
sessionsUnknownDevice: "Ukjent enhet",
sessionsCurrentDevice: "Denne enheten",
sessionsMeta: "Sist aktiv {lastSeen} · Utløper {expires}",
sessionsRevoke: "Logg ut",
sessionsRevoked: "Økten er logget ut.",
sessionsRevokeOthers: "Logg ut alle andre enheter",
sessionsRevokedOthers: "Alle andre økter er logget ut.",
sessionsRevokeOthersConfirmTitle: "Logg ut alle andre enheter?",
sessionsRevokeOthersConfirmBody: "Alle andre økter for kontoen din blir umiddelbart logget ut. Denne enheten forblir innlogget.",
cropDialogTitle: "Beskjær profilbilde",
cropDialogBody: "Plasser og zoom bildet. Det lagrede avataren eksporteres som en kvadratisk 512×512-fil.",
cropDialogZoom: "Zoom",
cropDialogSave: "Lagre bilde",
dashboardOverviewTitle: "Dashboard-oversikt",
dashboardHeroLabel: "Oversikt over jobbsøket",
onboardingTitle: "Kom i gang",
onboardingBody: "Noen få steg for å få mest mulig ut av Jobbjakt.",
onboardingDismiss: "Lukk",
onboardingStepCv: "Legg til CV-en din",
onboardingStepCvAction: "Legg til CV",
onboardingStepJob: "Importer din første jobb",
onboardingStepJobAction: "Legg til jobb",
onboardingStepMatch: "Sjekk CV-matchscoren på en jobb",
onboardingStepMatchAction: "Åpne jobber",
dashboardResponseRate: "{rate}% svarrate",
dashboardMonthsShort: "{count} md",
dashboardAppliedCount: "{count} søkt",
@@ -1578,6 +1730,7 @@ export const translations = {
googleAvailableToLink: "Tilgjengelig for kobling",
googleLinkedDate: "Koblet {date}",
googleSignInHint: "Logg inn med en Google-konto som allerede er koblet til Jobbjakt-brukeren din.",
googleSignInHintSelfServe: "Fortsett med Google. Ny her? Vi oppretter kontoen din automatisk.",
continueWithGoogle: "Fortsett med Google",
signInWithGoogle: "Logg inn med Google",
linkWithGoogle: "Koble til med Google",
@@ -1594,7 +1747,7 @@ export const translations = {
googleUnlinked: "Google-konto koblet fra.",
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.",
microsoftSetupHint: "Sett `NEXT_PUBLIC_MICROSOFT_CLIENT_ID` i UI-miljøet ditt for å aktivere Microsoft-innlogging og kontokobling.",
microsoftLinked: "Koblet",
microsoftAvailableToLink: "Tilgjengelig for kobling",
microsoftLinkedDate: "Koblet {date}",
@@ -1670,6 +1823,15 @@ export const translations = {
resetFailed: "Tilbakestilling mislyktes.",
backToLogin: "Tilbake til innlogging",
updatePassword: "Oppdater passord",
emailNotVerified: "Vennligst bekreft e-postadressen din før du logger inn.",
resendVerificationEmail: "Send bekreftelses-e-post på nytt",
verificationEmailResent: "Bekreftelses-e-post sendt. Sjekk innboksen din.",
registerCheckEmailForVerification: "Sjekk e-posten din for å bekrefte kontoen.",
verifyEmailTitle: "Bekreft e-posten din",
verifyEmailVerifying: "Bekrefter e-posten din...",
verifyEmailSuccess: "E-posten din er bekreftet. Du kan nå logge inn.",
verifyEmailFailed: "Denne bekreftelseslenken er ugyldig eller har utløpt.",
missingVerifyLinkInfo: "Mangler bruker/token i lenken.",
jobTableSearch: "Søk",
jobTableSearchPlaceholder: "Tittel, selskap, notater, meldinger",
jobTableStatus: "Status",
@@ -1722,6 +1884,9 @@ export const translations = {
jobTableOverview: "Oversikt",
jobTableNoSummaryYet: "Ingen oppsummering ennå.",
jobTableNoJobsFound: "Ingen jobber funnet.",
jobTableEmptyFirstTimeTitle: "Ingen jobber ennå — la oss fikse det.",
jobTableEmptyFirstTimeBody: "Klikk \"Legg til jobb\" over for å legge til en manuelt, eller lim inn en lenke til en stillingsannonse. Det finnes også et bokmerke som fanger en annonse rett fra siden du ser på.",
jobTableEmptyFirstTimeBookmarklet: "Sett opp bokmerket",
jobTableSetStatus: "Sett {status}",
editJobTitle: "Rediger jobb",
editJobIntro: "Oppdater jobbdetaljer, status i tidslinjen, dokumenter og notater fra ett redigeringsområde.",
@@ -1866,6 +2031,7 @@ export const translations = {
jobDetailsFollowUpSent: "Oppfølging sendt og loggført.",
jobDetailsFollowUpSendFailed: "Kunne ikke sende oppfølging.",
jobDetailsHowYouMatch: "Slik matcher du",
jobDetailsAiFitHint: "AI-vurdering — styrker, svakheter og et skreddersydd pitch basert på CV-en din og denne annonsen.",
matchScoreTitle: "Match-score",
matchScoreLoading: "Vurderer CV-en mot denne stillingen…",
matchScoreBand_Strong: "Sterk match",
@@ -1874,7 +2040,7 @@ export const translations = {
matchScoreBand_Unknown: "For lite grunnlag",
matchScoreKeywordsCovered: "{matched}/{total} nøkkelord",
matchScoreNoSignal: "Legg til mer CV-innhold eller en fyldigere stillingstekst for en pålitelig score.",
matchScoreDeterministicHint: "Deterministisk nøkkelorddekning — ingen AI, så scoren er stabil og repeterbar.",
matchScoreDeterministicHint: "Deterministisk nøkkelorddekning — ingen AI, så scoren er stabil og repeterbar. For en skriftlig vurdering av styrker og svakheter, se AI-seksjonen under.",
matchScoreMatched: "Treff på nøkkelord",
matchScoreMissing: "Manglende nøkkelord",
matchScoreNoneYet: "Ingen treff ennå.",
+7
View File
@@ -59,6 +59,7 @@ const SIDEBAR_SELECTED_ICON = "#a5b4fc";
export default function AppShell({
pageTitle,
pageSubtitle,
breadcrumbs,
pathname,
nav,
@@ -76,6 +77,7 @@ export default function AppShell({
children,
}: {
pageTitle: string;
pageSubtitle?: string;
breadcrumbs: string[];
pathname: string;
nav: NavItem[];
@@ -481,6 +483,11 @@ export default function AppShell({
<Typography variant="h5" sx={{ fontWeight: 600, overflowWrap: "anywhere" }}>
{pageTitle}
</Typography>
{pageSubtitle ? (
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, overflowWrap: "anywhere" }}>
{pageSubtitle}
</Typography>
) : null}
</Box>
</Box>
+85
View File
@@ -1,4 +1,5 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
@@ -81,4 +82,88 @@ describe('LoginPage', () => {
expect(mockNavigate).toHaveBeenCalledWith('/forgot-password?email=person%40example.com');
});
it('shows the 2FA code step when login requires two-factor, then proceeds like a normal login on success', async () => {
mockedApi.post.mockImplementation((url: string, payload?: any) => {
if (url === '/auth/login') {
return Promise.resolve({ data: { requiresTwoFactor: true, pendingToken: 'pending-abc' } } as any);
}
if (url === '/auth/2fa/challenge') {
expect(payload).toEqual({ pendingToken: 'pending-abc', code: '123456', trustDevice: false });
return Promise.resolve({ data: { authenticated: true, provider: 'local' } } as any);
}
return Promise.resolve({ data: {} } as any);
});
mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any);
renderLoginPage();
await screen.findByLabelText('Email');
await userEvent.type(screen.getByLabelText('Email'), 'person@example.com');
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }));
await screen.findByText('Two-factor verification');
expect(screen.queryByLabelText('Email')).not.toBeInTheDocument();
await userEvent.type(screen.getByLabelText('Code'), '123456');
await userEvent.click(screen.getByRole('button', { name: 'Verify' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/2fa/challenge', { pendingToken: 'pending-abc', code: '123456', trustDevice: false }));
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/auth/me'));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }));
});
it('shows a clear message when the 2FA challenge is rate-limited', async () => {
mockedApi.post.mockImplementation((url: string) => {
if (url === '/auth/login') {
return Promise.resolve({ data: { requiresTwoFactor: true, pendingToken: 'pending-abc' } } as any);
}
if (url === '/auth/2fa/challenge') {
return Promise.reject({ response: { status: 429 } });
}
return Promise.resolve({ data: {} } as any);
});
renderLoginPage();
await screen.findByLabelText('Email');
await userEvent.type(screen.getByLabelText('Email'), 'person@example.com');
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }));
await screen.findByText('Two-factor verification');
await userEvent.type(screen.getByLabelText('Code'), '123456');
await userEvent.click(screen.getByRole('button', { name: 'Verify' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Too many attempts. Please wait a few minutes and try again.');
});
it('offers a resend-verification action when login reports the account is not verified', async () => {
mockedApi.get.mockResolvedValueOnce({
data: { requireAuth: false, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: false, requireEmailVerification: true },
} as any);
mockedApi.post.mockImplementation((url: string) => {
if (url === '/auth/login') {
return Promise.reject({ response: { status: 403, data: { error: 'email_not_verified' } } });
}
if (url === '/auth/resend-verification-email') {
return Promise.resolve({ data: {} } as any);
}
return Promise.resolve({ data: {} } as any);
});
renderLoginPage();
await screen.findByLabelText('Email');
await userEvent.type(screen.getByLabelText('Email'), 'unverified@example.com');
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }));
expect(await screen.findByText('Please verify your email address before signing in.')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Resend verification email' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/resend-verification-email', { email: 'unverified@example.com' }));
await screen.findByText('Verification email sent. Check your inbox.');
});
});
+7 -20
View File
@@ -1,6 +1,6 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import SettingsView from './components/SettingsView';
@@ -21,16 +21,12 @@ jest.mock('./api', () => ({
}));
jest.mock('./components/ImportExportJobs', () => () => <div>Import Export Stub</div>);
jest.mock('./components/GoogleAuthCard', () => () => <div>Google Auth Stub</div>);
jest.mock('./components/BackupCard', () => () => <div>Backup Stub</div>);
jest.mock('./components/AuthStatusCard', () => () => <div>Auth Status Stub</div>);
const mockedApi = api as jest.Mocked<typeof api>;
function renderView(onAccentColorChange = jest.fn()) {
return {
onAccentColorChange,
...render(
function renderView() {
return render(
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ToastProvider>
<I18nProvider>
@@ -41,15 +37,11 @@ function renderView(onAccentColorChange = jest.fn()) {
onColumnsChange={jest.fn()}
themeMode="dark"
onThemeModeChange={jest.fn()}
accentColor="#15803d"
onAccentColorChange={onAccentColorChange}
onResetAccentColor={jest.fn()}
/>
</I18nProvider>
</ToastProvider>
</MemoryRouter>,
),
};
);
}
beforeEach(() => {
@@ -76,15 +68,10 @@ afterEach(() => {
jest.clearAllMocks();
});
test('settings view uses one follow-up section, one notification section, and staged accent apply', async () => {
const { onAccentColorChange } = renderView();
test('settings view has no accent picker and uses one follow-up section, one notification section', async () => {
renderView();
fireEvent.click(screen.getByRole('button', { name: /#15803D/i }));
const accentInput = (await screen.findAllByLabelText('Accent'))[1] as HTMLInputElement;
fireEvent.change(accentInput, { target: { value: '#2563eb' } });
expect(onAccentColorChange).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
expect(onAccentColorChange).toHaveBeenCalledWith('#2563eb');
expect(screen.queryByText(/accent/i)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /follow-ups/i }));
expect(await screen.findByText(/follow-up rules by scenario/i)).toBeInTheDocument();
+13 -9
View File
@@ -2,6 +2,10 @@ import { alpha, createTheme, darken, lighten } from "@mui/material/styles";
type PaletteLike = Record<string, any>;
// Single global brand accent -- matches the dark sidebar/landing page indigo used throughout
// the app. Not user-configurable; see jobbjakt-nextjs-migration memory / UI rework notes.
const ACCENT = "#6366F1";
function buildPrimary(main: string) {
return {
lighter: lighten(main, 0.82),
@@ -12,7 +16,7 @@ function buildPrimary(main: string) {
};
}
function buildLightPalette(accentColor: string): PaletteLike {
function buildLightPalette(): PaletteLike {
const textPrimary = "#1B1B1F";
const textSecondary = "#46464F";
@@ -24,7 +28,7 @@ function buildLightPalette(accentColor: string): PaletteLike {
const disabledBackground = "#E4E1E6";
return {
primary: buildPrimary(accentColor || "#6366F1"),
primary: buildPrimary(ACCENT),
secondary: {
lighter: "#E0E0FF",
light: "#C3C4E4",
@@ -82,14 +86,14 @@ function buildLightPalette(accentColor: string): PaletteLike {
// from the product mockups; cards/inputs (paper) sit above it.
background: { default: "#F4F6FB", paper: background },
action: {
hover: alpha(accentColor || "#6366F1", 0.05),
hover: alpha(ACCENT, 0.05),
disabled: alpha(disabled, 0.6),
disabledBackground: alpha(disabledBackground, 0.9),
},
};
}
function buildDarkPalette(accentColor: string): PaletteLike {
function buildDarkPalette(): PaletteLike {
const bg = "#0B0B0E";
const paper = "#111116";
const divider = alpha("#FFFFFF", 0.10);
@@ -101,7 +105,7 @@ function buildDarkPalette(accentColor: string): PaletteLike {
const disabledBackground = alpha("#FFFFFF", 0.08);
return {
primary: buildPrimary(accentColor || "#6366F1"),
primary: buildPrimary(ACCENT),
secondary: {
lighter: alpha(secondaryMain, 0.22),
light: alpha(secondaryMain, 0.14),
@@ -157,7 +161,7 @@ function buildDarkPalette(accentColor: string): PaletteLike {
divider,
background: { default: bg, paper },
action: {
hover: alpha(accentColor || "#6366F1", 0.16),
hover: alpha(ACCENT, 0.16),
disabled: alpha("#FFFFFF", 0.5),
disabledBackground,
},
@@ -196,9 +200,9 @@ function buildTypography() {
};
}
export const getTheme = (_mode: "light" | "dark", accentColor: string) => {
const lightPalette = buildLightPalette(accentColor);
const darkPalette = buildDarkPalette(accentColor);
export const getTheme = (_mode: "light" | "dark") => {
const lightPalette = buildLightPalette();
const darkPalette = buildDarkPalette();
const theme = createTheme({
breakpoints: {
-14
View File
@@ -19,17 +19,3 @@ export function getThemeModePref(): ThemeModePref {
export function setThemeModePref(v: ThemeModePref) {
window.localStorage.setItem(k("themeMode"), v);
}
export function getAccentColor(): string {
const raw = window.localStorage.getItem(k("accentColor"));
if (raw && /^#[0-9a-fA-F]{6}$/.test(raw)) return raw;
return "#6366f1";
}
export function setAccentColor(v: string) {
if (v && /^#[0-9a-fA-F]{6}$/.test(v)) window.localStorage.setItem(k("accentColor"), v);
}
export function clearAccentColor() {
window.localStorage.removeItem(k("accentColor"));
}
@@ -0,0 +1,95 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import TwoFactorSettingsCard from './components/TwoFactorSettingsCard';
import { api } from './api';
const mockedApi = api as jest.Mocked<typeof api>;
const writeTextMock = jest.fn(() => Promise.resolve());
Object.assign(navigator, { clipboard: { writeText: writeTextMock } });
Object.defineProperty(window.URL, 'createObjectURL', { writable: true, value: jest.fn(() => 'blob:mock') });
Object.defineProperty(window.URL, 'revokeObjectURL', { writable: true, value: jest.fn() });
function renderCard() {
return render(
<ToastProvider>
<I18nProvider>
<TwoFactorSettingsCard />
</I18nProvider>
</ToastProvider>,
);
}
beforeEach(() => {
jest.clearAllMocks();
mockedApi.get.mockImplementation((url: string) => {
if (url === '/auth/2fa/status') {
return Promise.resolve({ data: { enabled: false, enabledAtUtc: null } } as any);
}
return Promise.resolve({ data: {} } as any);
});
});
test('shows not-enabled status and walks through the full enable flow to recovery codes', async () => {
mockedApi.post.mockImplementation((url: string) => {
if (url === '/auth/2fa/setup') {
return Promise.resolve({ data: { manualEntryKey: 'ABCD1234', qrCodeDataUrl: 'data:image/png;base64,abc' } } as any);
}
if (url === '/auth/2fa/verify-setup') {
return Promise.resolve({ data: { enabled: true, recoveryCodes: ['aaaaa-11111', 'bbbbb-22222'] } } as any);
}
return Promise.resolve({ data: {} } as any);
});
renderCard();
expect(await screen.findByText('Not enabled')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Enable 2FA' }));
await userEvent.type(await screen.findByLabelText('Current password'), 'hunter2');
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/2fa/setup', { currentPassword: 'hunter2' }));
expect(await screen.findByAltText('Scan this QR code')).toHaveAttribute('src', 'data:image/png;base64,abc');
expect(screen.getByDisplayValue('ABCD1234')).toBeInTheDocument();
await userEvent.type(screen.getByLabelText('6-digit code'), '654321');
await userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/2fa/verify-setup', { code: '654321' }));
expect(await screen.findByText('Save your recovery codes')).toBeInTheDocument();
expect(screen.getByText('aaaaa-11111')).toBeInTheDocument();
expect(screen.getByText('bbbbb-22222')).toBeInTheDocument();
const doneButton = screen.getByRole('button', { name: 'Done' });
expect(doneButton).toBeDisabled();
await userEvent.click(screen.getByLabelText("I've saved my recovery codes"));
expect(doneButton).toBeEnabled();
await userEvent.click(doneButton);
await waitFor(() => expect(screen.queryByText('Save your recovery codes')).not.toBeInTheDocument());
});
test('shows wrong-password error on disable and lets the user retry', async () => {
mockedApi.get.mockImplementation((url: string) => {
if (url === '/auth/2fa/status') {
return Promise.resolve({ data: { enabled: true, enabledAtUtc: '2026-01-01T00:00:00Z' } } as any);
}
return Promise.resolve({ data: {} } as any);
});
mockedApi.post.mockRejectedValueOnce({ response: { status: 401 } });
renderCard();
expect(await screen.findByText(/enabled since/i)).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Disable 2FA' }));
await userEvent.type(await screen.findByLabelText('Current password'), 'wrong');
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
expect(await screen.findByText('Incorrect password.')).toBeInTheDocument();
expect(screen.getByLabelText('Current password')).toBeInTheDocument();
});
@@ -0,0 +1,58 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import VerifyEmailPage from './views/VerifyEmailPage';
import { I18nProvider } from './i18n/I18nProvider';
import { api, getApiErrorMessage } from './api';
const mockedApi = api as jest.Mocked<typeof api>;
// CRA's jest config sets resetMocks: true, which wipes the initial implementation given to
// jest.fn() in setupTests.ts before every test -- re-arm it here so error-derived text is testable.
const mockedGetApiErrorMessage = getApiErrorMessage as jest.Mock;
function renderVerifyEmailPage(search: string) {
window.history.pushState({}, '', `/verify-email${search}`);
return render(
<MemoryRouter initialEntries={[`/verify-email${search}`]} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<I18nProvider>
<VerifyEmailPage />
</I18nProvider>
</MemoryRouter>,
);
}
describe('VerifyEmailPage', () => {
beforeEach(() => {
mockedApi.post.mockReset();
mockedGetApiErrorMessage.mockImplementation((e: any, fallback?: string) => {
const data = e?.response?.data;
return typeof data === 'string' && data.trim() ? data.trim() : fallback;
});
});
it('confirms the account and shows success when the link is valid', async () => {
mockedApi.post.mockResolvedValueOnce({ data: {} } as any);
renderVerifyEmailPage('?userId=user-1&token=good-token');
expect(await screen.findByText('Your email has been verified. You can now sign in.')).toBeInTheDocument();
expect(mockedApi.post).toHaveBeenCalledWith('/auth/verify-email', { userId: 'user-1', token: 'good-token' });
});
it('shows an error when the link is invalid or expired', async () => {
mockedApi.post.mockRejectedValueOnce({ response: { status: 400, data: 'Invalid or expired link.' } });
renderVerifyEmailPage('?userId=user-1&token=bad-token');
expect(await screen.findByText('Invalid or expired link.')).toBeInTheDocument();
});
it('shows an error without calling the API when the link is missing userId/token', async () => {
renderVerifyEmailPage('');
expect(await screen.findByText('Missing user/token in link.')).toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled();
});
});
@@ -82,10 +82,11 @@ export default function CorrespondenceInboxPage() {
Cross-job view of imported correspondence and Gmail-linked history.
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip icon={<MailOutlineIcon />} label={`${items.length} items`} variant="outlined" />
<Chip label={`${filteredSummary.linked} linked`} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} />
<Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" />
<Button variant="outlined" size="small" onClick={() => navigate("/correspondence/review")}>Review Gmail queue</Button>
</Box>
</Box>
@@ -138,6 +138,7 @@ export default function GmailReviewPage() {
<Button variant="outlined" onClick={() => void load()} disabled={loading || syncing}>
{loading ? "Loading..." : "Refresh"}
</Button>
<Button variant="text" onClick={() => navigate("/correspondence")}>Back to inbox</Button>
</Box>
</Box>
+1 -1
View File
@@ -50,7 +50,7 @@ export default function LandingPage() {
let active = true;
api
.get("/auth/me")
.then(() => { if (active) navigate("/jobs", { replace: true }); })
.then(() => { if (active) navigate("/dashboard", { replace: true }); })
.catch(() => { if (active) setChecking(false); });
return () => { active = false; };
}, [navigate]);
+65 -7
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { Box, Button, Checkbox, FormControlLabel, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
import { Alert, Box, Button, Checkbox, FormControlLabel, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
import { useLocation, useNavigate } from "react-router-dom";
@@ -8,6 +8,7 @@ import { api, getApiErrorMessage } from "../api";
import { getRememberMePref, setAuthPersistencePreference } from "../auth";
import GoogleAuthCard from "../components/GoogleAuthCard";
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
import TwoFactorChallenge from "../components/TwoFactorChallenge";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
@@ -17,6 +18,7 @@ type AuthConfig = {
microsoftEnabled: boolean;
localEnabled: boolean;
allowRegistration: boolean;
requireEmailVerification: boolean;
};
export default function LoginPage() {
@@ -32,8 +34,12 @@ export default function LoginPage() {
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(() => getRememberMePref());
const [loading, setLoading] = useState(false);
const [pendingToken, setPendingToken] = useState<string | null>(null);
const [emailNotVerified, setEmailNotVerified] = useState(false);
const [resendingVerification, setResendingVerification] = useState(false);
const [verificationResent, setVerificationResent] = useState(false);
const nextPath = (location?.state?.from as string | undefined) ?? "/jobs";
const nextPath = (location?.state?.from as string | undefined) ?? "/dashboard";
useEffect(() => {
api
@@ -42,22 +48,52 @@ export default function LoginPage() {
.catch(() => setCfg(null));
}, []);
async function submit(mode: "login" | "register") {
setLoading(true);
try {
const url = mode === "register" ? "/auth/register" : "/auth/login";
await api.post(url, { email, password, rememberMe });
async function completeLogin() {
setAuthPersistencePreference(rememberMe ? "local" : "session");
await api.get("/auth/me");
toast(t("signedIn"), "success");
navigate(nextPath, { replace: true });
}
async function submit(mode: "login" | "register") {
setLoading(true);
setEmailNotVerified(false);
setVerificationResent(false);
try {
const url = mode === "register" ? "/auth/register" : "/auth/login";
const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string }>(url, { email, password, rememberMe });
if (res.data?.requiresTwoFactor && res.data.pendingToken) {
setPendingToken(res.data.pendingToken);
return;
}
await completeLogin();
if (mode === "register" && cfg?.requireEmailVerification) {
toast(t("registerCheckEmailForVerification"), "info");
}
} catch (e: any) {
if (mode === "login" && e?.response?.data?.error === "email_not_verified") {
setEmailNotVerified(true);
} else {
toast(getApiErrorMessage(e, t("loginFailed")), "error");
}
} finally {
setLoading(false);
}
}
async function resendVerification() {
setResendingVerification(true);
try {
await api.post("/auth/resend-verification-email", { email });
setVerificationResent(true);
toast(t("verificationEmailResent"), "success");
} catch (e: any) {
toast(getApiErrorMessage(e, t("verifyEmailFailed")), "error");
} finally {
setResendingVerification(false);
}
}
const allowReg = cfg?.allowRegistration ?? false;
return (
@@ -80,6 +116,14 @@ export default function LoginPage() {
{cfg?.requireAuth ? t("authRequired") : t("authOptional")}
</Typography>
{pendingToken ? (
<TwoFactorChallenge
pendingToken={pendingToken}
onCancel={() => setPendingToken(null)}
onSuccess={() => { setPendingToken(null); void completeLogin(); }}
/>
) : (
<>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
<Tab label={t("emailAndPassword")} />
<Tab label={t("google")} />
@@ -88,6 +132,18 @@ export default function LoginPage() {
{tab === 0 && (
<Box component="form" onSubmit={(e) => { e.preventDefault(); void submit("login"); }} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{cfg?.requireEmailVerification && emailNotVerified && (
<Alert
severity="warning"
action={
<Button color="inherit" size="small" disabled={resendingVerification || verificationResent} onClick={() => void resendVerification()}>
{verificationResent ? t("verificationEmailResent") : t("resendVerificationEmail")}
</Button>
}
>
{t("emailNotVerified")}
</Alert>
)}
<TextField label={t("profileEmail")} value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" fullWidth />
<TextField label={t("profileCurrentPassword")} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete={allowReg ? "new-password" : "current-password"} type="password" fullWidth />
@@ -127,6 +183,8 @@ export default function LoginPage() {
{tab === 1 && <GoogleAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
{tab === 2 && <MicrosoftAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
</>
)}
</Paper>
</Box>
);
+11
View File
@@ -10,6 +10,10 @@ import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
import { api, getApiErrorMessage } from "../api";
import GoogleAuthCard from "../components/GoogleAuthCard";
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
import AuthStatusCard from "../components/AuthStatusCard";
import TwoFactorSettingsCard from "../components/TwoFactorSettingsCard";
import SessionsSettingsCard from "../components/SessionsSettingsCard";
import EmailProviderConnections from "../components/EmailProviderConnections";
import CropImageDialog from "../components/CropImageDialog";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
@@ -562,8 +566,12 @@ export default function ProfilePage() {
</Box>
</Box>
<AuthStatusCard />
<GoogleAuthCard />
<MicrosoftAuthCard />
<Box sx={{ mt: 2 }}>
<EmailProviderConnections />
</Box>
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<Box sx={{ gridColumn: "1 / -1" }}>
@@ -1342,6 +1350,9 @@ export default function ProfilePage() {
</Button>
</Box>
</Box>
{isLocal ? <TwoFactorSettingsCard /> : null}
{isLocal ? <SessionsSettingsCard /> : null}
</Paper>
);
}
@@ -0,0 +1,75 @@
import React, { useEffect, useState } from "react";
import { Alert, Box, Button, CircularProgress, Paper, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { api, getApiErrorMessage } from "../api";
import { useI18n } from "../i18n/I18nProvider";
type Status = "verifying" | "success" | "error";
export default function VerifyEmailPage() {
const { t } = useI18n();
const navigate = useNavigate();
const [status, setStatus] = useState<Status>("verifying");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const userId = params.get("userId") || "";
const token = params.get("token") || "";
if (!userId || !token) {
setStatus("error");
setErrorMessage(t("missingVerifyLinkInfo"));
return;
}
api
.post("/auth/verify-email", { userId, token })
.then(() => setStatus("success"))
.catch((e: any) => {
setStatus("error");
setErrorMessage(getApiErrorMessage(e, t("verifyEmailFailed")));
});
}, [t]);
return (
<Box
sx={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
p: 2,
background:
"radial-gradient(1200px 700px at 20% 0%, rgba(79,140,255,0.14), transparent 55%), radial-gradient(900px 600px at 80% 20%, rgba(245,158,11,0.10), transparent 55%)",
}}
>
<Paper sx={{ width: "min(520px, 100%)", p: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>
{t("verifyEmailTitle")}
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5, mt: 2 }}>
{status === "verifying" && (
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<CircularProgress size={20} />
<Typography sx={{ color: "text.secondary" }}>{t("verifyEmailVerifying")}</Typography>
</Box>
)}
{status === "success" && <Alert severity="success">{t("verifyEmailSuccess")}</Alert>}
{status === "error" && <Alert severity="error">{errorMessage}</Alert>}
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1 }}>
<Button variant="contained" onClick={() => navigate("/login")}>
{t("backToLogin")}
</Button>
</Box>
</Box>
</Paper>
</Box>
);
}