diff --git a/.gitignore b/.gitignore index 5db41f9..679f847 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,4 @@ credentials.json *.pem *.key .credentials +*.hintrc diff --git a/.hintrc b/.hintrc new file mode 100644 index 0000000..aa8de6b --- /dev/null +++ b/.hintrc @@ -0,0 +1,5 @@ +{ + "extends": [ + "development" + ] +} \ No newline at end of file diff --git a/OWASP-SECURITY-AUDIT.md b/OWASP-SECURITY-AUDIT.md deleted file mode 100644 index 65bf030..0000000 --- a/OWASP-SECURITY-AUDIT.md +++ /dev/null @@ -1,96 +0,0 @@ -# OWASP Top 10 Security Review Report — Precept - -## Review Summary -- **Scope:** Precept.Api (ASP.NET Core 10 Web API) + Precept.Web (React/Vite frontend) + Docker Compose + CI/CD -- **Date:** 2026-06-26 -- **Risk summary:** RED High 0 | YELLOW Medium 0 (all fixed) | GREEN Low 0 (all fixed) | PASS 3 - ---- - -## ✅ What Was Fixed - -### A01: Broken Access Control — PASS (No findings) -- Every API endpoint (except `SystemController.Ping` and `TestimonialController.GetPublicTestimonials`) enforces `[Authorize]`. -- All controllers extract the `userId` from the JWT claims (`NameIdentifier` / `sub`) and pass it to the service layer. -- Services scope every database query to the authenticated user (`WHERE a.UserId == userId`). -- **Defense-in-depth:** `PreceptDbContext` applies global query filters (`HasQueryFilter`) so that even if a service forgets to filter by user, the database layer will still enforce isolation. - -### A02: Cryptographic Failures — PASS -- Passwords hashed with ASP.NET Identity (PBKDF2). -- JWT uses HMAC-SHA256 with secret from environment variable. -- Refresh tokens are SHA-256 hashed before storage. - -### A03: Injection — PASS (No findings) -- All database queries use Entity Framework Core with LINQ expressions — no string concatenation or raw SQL. -- `SearchService` uses parameterized `Contains` queries (translated to `LIKE` by EF Core). -- No `Process.Start`, `os.system`, or shell command execution with user input. -- No server-side template rendering; the API returns JSON and the React frontend auto-escapes output. - -### A04: Insecure Design — FIXED -- **Rate limiting added** (`Program.cs`): `auth` policy (10 req/min) and `general` policy (100 req/min) with `[EnableRateLimiting]` on all controllers. -- **Password reset flow added**: `POST /api/auth/forgot-password` and `POST /api/auth/reset-password` endpoints. -- **Input validation added**: `TestimonialDto` now has `[StringLength]` limits (Name 100, Handle 50, Text 2000, AvatarSrc 500). - -### A05: Security Misconfiguration — FIXED -- **Error leakage fixed**: `exception.Message` only returned in **Development**; production gets generic error message. -- **CORS split**: `AllowViteDev` for Development only; `Production` policy with restricted origins/headers/methods for non-dev. -- **Security headers added**: Middleware injects `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `CSP`, `X-XSS-Protection`, `Permissions-Policy`. -- **`AllowedHosts` fixed**: Development uses `localhost;127.0.0.1`; Production uses `your-production-domain.com`. -- **Migrations gated**: `Database.Migrate()` only runs when `IsDevelopment()` OR `RunMigrationsOnStartup: true`. -- **Docker Compose secured**: Credentials now use `${VAR:-default}` syntax with dev defaults; exposed port 5432 marked as **DEV ONLY**. - -### A06: Vulnerable and Outdated Components — FIXED -- **CI dependency scanning added**: `dotnet list package --vulnerable --include-transitive` for NuGet. -- **npm audit added**: New `npm-audit` job in CI with `npm audit --audit-level=moderate` + `npm run build`. - -### A07: Identification and Authentication Failures — FIXED -- **Email verification added**: `POST /api/auth/verify-email` endpoint. Registration generates confirmation token (logged in dev for testing). -- **Password reset added**: `POST /api/auth/forgot-password` generates reset token (logged in dev for testing). -- **Rate limiting on auth**: All auth endpoints (`register`, `login`, `refresh`, `revoke`, `forgot-password`, `reset-password`, `verify-email`, `me`, `profile`) now have `[EnableRateLimiting("auth")]`. -- **localStorage warning**: `api.ts` has OWASP comment documenting the XSS risk and migration path to http-only cookies. - -### A08: Software and Data Integrity Failures — PASS -- No unsafe deserialization (`pickle` etc.). -- No CDN scripts requiring SRI (all bundled via Vite). -- No CI artifact signing (defense-in-depth gap, low priority). - -### A09: Security Logging and Monitoring Failures — PASS -- Serilog structured logging with file rotation. -- Auth events logged (login, register, token rotation, reuse detection). -- No centralized audit logging for data changes (defense-in-depth gap, low priority). -- No alerting for anomalous behavior (defense-in-depth gap, low priority). - -### A10: Server-Side Request Forgery (SSRF) — PASS (No findings) -- No controller accepts user-supplied URLs and makes server-side HTTP requests. -- No webhook registration, image fetching, URL preview, or server-side proxy functionality exists. - ---- - -## 🧪 Dev-Friendly Defaults Preserved - -All fixes maintain zero breaking changes for local development: -- `dotnet run` still auto-migrates (via `appsettings.json` `RunMigrationsOnStartup: true`) -- Vite dev server still connects via CORS (`AllowViteDev` policy in Development) -- Tokens still stored in `localStorage` (with documented OWASP warning and migration path) -- All new endpoints (forgot-password, reset-password, verify-email) log tokens to console for easy testing without an email provider - ---- - -## 🚀 Production Checklist (Before Deploying) - -Update these **3 values** before deploying to production: - -1. **`appsettings.Production.json`** → `AllowedHosts`: Replace `your-production-domain.com` with your actual domain -2. **`Program.cs`** → `Production` CORS policy: Replace `https://your-production-domain.com` with your actual domain -3. **`docker-compose.yml`** → Remove the `ports: - "5432:5432"` block from the `db` service - -**Optional but recommended:** -- Configure an email service (SendGrid/SES) and replace the `// TODO` email comments in `AuthController` -- Migrate access tokens from `localStorage` to http-only cookies (steps documented in `api.ts`) -- Add `Audit.NET` or `EntityFrameworkCore.Triggers` for centralized audit logging -- Integrate with a SIEM for anomalous behavior alerting -- Enable GitHub commit signing and `cosign` for Docker image signing - ---- - -> **Disclaimer:** This audit is based on static code analysis and the OWASP Top 10 2021 checklist. It does not replace dynamic application security testing (DAST), penetration testing, or a full threat model review. For high-security deployments, combine this review with automated scanning (OWASP ZAP, Burp Suite) and a manual penetration test. diff --git a/PRECEPT_OVERVIEW.md b/PRECEPT_OVERVIEW.md new file mode 100644 index 0000000..f6c0ee4 --- /dev/null +++ b/PRECEPT_OVERVIEW.md @@ -0,0 +1,168 @@ +# Precept + +**A career command center for software engineers — story bank, drill engine, and job pipeline tracker.** + +Precept is a self-hostable web application that helps software engineers prepare for interviews and run their job hunt as a structured project rather than a graveyard of browser tabs. It is developer-first: dark-mode, monospace-leaning, keyboard-shoppable, with a real security posture and no telemetry. + +> **Current status:** R1 is shipped — full-stack, secure, containerized, and CI-green. R2 (AI-powered interview intelligence) is in design. + +--- + +## What Precept Does + +Interview prep for engineers is usually fragmented across a Google Doc of STAR stories, a spreadsheet of applications, twenty open JD tabs, and a vague mental model of "things I have built." Precept collapses that into one application with three explicit jobs: + +### 1. Bank Your Stories +Capture both technical and behavioral interview material so you walk into every round with a written corpus to draw from. + +- **Technical Story Bank** — Code snippets + written explanations, tagged across 12 engineering domains: `Auth`, `Database`, `AI`, `ML`, `DevOps`, `Frontend`, `Backend`, `SystemDesign`, `Security`, `Testing`, `Cloud`, `Architecture`. Each story carries a `ConfidenceLevel`. +- **Behavioral Story Bank** — STAR-method (Situation / Task / Action / Result) narratives with free-text tags. + +### 2. Drill Until Recall Is Automatic +A Quiz Mode resurfaces the right story next using a spaced-repetition-style priority: + +- Never-reviewed stories first +- Then lowest-confidence stories (`Panic` → `Shaky`) +- Then oldest-reviewed stories + +Each story is rated on a 5-rung **Confidence Ladder**: + +``` +Panic → Shaky → Okay → Solid → Can Teach +``` + +Rating a story updates its `ConfidenceLevel` and `LastReviewedAt` atomically. + +### 3. Run the Pipeline Like a Project +Track every application through a five-stage status machine with automatic event history, so nothing goes stale and you always know what to chase. + +``` +Applied → Phone Screen → Interviewing → Offer / Rejected / Ghosted +``` + +Every status change writes an `ApplicationEvent`, creating an auditable trajectory for each opportunity. + +--- + +## R1 Feature Set (Live) + +| Feature | Description | +|---------|-------------| +| **Technical Story Bank** | Catalog snippets and explanations across 12 engineering domains with confidence tracking. | +| **Behavioral Story Bank** | STAR-structured narratives with tags. | +| **Quiz Mode** | Spaced-repetition resurfacing with confidence-ladder rating. | +| **JD Skill Mapper** | Persist a job description with a user-supplied keyword list; Precept computes a match score against your Skills inventory and surfaces missing keywords. | +| **Pipeline Tracker** | Five-stage application status machine with automatic event history. | +| **Skills Matrix** | Inventory skills with name, category, proficiency level, and notes; feeds the JD match. | +| **Technical Readiness** | Radar visualization of proficiency per skill category, plotted against an interview-ready threshold, plus gap analysis from saved job descriptions. | +| **Analytics Dashboard** | Story confidence breakdowns, applications by status, response/rejection rates, and average JD match score. | +| **Search** | Cross-entity search over stories, applications, job descriptions, and skills. | +| **Data Export** | `GET /api/dashboard/export` returns your entire dataset as JSON — no lock-in. | +| **Testimonials** | Authenticated users can submit public testimonials for the landing page. | + +All endpoints are user-scoped (`[Authorize]` + `WHERE UserId = current_user`) and rate-limited. + +--- + +## Architecture at a Glance + +``` +Precept.Web → Vite + React 19 + TypeScript + Tailwind v4 + ↓ + nginx (production) / Vite dev server (development) + ↓ +Precept.Api → ASP.NET Core 10 + EF Core 10 + PostgreSQL 18 +``` + +### Tech Stack + +| Layer | Technology | +|-------|------------| +| **Backend** | ASP.NET Core 10, C# 13, EF Core 10, Npgsql, ASP.NET Core Identity, JWT bearer, `System.Threading.RateLimiting`, Serilog, Scalar | +| **Frontend** | React 19, TypeScript, Vite 6, Tailwind v4, GSAP, Framer Motion, Recharts, lenis, lucide-react, React Router 7 | +| **Database** | PostgreSQL 18 with EF Core migrations committed to source control | +| **Tests** | xUnit + Testcontainers for .NET (52+ DB-backed integration and unit tests) | +| **CI** | GitHub Actions: build, test, vulnerable-package scan, `npm audit` | +| **Deployment** | Multi-stage Dockerfiles + `docker-compose.yml` for db → api → web | + +--- + +## Security Posture + +Precept handles personal career data, so the security model is intentionally overbuilt: + +- **Passwords** — PBKDF2 via ASP.NET Core Identity (≥8 chars, upper/lower/digit/symbol) +- **Lockout** — 5 failed attempts → 15-minute lockout +- **Access tokens** — JWT bearer, HMAC-SHA256, 15-minute expiry, zero clock skew +- **Refresh tokens** — 64-byte CSPRNG, only SHA-256 hashes persisted, stored in `HttpOnly` + `Secure` + `SameSite=Strict` cookies +- **Refresh-token rotation (RTR)** — every refresh invalidates the spent token and issues a new one atomically +- **Replay detection** — benign concurrent retries get a soft 401; confirmed replays cascade-revoke every active session for the identity +- **Rate limiting** — `auth` policy 10 req/min, `general` policy 100 req/min +- **Tenant isolation** — global EF Core query filters ensure users can only see their own data +- **CORS & security headers** — environment-gated strict policies in production + +A full OWASP Top 10 audit is documented in `OWASP-SECURITY-AUDIT.md`, and the auth architecture is detailed in `auth_reuse_detection_cascade_revocation.md`. + +--- + +## Roadmap + +### R2 — AI-Assisted Interview Intelligence + +The goal is not to ship an LLM wrapper, but to ship LLM features that are **cheap, observable, and abuse-resistant** under a freemium model. The hard constraint is unit economics: + +| Constraint | Target | +|------------|--------| +| Marginal LLM cost per mock-interview session | ≤ **$0.005** end-to-end | +| Free-tier sessions per user | Rate-limited to **2 / month** | +| Paid extension | Non-expiring credit packs, ledger-backed (atomic decrement, audit trail) | +| Free-tier STT | Browser-native Web Speech API (zero server cost) | +| Free-tier TTS | `SpeechSynthesisUtterance` (zero server cost) | +| Kill switch | `AI_FEATURES_ENABLED` env flag for one-config disable | + +**Planned R2 features:** + +- **AI Mock Interviewer** — Small-model question generation (Gemini Flash / Claude Haiku tier) tailored to the user's resume + a job description, with prompt caching for static context and a per-session server-side token budget. +- **Voice mock rounds** — Browser-native STT/TTS for free tier; optional `whisper-1` for paid users. +- **Scored feedback** — Structured rubric (Structure / Specificity / Conciseness) returned per response and persisted against the relevant Story for the spaced-repetition loop. +- **Resume parser** — Server-side PDF/DOCX → Skills inventory + JD match auto-fill. +- **Operational tooling** — Per-user spend ledger (`{user_id, session_id, input_tokens, output_tokens, model, cost_usd}`), admin `/spend` page, and a CI budget-regression check. + +### R3 — Platform Expansion + +Aspirational; not in active development. + +- Native desktop client (Tauri) +- Companion mobile client +- **Team Mode** — shared story banks and peer mocks for engineering teams + +--- + +## Why Precept Exists + +The wedge against generic job trackers (Teal, Huntr, Simplify) is the second job: those tools track applications, but Precept also makes you interview-ready. It was built by a developer who needed it — a new grad with no internship experience trying to land a first SWE role — and realized a spreadsheet was not enough. + +Precept is a personal project. It is not a funded startup or a hosted commercial service (yet), and it is not affiliated with any employer. It is MIT-licensed and self-hostable by anyone who finds it useful. + +--- + +## Get Started + +```bash +git clone https://github.com/austinchima/Precept.git +cd Precept +# Create a .env file with POSTGRES_*, JWT_SECRET_KEY, and optional CORS_ORIGINS +docker compose up -d --build +``` + +- Web: http://localhost +- API: http://localhost:8080 +- API health: http://localhost:8080/api/health + +For local development with hot reload, see `README.md`. + +--- + +## License + +MIT — fork it, run it, adapt it for your own job hunt. diff --git a/Precept.Api/Controllers/AuthController.cs b/Precept.Api/Controllers/AuthController.cs index 0ec02a6..d5250b8 100644 --- a/Precept.Api/Controllers/AuthController.cs +++ b/Precept.Api/Controllers/AuthController.cs @@ -27,6 +27,9 @@ public class AuthController( { private readonly JwtSettings _jwtSettings = jwtSettings.Value; + private static string NormalizeEmail(string email) => + new(email.Where(c => !char.IsWhiteSpace(c)).ToArray()); + // ───────────────────────────────────────────────────────────── // POST /api/auth/register // ───────────────────────────────────────────────────────────── @@ -42,6 +45,8 @@ public async Task Register([FromBody] RegisterRequest request) if (!ModelState.IsValid) return BadRequest(ModelState); + request.Email = NormalizeEmail(request.Email); + // Check if a user with this email already exists var existingUser = await userManager.FindByEmailAsync(request.Email); if (existingUser != null) @@ -101,6 +106,8 @@ public async Task Login([FromBody] LoginRequest request) if (!ModelState.IsValid) return BadRequest(ModelState); + request.Email = NormalizeEmail(request.Email); + // 1. Find user by email (ASP.NET Identity) var user = await userManager.FindByEmailAsync(request.Email); if (user == null) @@ -348,6 +355,8 @@ public async Task ForgotPassword([FromBody] ForgotPasswordRequest if (!ModelState.IsValid) return BadRequest(ModelState); + request.Email = NormalizeEmail(request.Email); + var user = await userManager.FindByEmailAsync(request.Email); if (user == null) { @@ -383,6 +392,8 @@ public async Task ResetPassword([FromBody] ResetPasswordRequest r if (!ModelState.IsValid) return BadRequest(ModelState); + request.Email = NormalizeEmail(request.Email); + var user = await userManager.FindByEmailAsync(request.Email); if (user == null) return BadRequest(new { message = "Invalid request." }); @@ -414,6 +425,8 @@ public async Task VerifyEmail([FromBody] VerifyEmailRequest reque if (!ModelState.IsValid) return BadRequest(ModelState); + request.Email = NormalizeEmail(request.Email); + var user = await userManager.FindByEmailAsync(request.Email); if (user == null) return BadRequest(new { message = "Invalid request." }); diff --git a/Precept.Api/DTOs/AuthDtos.cs b/Precept.Api/DTOs/AuthDtos.cs index 2148532..a37d8fc 100644 --- a/Precept.Api/DTOs/AuthDtos.cs +++ b/Precept.Api/DTOs/AuthDtos.cs @@ -2,6 +2,17 @@ namespace Precept.Api.DTOs; +public static class AuthValidationConstants +{ + /// + /// Requires a local part, an @, a domain with at least one dot, and a TLD + /// of at least two characters. Whitespace is not matched, so it is rejected. + /// + public const string StrictEmailPattern = @"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"; + + public const string StrictEmailErrorMessage = "Email must be a valid address with a domain and TLD (e.g. user@example.com)."; +} + /// /// Request body for POST /api/auth/register. /// @@ -17,6 +28,7 @@ public class RegisterRequest [Required] [EmailAddress] + [RegularExpression(AuthValidationConstants.StrictEmailPattern, ErrorMessage = AuthValidationConstants.StrictEmailErrorMessage)] public string Email { get; set; } = string.Empty; [Required] @@ -46,6 +58,7 @@ public class LoginRequest { [Required] [EmailAddress] + [RegularExpression(AuthValidationConstants.StrictEmailPattern, ErrorMessage = AuthValidationConstants.StrictEmailErrorMessage)] public string Email { get; set; } = string.Empty; [Required] @@ -73,6 +86,7 @@ public class ForgotPasswordRequest { [Required] [EmailAddress] + [RegularExpression(AuthValidationConstants.StrictEmailPattern, ErrorMessage = AuthValidationConstants.StrictEmailErrorMessage)] public string Email { get; set; } = string.Empty; } @@ -83,6 +97,7 @@ public class ResetPasswordRequest { [Required] [EmailAddress] + [RegularExpression(AuthValidationConstants.StrictEmailPattern, ErrorMessage = AuthValidationConstants.StrictEmailErrorMessage)] public string Email { get; set; } = string.Empty; [Required] @@ -100,6 +115,7 @@ public class VerifyEmailRequest { [Required] [EmailAddress] + [RegularExpression(AuthValidationConstants.StrictEmailPattern, ErrorMessage = AuthValidationConstants.StrictEmailErrorMessage)] public string Email { get; set; } = string.Empty; [Required] diff --git a/Precept.Api/Precept.Api.csproj b/Precept.Api/Precept.Api.csproj index e700cdf..e4d2419 100644 --- a/Precept.Api/Precept.Api.csproj +++ b/Precept.Api/Precept.Api.csproj @@ -12,6 +12,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Precept.Tests/Integration/AuthEndpointTests.cs b/Precept.Tests/Integration/AuthEndpointTests.cs index a4fc3f0..5c0ebe5 100644 --- a/Precept.Tests/Integration/AuthEndpointTests.cs +++ b/Precept.Tests/Integration/AuthEndpointTests.cs @@ -103,6 +103,18 @@ public async Task Register_Returns400_WithWeakPassword(string weakPassword, stri response.StatusCode.Should().Be(HttpStatusCode.BadRequest, reason); } + [Theory] + [InlineData("userexample@email", "missing TLD dot")] + [InlineData("user@example", "missing TLD dot")] + [InlineData("user@example.c", "TLD too short")] + [InlineData("user example@example.com", "contains whitespace")] + [InlineData("user@ example.com", "contains whitespace")] + public async Task Register_Returns400_WithInvalidEmail(string invalidEmail, string reason) + { + var (response, _) = await RegisterAsync(email: invalidEmail); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest, reason); + } + [Fact] public async Task Register_PersistsHashedToken_NotRawCookieValue() { @@ -154,6 +166,19 @@ public async Task Login_Returns401_WithWrongPassword() response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } + [Theory] + [InlineData("userexample@email")] + [InlineData("user@example")] + [InlineData("user @example.com")] + public async Task Login_Returns400_WithInvalidEmail(string invalidEmail) + { + var client = _factory.CreateAnonymousClient(); + var response = await client.PostAsJsonAsync("/api/auth/login", + new { Email = invalidEmail, Password = "ValidPass123!" }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + // ───────────────────────────────────────────────────────────── // Token Refresh — Happy Path (must pass before cascade tests) // ───────────────────────────────────────────────────────────── diff --git a/Precept.Web/index.html b/Precept.Web/index.html index 88fca2c..240e9b6 100644 --- a/Precept.Web/index.html +++ b/Precept.Web/index.html @@ -6,7 +6,7 @@ - + diff --git a/Precept.Web/package-lock.json b/Precept.Web/package-lock.json index 059bed2..24e4c12 100644 --- a/Precept.Web/package-lock.json +++ b/Precept.Web/package-lock.json @@ -35,6 +35,8 @@ "@types/canvas-confetti": "^1.9.0", "@types/express": "^4.17.21", "@types/node": "^22.14.0", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", "autoprefixer": "^10.4.21", "esbuild": "^0.25.0", "tailwindcss": "^4.1.14", @@ -1672,6 +1674,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -2002,6 +2024,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", diff --git a/Precept.Web/package.json b/Precept.Web/package.json index 8cd2694..bc90b5d 100644 --- a/Precept.Web/package.json +++ b/Precept.Web/package.json @@ -38,6 +38,8 @@ "@types/canvas-confetti": "^1.9.0", "@types/express": "^4.17.21", "@types/node": "^22.14.0", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", "autoprefixer": "^10.4.21", "esbuild": "^0.25.0", "tailwindcss": "^4.1.14", diff --git a/Precept.Web/src/api.ts b/Precept.Web/src/api.ts index 6a15e42..40663d7 100644 --- a/Precept.Web/src/api.ts +++ b/Precept.Web/src/api.ts @@ -44,13 +44,25 @@ function onRefreshed(token: string) { refreshSubscribers = []; } +function isNetworkError(err: unknown): boolean { + return err instanceof TypeError || (err instanceof Error && /fetch|network|failed/i.test(err.message)); +} + async function refreshAccessToken(): Promise { - const response = await fetch('/api/auth/refresh', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }); + let response: Response; + try { + response = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (err) { + if (isNetworkError(err)) { + throw new Error('Unable to reach the server. Please check your connection and try again.'); + } + throw err; + } if (!response.ok) { try { @@ -102,7 +114,15 @@ export async function apiFetch(url: string, options: RequestOptions = {}): Promi headers, }; - const response = await fetch(url, config); + let response: Response; + try { + response = await fetch(url, config); + } catch (err) { + if (isNetworkError(err)) { + throw new Error('Unable to reach the server. Please check your connection and try again.'); + } + throw err; + } if (response.status === 401 && !options.skipAuth) { // If already refreshing, wait for it to finish diff --git a/Precept.Web/src/components/Layout.tsx b/Precept.Web/src/components/Layout.tsx index 31536ff..9d4fb53 100644 --- a/Precept.Web/src/components/Layout.tsx +++ b/Precept.Web/src/components/Layout.tsx @@ -3,6 +3,7 @@ import { NavLink, Outlet, useNavigate } from 'react-router-dom'; import { useAuth } from '../AuthContext'; import { gsap, useGSAP, prefersReducedMotion } from '../lib/animations'; import CommandPalette from './ui/CommandPalette'; +import { Terminal } from 'lucide-react'; /* ─────── DESIGN TOKENS (from Landing.tsx) ─────── */ const C = { @@ -122,15 +123,12 @@ export default function Layout() { onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.04)')} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} > - - P - + {!isSidebarCollapsed && (
@@ -257,6 +255,7 @@ export default function Layout() {
{/* mobile burger */}
diff --git a/Precept.Web/src/components/stories/BehavioralStoryForm.tsx b/Precept.Web/src/components/stories/BehavioralStoryForm.tsx index 718f24e..10967fd 100644 --- a/Precept.Web/src/components/stories/BehavioralStoryForm.tsx +++ b/Precept.Web/src/components/stories/BehavioralStoryForm.tsx @@ -55,14 +55,14 @@ export const BehavioralStoryForm: React.FC = ({ story, return (
{/* Top accent */} -
+

{story ? 'Edit Behavioral Story' : 'New Behavioral Story'}

-
diff --git a/Precept.Web/src/components/ui/8bit-not-found1.tsx b/Precept.Web/src/components/ui/8bit-not-found1.tsx index 5673bc8..47cca35 100644 --- a/Precept.Web/src/components/ui/8bit-not-found1.tsx +++ b/Precept.Web/src/components/ui/8bit-not-found1.tsx @@ -139,7 +139,7 @@ export default function NotFound1({ (nodeRefs.current[item.id] = el)} + ref={(el) => { nodeRefs.current[item.id] = el; }} className="absolute transition-all duration-700 ease-[cubic-bezier(0.23,1,0.32,1)] cursor-pointer" style={nodeStyle} onClick={(e) => { diff --git a/Precept.Web/src/components/ui/sign-in.tsx b/Precept.Web/src/components/ui/sign-in.tsx index b48d255..cc23ddb 100644 --- a/Precept.Web/src/components/ui/sign-in.tsx +++ b/Precept.Web/src/components/ui/sign-in.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { ArrowUpRight, Mail, Lock, Eye, EyeOff, ArrowLeft, ArrowRight, AlertTriangle, Loader2 } from 'lucide-react'; +import { ArrowUpRight, Mail, Lock, Eye, EyeOff, ArrowLeft, ArrowRight, AlertTriangle, Loader2, Terminal } from 'lucide-react'; /* ─────── DESIGN TOKENS (from Landing.tsx) ─────── */ const C = { @@ -141,15 +141,7 @@ export const SignInPage: React.FC = ({ {/* brand row */} diff --git a/Precept.Web/src/index.css b/Precept.Web/src/index.css index b60efb3..72ee7d4 100644 --- a/Precept.Web/src/index.css +++ b/Precept.Web/src/index.css @@ -464,8 +464,8 @@ font-family: "Geist", "Inter", ui-sans-serif, system-ui, sans-serif; } .font-editorial { - font-family: "Instrument Serif", "Times New Roman", serif; - font-style: italic; + font-family: "Fira Code", "JetBrains Mono", ui-monospace, monospace; + font-style: normal; letter-spacing: -0.01em; } /* IDE-style dot grid for landing backgrounds */ diff --git a/Precept.Web/src/pages/AppTracker.tsx b/Precept.Web/src/pages/AppTracker.tsx index 063b551..0149012 100644 --- a/Precept.Web/src/pages/AppTracker.tsx +++ b/Precept.Web/src/pages/AppTracker.tsx @@ -279,6 +279,8 @@ export default function AppTracker() { ].map((opt) => (
-
- setStatus(e.target.value as ApplicationStatus)} style={inputStyle}> {COLUMNS.map((c) => )} - setSalaryRange(e.target.value)} placeholder="$120k – $140k" style={inputStyle} /> + setSalaryRange(e.target.value)} placeholder="$120k – $140k" style={inputStyle} />
@@ -530,11 +533,11 @@ export default function AppTracker() {
- setDateApplied(e.target.value)} style={inputStyle} /> - setDateLastContact(e.target.value)} style={inputStyle} /> + setDateApplied(e.target.value)} style={inputStyle} /> + setDateLastContact(e.target.value)} style={inputStyle} />
- setJobDescriptionId(e.target.value)} style={inputStyle}> {jds.map((jd) => ( diff --git a/Precept.Web/src/pages/Dashboard.tsx b/Precept.Web/src/pages/Dashboard.tsx index 3056145..6880b3b 100644 --- a/Precept.Web/src/pages/Dashboard.tsx +++ b/Precept.Web/src/pages/Dashboard.tsx @@ -352,13 +352,14 @@ export default function Dashboard() { STAR Method - {activeSTARStory.company || 'General'} + {activeSTARStory.tags || 'General'}

{activeSTARStory.title}

Situation: {activeSTARStory.situation}

+

Task: {activeSTARStory.task}

Action: {activeSTARStory.action}

Result: {activeSTARStory.result}

@@ -450,6 +451,7 @@ export default function Dashboard() { {/* Interactive Quick Status Changer */}
e.stopPropagation()}> setProfileFirstName(e.target.value)} style={inputStyle} required data-testid="settings-firstname" /> + setProfileFirstName(e.target.value)} style={inputStyle} required data-testid="settings-firstname" /> - setProfileLastName(e.target.value)} style={inputStyle} required data-testid="settings-lastname" /> + setProfileLastName(e.target.value)} style={inputStyle} required data-testid="settings-lastname" />
- @@ -329,13 +329,13 @@ export default function StoryBank() {
- setCategory(e.target.value as StoryCategory)} style={inputStyle}> {CATEGORIES.slice(1).map((c) => )}
- setSourceProject(e.target.value)} placeholder="e.g. Apollo" style={inputStyle} /> + setSourceProject(e.target.value)} placeholder="e.g. Apollo" style={inputStyle} />
@@ -361,7 +361,7 @@ export default function StoryBank() {
-