Phase 4 foundation. A CvVariant is a lens over the master CareerProfile
(section order/visibility, per-item overrides keyed by ItemKey, theme +
builder settings) — it references career data, never duplicates it. One
renderer (ThemedCvRenderer) draws every theme; a theme is pure data
(CvThemeCatalog, 8 professional themes), so adding a theme needs no renderer
change. Autosave version history + non-destructive restore, public CV via
/api/public-cv/{slug} (anonymous, noindex, filter-bypassing owner load), and
an AI-assist endpoint reusing the existing provider abstraction (suggestions
only, never auto-applied).
- Models: CvVariant/CvVariantVersion, CvVariantSettings, CvTheme + catalog
- Services: CvVariantResolver (profile+lens -> render model), ThemedCvRenderer,
CvVariantService, CareerProfileService.LoadStructuredForOwnerAsync (public)
- API: CvVariantController (/api/cv), PublicCvController (/api/public-cv)
- Migration AddCvVariants (2 self-contained tables; verified applied on the
running container), 16 tests (resolver/renderer/service), 296 backend green
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3, versioning UI. The /career overview now shows a collapsible version
history with a Restore action per past version, backed by the versioning API.
Restore reapplies the chosen snapshot as a new version (non-destructive) and
refreshes the profile + completeness in place.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3, version history (list + restore). CareerProfileVersions was already
populated on every save; this makes it usable.
- ICareerProfileService.ListVersionsAsync — the append-only history, newest first,
with the current version flagged.
- RestoreVersionAsync — reapplies a past snapshot NON-DESTRUCTIVELY: it is re-saved
as a new version, so the current state stays in history and the restore is itself
reversible. Syncs the relational children + blob projection like any save.
- Endpoints: GET /career/profile/versions, POST /career/profile/versions/{v}/restore.
Tests (+4): versions listed newest-first with current flagged; restore reapplies
an old snapshot as a new version (history preserved, reversible); restore of a
missing version returns null.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3, frontend. /career now reads and writes the master profile through the
relational source of truth instead of the legacy blob path.
- CareerProfilePage loads GET /career/profile (structured profile from the
relational children + cvText + completeness) and saves PUT /career/profile
({ profile, cvText }). This keeps the relational store authoritative — the
previous PUT /auth/profile blob write left it stale after first load.
- Added a "Profile completeness" overview (percent bar + missing sections) at the
top of /career, from the server scorecard.
- PUT /career/profile now accepts { profile, cvText } so the single /career save
covers both the structured profile and the raw imported text; GET returns cvText.
Tests: career-save asserts the /career/profile payload; new completeness-overview
test; controller tests updated for the request wrapper. 75/76 frontend pass (the
1 failure is the unrelated pre-existing settings-view suite); prod build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3, API layer. GET/PUT /api/career/profile — the endpoint the /career editor
uses to read and write the master profile.
- GET: returns the structured profile (assembled from the relational children,
backfilled from the blob if needed) plus a completeness scorecard.
- PUT: validates limits, persists via CareerProfileService (relational children +
append-only version), then serializes the result into
ApplicationUser.ProfileCvStructureJson so the legacy read paths stay in sync.
Identity fields are untouched (they belong to /profile).
- GET /completeness: just the scorecard, for the overview.
- CareerCompleteness: weighted percent + missing sections.
- CareerProfileValidator: item-count/length limits (abuse guard, NOT completeness
— a work-in-progress profile always saves).
Tests (+4): put/get round-trip + projection sync, completeness, over-limit
rejection, empty WIP profile accepted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3, service layer. CareerProfileService now maintains the relational children
as the source of truth for structured career data, with the StructuredCvProfile
blob kept as a derived projection.
- SaveVersionAsync additionally syncs the relational children (replace-all,
preserving ItemKeys from the blob item ids; SortOrder = array position) and the
LongTailJson (contact, summary, interests, other sections, metadata).
- New LoadStructuredAsync reads the master profile from the relational children,
lazily backfilling from the ProfileJson blob for profiles that predate Phase 3.
- CareerProfileMapper: the two-way projection between relational rows and
StructuredCvProfile.
Tests (+5): round-trip through relational, item-key preservation, wholesale child
replacement (no orphans), backfill from a pre-Phase-3 blob, empty profile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3, schema layer. Relational children of CareerProfile — the editable master
career profile. See docs/architecture/career-profile-model.md.
- New entities (Models/CareerEntities.cs): CareerExperience, CareerEducation,
CareerSkill, CareerProject, CareerCertification, CareerLanguage. Each carries
OwnerUserId (tenant filter), a stable ItemKey (carried from the blob so future
CV variants can reference items), and SortOrder. List fields persist as JSON
string columns via [NotMapped] accessors — plain TEXT, reconciler-friendly.
- CareerProfile gains typed child collections + a LongTailJson column (contact,
summary, interests, achievements, orgs, pubs, courses, custom sections,
metadata). ProfileJson becomes a derived projection for legacy read paths.
- DbContext: DbSets + tenant query filters + ordered indexes; FK/cascade by
convention via the typed collections.
- Migration hand-edited to add only the 6 new tables + LongTailJson; the
scaffolder re-emitted four reconciler-owned tables (AiWorkspaceNotes,
CareerProfiles, InterviewPrepNotes, CareerProfileVersions) which were stripped.
The regenerated snapshot now includes them, closing the drift. Verified against
a copy of the real dev DB: applies cleanly, no data loss.
Long tail (achievements/orgs/pubs/courses) starts as JSON; promotable to
relational later without a source-of-truth change. Source-of-truth flip stays
deferred; the blob is kept as a derived projection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3 foundation: entities, relationships, ownership, source-of-truth, and
snapshot rules for the structured career profile. Relational children
(Experience/Education/Skill/Project/Certification/Language) under CareerProfile;
long tail as JSON; blob (ProfileCvStructureJson) becomes a derived projection for
legacy read paths; lazy non-destructive backfill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add section 4a to docs/architecture/current.md: request flow, data ownership,
API responsibilities, and future extension points for the /profile vs /career
separation completed in Phase 2/2.2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Complete the Phase 2.2 split. Each dedicated component now carries only its own
state, effects, and JSX; the shared-copy duplication from the split checkpoint
is removed.
- ProfilePage (/profile): 1372 -> ~495 lines. Dropped the 700-line master-CV
block, all CV/rewrite/PDF state + helpers + the extraction-run polling effects.
loadProfile now fetches only /auth/me (no runs/jobs). Saves identity only.
- CareerProfilePage (/career): dropped identity fields, password, 2FA/sessions
and their state; loadProfile no longer sets identity fields. Saves the master
profile only. Owns the master-CV editing surface.
Both save through the partial-update PUT /auth/profile, so neither can overwrite
the other's data. The master career profile stays the only editable source of
truth on /career.
Tests: the CV-editing tests in profile-page.test.tsx now render CareerProfilePage
(where that surface lives) — all 5 pass, fixing 4 pre-existing failures that were
caused by the display:none shared block.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2.2 — stop backing /profile and /career from one component behind a
boolean. /career now renders a dedicated CareerProfilePage; /profile keeps
ProfilePage. Each hardcodes its mode and saves only its own concern (identity
vs master profile) via the partial-update endpoint.
This commit is the behaviour-preserving checkpoint: the two components still
share the full implementation (each carries all state, only its own JSX renders).
The per-component pruning that removes the other concern's state/JSX follows in
subsequent commits, verified by tsc at each step.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 — Career/Profile separation. The master career profile is the source of
truth; identity and career data are now saved independently so neither wipes the
other. CV Builder deliberately not built yet.
Backend — PUT /auth/profile is now a partial update:
- null/omitted field -> unchanged; "" -> cleared; value -> set (trimmed).
- Email/UserName never cleared to empty (login identifiers).
This lets /profile save identity fields and /career save the master-profile
fields through the same endpoint without one nulling the other. 4 new tests
cover the data-integrity guarantees (identity save keeps the CV, career save
keeps identity, empty clears, null leaves).
Frontend:
- ProfilePage save payload is now scoped by careerOnly: /career sends only
{ profileCvText, profileCvStructureJson }, /profile sends only identity.
- CareerWorkspacePage: removed the inert "CV Builder" tab (careerView) — Phase 2
establishes the master profile only; the builder is Phase 4.
- Dropped the dead careerView prop.
- Updated the CV-save test to render career mode and assert identity is excluded.
Source-of-truth flip (CareerProfileService authoritative) stays deferred to F5
per the branch design; CareerProfileService keeps mirroring via its dual-write.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Recover the F1 Career Profile foundation + AI-workspace persistence from the
unmerged feature/career-workspace branch, so Phase 2 builds on the documented,
tested target state instead of re-deriving it. Foundation only — CV Builder
commits (variants, ATS badge, rewrite diff) stay deferred per "do not build CV
Builder yet". See docs/career-workspace-branch-assessment.md.
Squashed from 3 branch commits (235e291, 5916f09, 00a035e), resolved against
main + Phase 0:
- CareerProfile + CareerProfileVersion (append-only history), dual-written from
every profile save path via CareerProfileService. ApplicationUser.
ProfileCvStructureJson stays authoritative; the tables mirror it. Stable item
IDs assigned to jobs/education/certifications/projects (the prerequisite for
future variant lineage). CvDateNormalizer for free-text -> YYYY-MM.
- InterviewPrepNote + AiWorkspaceNote: cache AI interview prep / candidate fit /
focus plan keyed by an attachment-context signature, so they stop regenerating
(and re-spending the provider) on every open.
Conflict resolutions (union, favouring current code + Phase 0):
- JobTrackerContext / StartupInitializationExtensions: kept Phase 0's tables and
reconciler blocks, added the career/interview/ai-note tables (both SQLite and
MySQL dialects).
- ProfileCvController: dropped the branch's in-file DTO records (main defines them
in ProfileCvDtos.cs) and the LayoutFamily/AtsRating template fields (deferred
ATS-badge work), keeping main's 7-arg CvTemplateDescriptor.
- JobApplicationsController: kept the branch's cache-check, restored main's
AsNoTracking on the read-only user load.
Tables ship empty (verified dev); nothing to migrate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bring the four Career Workspace documents onto main as the target architecture
for Phases 2-4, and point MASTER_IMPLEMENTATION_GUIDE.md at them. Taken from the
branch tip (later commits refined them). Pure additions — none previously existed
on main.
- cv-builder-competitor-deep-research.md (Novoresume, Reactive Resume, FlowCV,
Teal, Enhancv, Canva, Resume.io, Kickresume; matrix; pricing intelligence).
- cv-builder-product-teardown.md
- career-workspace-product-strategy.md
- career-workspace-implementation-roadmap.md (F0-F5)
MASTER_IMPLEMENTATION_GUIDE.md v1.1: adds a Source-Of-Truth Documents section and
restates the "profile is the source of truth; documents reference snapshots" rule.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unblocks the documented core workflow and closes the AI-service exposure,
without changing existing behaviour.
Job/JobApplication split (additive; see ADR-002):
- New Job entity (the opportunity) with owner-scoped query filter; nullable
JobApplication.JobId FK. Nothing reads Job yet.
- Migration AddJobEntityAndProspectStages, hand-edited to drop reconciler-owned
tables the scaffolder re-emitted; verified against the real dev DB.
Pipeline: 10 internal stages across three concerns kept separate —
PipelineStage (workflow) / PipelineGroup (UI: NotApplied/Active/Closed) /
PipelineCategory (analytics). Adds Saved/Interested/Preparing/Withdrawn;
keeps Waiting and Ghosted. Kanban shows 3 grouped columns; cards keep a stage
chip and full transitions; drag applies only safe transitions (never infers
Ghosted/Withdrawn).
DateApplied nullable + SavedAt. Cleared when leaving Applied so analytics stay
accurate; the discarded date is preserved as an AppliedDateCleared JobEvent.
AI service lockdown: no host port; private ai_internal network (backend is the
only other member); X-Ai-Service-Token required on all non-/health endpoints;
AI_SERVICE_TOKEN mandatory via compose. Verified backend-only against the live
stack.
Also carries two pre-existing working-tree files (views/ProfilePage.tsx,
views/CareerWorkspacePage.tsx) so the tree is clean for the branch integration.
Tests: +40 backend (247 total), +5 sidecar (16), +15 frontend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Active docs/ was stub scaffolding while the real docs sat in docs/_archive/.
Restore and correct them, and record the Phase 0 work.
- docs/architecture/current.md: verified system map (from archived SYSTEM_OVERVIEW,
9 corrections against code).
- docs/research/competitors.md: sourced competitor analysis (from archived
PRODUCT_RESEARCH, feature matrix corrected).
- docs/decisions/ADR-002-job-application-model.md: the Job/JobApplication split.
- docs/application-discovery-report.md, docs/implementation-roadmap.md,
docs/phase-0-foundation-report.md, docs/career-workspace-branch-assessment.md.
- Remove 10 zero-byte placeholder files that advertised content that never existed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Global @media (prefers-reduced-motion: reduce) rule in MuiCssBaseline
collapses every animation/transition duration to near-zero at once --
covers MUI's own Dialog/Menu/Collapse/ripple transitions and every
hover-lift added below, so accessibility doesn't need re-checking
per-component as more motion gets added later.
Dashboard stat tiles and Kanban cards get a subtle hover lift (shadow
deepens, translateY(-2px)/-1px), matching the hover pattern the
landing page's feature cards already had -- these are the two places
a small lift reads as an affordance rather than noise: stat tiles are
dashboard-customization-adjacent, and kanban cards are literally
draggable, so the lift reinforces the existing grab-cursor signal
instead of competing with it. Left everything else alone -- most
cards on this app hold passive content, not something to invite a
hover response.
Last two views/*.tsx files not yet covered this session. Same
floating-shadow page-wrapper treatment as every other screen; the
outlined item-row Paper inside each list stays as-is, matching the
established "list rows stay flat/outlined, the page wrapper floats"
convention (same pattern already used for trusted-devices/sessions
list rows).
The mockup's kanban columns have no border, just a light grey
background -- this instance used multi-line sx formatting
(border/borderColor on separate lines) so it slipped past the earlier
single-line-substring grep sweep. Caught by a follow-up multi-line
search across the whole frontend for the same pattern; nothing else
turned up except a legitimate circular crop-tool boundary in
CropImageDialog, which correctly stays as-is.
Every Dialog/Menu/Popover in the app renders its content via MuiPaper,
which is deliberately kept flat (1px border, no shadow -- Paper is
used too broadly, e.g. as a plain content divider, to safely restyle
globally). That meant every modal (confirm/prompt, AddJobModal,
EditJobDialog, JobDetailsDialog) and every dropdown/Select menu in the
app was still rendering flat-bordered despite every other screen this
session moving to the floating-shadow mockup look.
Added targeted MuiDialog/MuiPopover/MuiMenu paper overrides -- these
win on specificity over MuiPaper's own defaults without touching
MuiPaper itself, so every dialog and dropdown in the app picks up the
rounded floating-shadow treatment from this one change instead of
patching each dialog file individually. Also added MuiChip (full pill
radius, matching every status/skill pill in the mockups) and
MuiTooltip (matching corner radius) overrides, and gave the toast
Snackbar/Alert a consistent radius + weight.
No mockup exists for 404/500 pages, so these follow the visual
language already established elsewhere this session: floating-shadow
card, big bold status number (matching the dashboard stat tiles'
bold-number treatment) instead of a small overline.
JobTable's first-run empty state gets an icon chip matching the
landing page's feature-card icon treatment (rounded square, tinted
primary background) instead of plain text -- the filtered "no results"
one-liner stays as-is, that's a different, correctly minimal case.
Main table Paper wrapper gets the same floating-shadow treatment as
every other card this session.
ViewStateNotice (the shared loading/error component used across the
app) reviewed and left untouched -- it's an MUI Alert used as an inline
banner, which is the correct pattern; it was never a "fake card" to
begin with.
Repo-wide sweep for the same flat 1px-border "fake card" pattern
already fixed in Dashboard/Kanban/JobDetailsDialog/auth pages this
session -- AddJobModal, Attachments, CompaniesTable, Correspondence,
EditJobDialog, and the admin audit/system/users pages all had a table
container or content box using border+divider instead of the
floating-shadow treatment used everywhere else now.
Left AppShell.tsx/App.tsx alone -- their border:1px+divider instances
are icon-button and badge outlines, not card containers; that's a
different, correct use of the pattern.
Login/register, forgot-password, reset-password, verify-email, and the
2FA/sessions settings cards all used a bare MuiPaper (1px border, no
shadow) predating this session's theme foundation. MuiPaper itself
stays untouched (it's a lower-level primitive used too broadly across
the app -- menus, popovers -- to safely restyle globally), so these
specific card instances get the same explicit no-border/floating-shadow
treatment already applied screen-by-screen elsewhere this session.
Static shadow value again, not theme.vars.customShadows -- inline sx
callbacks execute against whatever theme is in context, and none of
this repo's tests wrap components in a ThemeProvider (see fc56f94).
LandingPage.tsx already closely matched the mockup set (dark navy hero,
gradient CTAs, numbered step badges, feature/pricing cards) from an
earlier pass -- nothing structural needed here. Replaced the 4 places
that hand-rolled the same linear-gradient(90deg,#6366f1,#22d3ee) inline
with the shared GradientButton component introduced this session, so
the gradient can't drift out of sync between screens.
Restyle JobDetailsDialog.tsx (04-job-workspace.png mockup) within its
existing dialog/tab structure -- the real app splits Correspondence,
Attachments, and Candidate Fit into separate tabs rather than the
mockup's single-screen 2x2 card grid, so this is a visual-language
pass over the existing IA, not a restructure:
- Header: bolder title (h5/800), heavier status chip, cleaner
no-underline tab styling.
- Every flat bordered "fake card" Box (11 instances across all tabs,
plus the 2 in the Overview strategy-snapshot panel) becomes a
floating shadow card with no border, matching every other screen
redesigned this session.
- The two genuinely AI-generation actions (Generate Strategy Snapshot,
and by extension the shared GradientButton component) get the
mockup's signature gradient CTA treatment; the confirm-gated
"Refresh AI summary" action stays a plain outlined button so the
gradient doesn't get diluted by a second use on the same tab.
Also fixes a real bug surfaced by actually using GradientButton for
the first time: its sx callback read theme.vars.customShadows, which
throws when a component renders without this app's ThemeProvider --
true in production always, but true in every test in this repo (none
of them wrap with a ThemeProvider), so every test touching a
GradientButton or one of these restyled boxes crashed. Fixed by using
a static shadow value instead of a theme.vars lookup in both the
component and this file, matching the fact that inline sx callbacks
execute against whatever theme is in context (unlike theme.components
styleOverrides, which only run when this app's real theme is actually
provided).
Verified: tsc clean, full suite green (65/65, including 4 test files
that render this exact dialog). Live check: booted the backend and
loaded the dashboard through a fresh Next.js dev server + cache
(cleared .next after chasing what turned out to be a stale console-log
history in the Browser pane tooling, not a real compile error) --
confirmed real data renders with no actual runtime errors.
Extract design tokens from the mockup set (F:\Pictures\website\jobtracker\new
dashboard, pipeline, job-workspace, features, workflow screens) into the
central theme so every screen picks the change up automatically:
- Heading weight: h1-h4 go bold/black (800/700) to match the mockups' heavy
display type; h5/h6 stay a lighter semibold so dense screens don't turn
into a wall of black text.
- Card shadow: replace the flat 1px "section" shadow + visible border with a
soft floating shadow and no border, matching how mockup cards sit on the
grey page background.
- Border radius: 10/12/8px -> 14/16/10px across shape/card/button defaults,
matching the mockups' rounder corners.
- New GradientButton component wrapping the mockup's signature indigo->cyan
CTA gradient ("Tailor my CV for this role", "See the interface tour"),
reserved for the single most important AI-assist/hero action per screen.
The dark navy sidebar (#0f172a) already matched the mockups from an earlier
pass -- untouched here.
Verified: tsc clean, full frontend suite green (65/65). Live visual
screenshot verification wasn't possible -- the Browser pane's screenshot
tool times out in this environment; verified structurally via read_page
and the app rendering without console errors instead.
- LoginPage: add client-side email/password validation (inline error +
helperText, matching the 2FA components' established pattern), and a
proper register-mode toggle with a "Confirm password" field. The
brief asked for confirm-password on registration but the page only
had one shared password field; a toggle (mirroring the existing
Tabs-for-mode pattern already used for Google/Microsoft) keeps this
from cluttering the login form for returning users.
- Fix a real bug in ResetPasswordPage: it didn't use the app's
getApiErrorMessage helper, so a non-string error response body would
render as "[object Object]" in the toast. Also add a confirm-password
field and matching client-side validation for parity with register.
- ForgotPasswordPage: add proper email format validation instead of
only checking for non-empty.
- Add matching i18n keys (en/no) for every new validation message.
Verified live end-to-end against a running backend: register-mode
toggle, confirm-password mismatch blocking submission client-side,
and a full registration completing and landing on the dashboard.
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.
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.
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>
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.
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.
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>
- Move inline DTOs to GmailDtos.cs, pure parse helpers to GmailParsing.cs
- Batch per-message existence checks in CreateSuggestedJob/RefreshLinkedThreads
- Remove redundant second pass in RelinkThread, reuse existing HashSet
- Replace ToListAsync+scan with FirstOrDefaultAsync for GmailReviewDecisions lookups
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Move inline DTOs to JobApplicationDtos.cs, pure static helpers to JobApplicationHelpers.cs
- GetStats aggregates server-side (COUNT/GROUP BY) instead of loading the full table
- Cache RuleSettings via IMemoryCache, keyed per-user (RulesEngine.GetSettings falls back
to per-user UserRuleSettings overrides, so a single global cache key would leak settings
across users)
- Add missing AsNoTracking() to read-only GET endpoints (GetAll, GetById, GetBoard,
GetReminders, GetStatusSuggestion, GetMatchScore, GetCandidateFit, GetFocusPlan,
GetInterviewPrep, GetReadiness)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Prod was hard-down: InitializeJobTrackerAsync threw an unhandled
MySqlException ("Specified key was too long; max key length is 3072
bytes") while creating IX_JobApplications_OwnerUserId_FollowUpAt,
which crashed Program.Main before the app could start (surfaced to
users as a 500 on Google sign-in, but really affected every request).
Root cause: this reconciler assumes OwnerUserId is varchar(255), but
the live column was provisioned wider by an earlier EF migration,
close enough to the utf8mb4 3072-byte limit that pairing it with a
second column tips a composite index over.
Fix:
- Prefix-index OwnerUserId at 191 chars (safe under the legacy
767-byte-per-column limit, still far wider than the GUID-like
Identity ids actually stored) in every composite/unique index that
includes it, so index creation no longer depends on the column's
actual declared width.
- Wrap each CREATE INDEX in try/catch + LogWarning instead of letting
it propagate: a schema reconciler is best-effort and one failed
index must never crash startup, matching the existing non-fatal
pattern already used a few lines below for legacy-schema ownership
claims.
Backend build + full test suite (177 passing) verified green.
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.