diff --git a/README.md b/README.md index 1440e41..66b5cef 100644 --- a/README.md +++ b/README.md @@ -17,20 +17,17 @@ https://github.com/user-attachments/assets/06e5e782-47f5-42d0-8b28-b89102d7ea1b ## Quick Start ```bash -# 1. Setup environment -cp .env.example .env - -# 2. Run with Docker +# 1. Run with Docker docker compose up -d --build -# 3. Access Dashboard +# 2. Open the dashboard # http://localhost:3005 ``` -## Setup -Essential variables in `.env`: -- `OPENROUTER_API_KEY`: For job scoring and tailoring. -- `RXRESUME_EMAIL`/`PASSWORD`: To automate PDF exports. +The app will guide you through setup on first launch. The onboarding wizard helps you: +- Connect your OpenRouter API key (for AI scoring/tailoring) +- Add your RxResume credentials (for PDF export) +- Upload your base resume JSON (exported from RxResume) ## Structure - `/orchestrator`: React frontend + Node.js backend & pipeline. @@ -43,14 +40,8 @@ Orchestrator docs here: `documentation/orchestrator.md` ## Read-only mode (Basic Auth) -Set `BASIC_AUTH_USER` and `BASIC_AUTH_PASSWORD` in `.env` to make the app read-only for the public. +You can make the app read-only for the public by setting a username and password in the **Settings** page. After this, all write actions (POST/PATCH/DELETE) require Basic Auth; browsing and viewing remain public. -2. Put your exported RXResume JSON at `resume-generator/base.json`. -3. Start: `docker compose up -d --build` -4. Open: - - Dashboard/UI: `http://localhost:3005` - - API: `http://localhost:3005/api` - - Health: `http://localhost:3005/health` Persistent data lives in `./data` (bind-mounted into the container). diff --git a/docker-compose.yml b/docker-compose.yml index d29e177..5859374 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,10 +14,6 @@ services: volumes: # Persist database and generated PDFs - ./data:/app/data - # Base resume JSON (read-only) - - ./resume-generator/base.json:/app/resume-generator/base.json:ro - env_file: - - .env environment: # Server config - NODE_ENV=production diff --git a/documentation/orchestrator.md b/documentation/orchestrator.md index f235461..e9f57e0 100644 --- a/documentation/orchestrator.md +++ b/documentation/orchestrator.md @@ -38,7 +38,7 @@ Once a job is `ready`, the Ready panel is the "shipping lane": The PDF is generated from: -- The base resume JSON (`resume-generator/base.json`). +- The base resume JSON (uploaded via the Onboarding UI or Settings). - The job description (used for AI tailoring and project selection). - Your tailored summary/headline/skills and selected projects. diff --git a/documentation/self-hosting.md b/documentation/self-hosting.md index 778b074..45dd831 100644 --- a/documentation/self-hosting.md +++ b/documentation/self-hosting.md @@ -1,61 +1,40 @@ # Self-Hosting (Docker Compose) -This project is designed to be self-hostable with a single Docker Compose command. +The easiest way to run JobOps is via Docker Compose. The app is self-configuring and will guide you through the setup on your first visit. ## Prereqs - Docker Desktop or Docker Engine + Compose v2 -- An OpenRouter API key (required for AI scoring and summaries) -- RXResume credentials (only if you want PDF exports) -## 1) Clone and set up environment +## 1) Start the stack -```bash -cp .env.example .env -``` - -Open `.env` and set at least: -- `OPENROUTER_API_KEY` - -Optional but commonly used: -- `RXRESUME_EMAIL`, `RXRESUME_PASSWORD` (for CV PDF generation) -- `UKVISAJOBS_EMAIL`, `UKVISAJOBS_PASSWORD` (if you want to scrape UKVisaJobs) -- `BASIC_AUTH_USER`, `BASIC_AUTH_PASSWORD` (read-only public, auth required for writes) - -## 2) Provide a base resume JSON - -The container mounts a base resume JSON at `resume-generator/base.json`. - -- Create or copy your exported RXResume JSON to: - - `resume-generator/base.json` - -If you do not plan to generate PDFs, you can still provide a minimal JSON file to satisfy the mount. - -## 3) Start the stack +No environment variables are strictly required to start. Simply run: ```bash docker compose up -d --build ``` -This will build a single container that runs the API, UI, scrapers, and resume generator. +This builds a single container that runs the API, UI, scrapers, and resume generator. -## 4) Access the app +## 2) Access the app and Onboard -- Dashboard: http://localhost:3005 -- API: http://localhost:3005/api -- Health: http://localhost:3005/health +Open your browser to: +- **Dashboard**: http://localhost:3005 + +On first launch, you will be greeted by an **Onboarding Wizard**. The app will help you validate and save your configuration: + +1. **Connect AI**: Add your OpenRouter API key (required for job scoring and summaries). +2. **PDF Export**: Add your RxResume credentials (if you want to generate tailored PDFs). +3. **Resume JSON**: Upload your base resume JSON (exported from RxResume). + +The app saves these to its persistent database, so you don't need to manage `.env` files for basic setup. All other settings (like search terms, job sources, and more) can also be configured directly in the UI. ## Persistent data `./data` is bind-mounted into the container. It stores: -- SQLite DB: `data/jobs.db` +- SQLite DB: `data/jobs.db` (contains your API keys and configuration) - Generated PDFs: `data/pdfs/` - -## Common issues - -- First build is slow: Playwright + Camoufox download Firefox during the image build. -- Scraping can be blocked by target sites (LinkedIn/Indeed/UKVisa). Retry or adjust sources. -- Missing `resume-generator/base.json` will break PDF generation (and the mount). +- Resume JSON: Stored internally after upload. ## Updating diff --git a/orchestrator/README.md b/orchestrator/README.md index 12085b3..1adaf7b 100644 --- a/orchestrator/README.md +++ b/orchestrator/README.md @@ -33,7 +33,7 @@ orchestrator/ 2. **Set up environment:** ```bash cp .env.example .env - # Edit .env with your API keys + # The app is self-configuring. You can add keys via the UI Onboarding. ``` 3. **Initialize database:** diff --git a/orchestrator/package-lock.json b/orchestrator/package-lock.json index 9e01d12..7f7c5bf 100644 --- a/orchestrator/package-lock.json +++ b/orchestrator/package-lock.json @@ -15,11 +15,14 @@ "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/vite": "^4.1.18", "better-sqlite3": "^11.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -36,7 +39,6 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", - "tw-animate-css": "^1.4.0", "vaul": "^1.1.2", "zod": "^3.23.8" }, @@ -63,6 +65,7 @@ "tailwindcss": "^4.1.18", "tsc-alias": "^1.8.16", "tsx": "^4.19.2", + "tw-animate-css": "^1.4.0", "typescript": "^5.7.2", "vite": "^6.0.3", "vitest": "^4.0.16" @@ -1244,7 +1247,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1278,7 +1280,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1312,7 +1313,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1440,7 +1440,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1451,7 +1450,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1462,7 +1460,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1472,14 +1469,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1594,6 +1589,7 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", @@ -1959,6 +1955,52 @@ } } }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-menu": { "version": "2.1.16", "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", @@ -2190,6 +2232,38 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", @@ -2224,6 +2298,7 @@ "version": "1.1.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, @@ -2268,6 +2343,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, @@ -2532,7 +2608,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2546,7 +2621,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2560,7 +2634,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2574,7 +2647,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2588,7 +2660,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2602,7 +2673,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2616,7 +2686,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2630,7 +2699,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2644,7 +2712,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2658,7 +2725,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2672,7 +2738,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2686,7 +2751,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2700,7 +2764,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2714,7 +2777,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2728,7 +2790,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2742,7 +2803,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2756,7 +2816,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2770,7 +2829,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2784,7 +2842,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2798,7 +2855,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2812,7 +2868,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2826,7 +2881,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2849,7 +2903,6 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", - "dev": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", @@ -2864,7 +2917,6 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", - "dev": true, "engines": { "node": ">= 10" }, @@ -2890,7 +2942,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "android" @@ -2906,7 +2957,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -2922,7 +2972,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -2938,7 +2987,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -2954,7 +3002,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2970,7 +3017,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2986,7 +3032,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -3002,7 +3047,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -3018,7 +3062,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -3042,7 +3085,6 @@ "cpu": [ "wasm32" ], - "dev": true, "optional": true, "dependencies": { "@emnapi/core": "^1.7.1", @@ -3063,7 +3105,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -3079,7 +3120,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -3101,6 +3141,20 @@ "tailwindcss": "4.1.18" } }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -4141,6 +4195,7 @@ "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", "dependencies": { "clsx": "^2.1.1" }, @@ -4167,6 +4222,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", "engines": { "node": ">=6" } @@ -4707,7 +4763,6 @@ "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", - "dev": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -5018,7 +5073,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -5141,7 +5195,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5264,7 +5317,7 @@ "version": "4.13.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -5328,8 +5381,7 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/has-flag": { "version": "4.0.0", @@ -5689,7 +5741,6 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -5770,7 +5821,6 @@ "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", - "dev": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -5802,7 +5852,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "android" @@ -5822,7 +5871,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -5842,7 +5890,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -5862,7 +5909,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -5882,7 +5928,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5902,7 +5947,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5922,7 +5966,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5942,7 +5985,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5962,7 +6004,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5982,7 +6023,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -6002,7 +6042,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -6050,6 +6089,7 @@ "version": "0.561.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.561.0.tgz", "integrity": "sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==", + "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -6068,7 +6108,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } @@ -7038,7 +7077,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -7255,14 +7293,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -7288,7 +7324,6 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -7861,7 +7896,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -7882,7 +7917,6 @@ "version": "4.53.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -8351,7 +8385,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -8511,6 +8544,7 @@ "version": "3.4.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -8519,7 +8553,8 @@ "node_modules/tailwindcss": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", - "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==" + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "license": "MIT" }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -8533,7 +8568,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "dev": true, "engines": { "node": ">=6" }, @@ -8589,7 +8623,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -8735,7 +8768,7 @@ "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "~0.27.0", @@ -8758,7 +8791,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8775,7 +8807,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8792,7 +8823,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8809,7 +8839,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8826,7 +8855,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8843,7 +8871,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8860,7 +8887,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8877,7 +8903,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8894,7 +8919,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8911,7 +8935,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8928,7 +8951,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8945,7 +8967,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8962,7 +8983,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8979,7 +8999,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8996,7 +9015,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9013,7 +9031,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9030,7 +9047,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9047,7 +9063,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9064,7 +9079,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9081,7 +9095,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9098,7 +9111,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9115,7 +9127,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9132,7 +9143,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9146,7 +9156,7 @@ "version": "0.27.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz", "integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -9200,6 +9210,8 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/Wombosvideo" } @@ -9466,7 +9478,6 @@ "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -9544,7 +9555,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9561,7 +9571,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9578,7 +9587,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9595,7 +9603,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9612,7 +9619,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9629,7 +9635,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9646,7 +9651,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9663,7 +9667,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9680,7 +9683,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9697,7 +9699,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9714,7 +9715,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9731,7 +9731,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9748,7 +9747,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9765,7 +9763,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9782,7 +9779,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9799,7 +9795,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9816,7 +9811,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9833,7 +9827,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9850,7 +9843,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9867,7 +9859,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9884,7 +9875,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9901,7 +9891,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9918,7 +9907,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9935,7 +9923,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9952,7 +9939,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9969,7 +9955,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9983,7 +9968,6 @@ "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { diff --git a/orchestrator/package.json b/orchestrator/package.json index 56c5b4e..1510a6a 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -27,11 +27,14 @@ "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/vite": "^4.1.18", "better-sqlite3": "^11.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -48,7 +51,6 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", - "tw-animate-css": "^1.4.0", "vaul": "^1.1.2", "zod": "^3.23.8" }, @@ -75,6 +77,7 @@ "tailwindcss": "^4.1.18", "tsc-alias": "^1.8.16", "tsx": "^4.19.2", + "tw-animate-css": "^1.4.0", "typescript": "^5.7.2", "vite": "^6.0.3", "vitest": "^4.0.16" diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx index e6ec43b..c5df563 100644 --- a/orchestrator/src/client/App.tsx +++ b/orchestrator/src/client/App.tsx @@ -11,6 +11,7 @@ import { OrchestratorPage } from "./pages/OrchestratorPage"; import { SettingsPage } from "./pages/SettingsPage"; import { UkVisaJobsPage } from "./pages/UkVisaJobsPage"; import { VisaSponsorsPage } from "./pages/VisaSponsorsPage"; +import { OnboardingGate } from "./components/OnboardingGate"; export const App: React.FC = () => { const location = useLocation(); @@ -27,6 +28,7 @@ export const App: React.FC = () => { return ( <> + { return fetchApi('/profile'); } +export async function getProfileStatus(): Promise { + return fetchApi('/profile/status'); +} + +export async function uploadProfile(profile: ResumeProfile): Promise { + return fetchApi('/profile/upload', { + method: 'POST', + body: JSON.stringify({ profile }), + }); +} + +export async function validateOpenrouter(apiKey?: string): Promise { + return fetchApi('/onboarding/validate/openrouter', { + method: 'POST', + body: JSON.stringify({ apiKey }), + }); +} + +export async function validateRxresume(email?: string, password?: string): Promise { + return fetchApi('/onboarding/validate/rxresume', { + method: 'POST', + body: JSON.stringify({ email, password }), + }); +} + +export async function validateResumeJson(): Promise { + return fetchApi('/onboarding/validate/resume'); +} export async function updateSettings(update: { model?: string | null diff --git a/orchestrator/src/client/components/OnboardingGate.tsx b/orchestrator/src/client/components/OnboardingGate.tsx new file mode 100644 index 0000000..c144a64 --- /dev/null +++ b/orchestrator/src/client/components/OnboardingGate.tsx @@ -0,0 +1,501 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { Check } from "lucide-react" +import { toast } from "sonner" + +import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" +import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Progress } from "@/components/ui/progress" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { cn } from "@/lib/utils" +import * as api from "@client/api" +import { useSettings } from "@client/hooks/useSettings" +import { SettingsInput } from "@client/pages/settings/components/SettingsInput" +import { formatSecretHint } from "@client/pages/settings/utils" +import type { ResumeProfile, ValidationResult } from "@shared/types" + +type ValidationState = ValidationResult & { checked: boolean } + +export const OnboardingGate: React.FC = () => { + const { settings, isLoading: settingsLoading, refreshSettings } = useSettings() + const [isSavingEnv, setIsSavingEnv] = useState(false) + const [isUploadingResume, setIsUploadingResume] = useState(false) + const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false) + const [isValidatingRxresume, setIsValidatingRxresume] = useState(false) + const [isValidatingResume, setIsValidatingResume] = useState(false) + const [openrouterValidation, setOpenrouterValidation] = useState({ + valid: false, + message: null, + checked: false, + }) + const [rxresumeValidation, setRxresumeValidation] = useState({ + valid: false, + message: null, + checked: false, + }) + const [resumeValidation, setResumeValidation] = useState({ + valid: false, + message: null, + checked: false, + }) + const [currentStep, setCurrentStep] = useState(null) + + const [openrouterApiKey, setOpenrouterApiKey] = useState("") + const [rxresumeEmail, setRxresumeEmail] = useState("") + const [rxresumePassword, setRxresumePassword] = useState("") + const [resumeFile, setResumeFile] = useState(null) + const fileInputRef = useRef(null) + + const validateResume = useCallback(async () => { + setIsValidatingResume(true) + try { + const result = await api.validateResumeJson() + setResumeValidation({ ...result, checked: true }) + return result + } catch (error) { + const message = error instanceof Error ? error.message : "Resume validation failed" + const result = { valid: false, message } + setResumeValidation({ ...result, checked: true }) + return result + } finally { + setIsValidatingResume(false) + } + }, []) + + const validateOpenrouter = useCallback(async (apiKey?: string) => { + setIsValidatingOpenrouter(true) + try { + const result = await api.validateOpenrouter(apiKey) + setOpenrouterValidation({ ...result, checked: true }) + return result + } catch (error) { + const message = error instanceof Error ? error.message : "OpenRouter validation failed" + const result = { valid: false, message } + setOpenrouterValidation({ ...result, checked: true }) + return result + } finally { + setIsValidatingOpenrouter(false) + } + }, []) + + const validateRxresume = useCallback(async (email?: string, password?: string) => { + setIsValidatingRxresume(true) + try { + const result = await api.validateRxresume(email, password) + setRxresumeValidation({ ...result, checked: true }) + return result + } catch (error) { + const message = error instanceof Error ? error.message : "RxResume validation failed" + const result = { valid: false, message } + setRxresumeValidation({ ...result, checked: true }) + return result + } finally { + setIsValidatingRxresume(false) + } + }, []) + + const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint) + const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim()) + const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint) + const hasBaseResume = resumeValidation.valid + + const shouldOpen = Boolean(settings && !settingsLoading) + && !(openrouterValidation.valid && rxresumeValidation.valid && resumeValidation.valid) + + const openrouterCurrent = settings?.openrouterApiKeyHint + ? formatSecretHint(settings.openrouterApiKeyHint) + : undefined + const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim() + ? settings.rxresumeEmail + : undefined + const rxresumePasswordCurrent = settings?.rxresumePasswordHint + ? formatSecretHint(settings.rxresumePasswordHint) + : undefined + + const steps = useMemo( + () => [ + { + id: "openrouter", + label: "Connect AI", + subtitle: "OpenRouter key", + complete: openrouterValidation.valid, + }, + { + id: "rxresume", + label: "PDF Export", + subtitle: "RxResume login", + complete: rxresumeValidation.valid, + }, + { + id: "resume", + label: "Resume JSON", + subtitle: "Upload your file", + complete: resumeValidation.valid, + }, + ], + [openrouterValidation.valid, resumeValidation.valid, rxresumeValidation.valid] + ) + + const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id + + useEffect(() => { + if (!shouldOpen) return + if (!currentStep && defaultStep) { + setCurrentStep(defaultStep) + } + }, [currentStep, defaultStep, shouldOpen]) + + const runAllValidations = useCallback(async () => { + if (!settings) return + const results = await Promise.allSettled([ + validateOpenrouter(), + validateRxresume(), + validateResume(), + ]) + + const failed = results.find((result) => result.status === "rejected") + if (failed) { + const reason = failed.status === "rejected" ? failed.reason : null + const message = reason instanceof Error ? reason.message : "Validation checks failed" + toast.error(message) + } + }, [settings, validateOpenrouter, validateRxresume, validateResume]) + + useEffect(() => { + if (!settings || settingsLoading) return + if (openrouterValidation.checked || rxresumeValidation.checked || resumeValidation.checked) return + void runAllValidations() + }, [settings, settingsLoading, openrouterValidation.checked, rxresumeValidation.checked, resumeValidation.checked, runAllValidations]) + + const handleRefresh = async () => { + const results = await Promise.allSettled([refreshSettings(), runAllValidations()]) + const failed = results.find((result) => result.status === "rejected") + if (failed) { + const reason = failed.status === "rejected" ? failed.reason : null + const message = reason instanceof Error ? reason.message : "Failed to refresh setup" + toast.error(message) + } + } + + const handleSaveOpenrouter = async (): Promise => { + const openrouterValue = openrouterApiKey.trim() + if (!openrouterValue && !hasOpenrouterKey) { + toast.info("Add your OpenRouter API key to continue") + return false + } + + try { + const validation = await validateOpenrouter(openrouterValue || undefined) + if (!validation.valid) { + toast.error(validation.message || "OpenRouter validation failed") + return false + } + + if (openrouterValue) { + setIsSavingEnv(true) + await api.updateSettings({ openrouterApiKey: openrouterValue }) + await refreshSettings() + setOpenrouterApiKey("") + } + + toast.success("OpenRouter connected") + return true + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to save OpenRouter key" + toast.error(message) + return false + } finally { + setIsSavingEnv(false) + } + } + + const handleSaveRxresume = async (): Promise => { + const emailValue = rxresumeEmail.trim() + const passwordValue = rxresumePassword.trim() + const missing: string[] = [] + + if (!hasRxresumeEmail && !emailValue) missing.push("RxResume email") + if (!hasRxresumePassword && !passwordValue) missing.push("RxResume password") + + if (missing.length > 0) { + toast.info("Almost there", { + description: `Missing: ${missing.join(", ")}`, + }) + return false + } + + try { + const validation = await validateRxresume(emailValue || undefined, passwordValue || undefined) + if (!validation.valid) { + toast.error(validation.message || "RxResume validation failed") + return false + } + + const update: { rxresumeEmail?: string; rxresumePassword?: string } = {} + if (emailValue) update.rxresumeEmail = emailValue + if (passwordValue) update.rxresumePassword = passwordValue + + if (Object.keys(update).length > 0) { + setIsSavingEnv(true) + await api.updateSettings(update) + await refreshSettings() + setRxresumePassword("") + } + + toast.success("RxResume connected") + return true + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to save RxResume credentials" + toast.error(message) + return false + } finally { + setIsSavingEnv(false) + } + } + + const handleUploadResume = async (): Promise => { + if (!resumeFile) { + const validation = await validateResume() + if (!validation.valid) { + toast.info(validation.message || "Upload your resume JSON to continue") + return false + } + + return true + } + + try { + setIsUploadingResume(true) + const text = await resumeFile.text() + let parsed: ResumeProfile + try { + parsed = JSON.parse(text) as ResumeProfile + } catch { + throw new Error("Resume JSON is invalid. Export the base.json from RxResume.") + } + + await api.uploadProfile(parsed) + await validateResume() + setResumeFile(null) + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + toast.success("Resume uploaded") + return true + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to upload resume" + toast.error(message) + return false + } finally { + setIsUploadingResume(false) + } + } + + const resumeFileName = resumeFile?.name || "" + const resolvedStepIndex = currentStep ? steps.findIndex((step) => step.id === currentStep) : 0 + const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0 + const completedSteps = steps.filter((step) => step.complete).length + const progressValue = steps.length > 0 ? Math.round((completedSteps / steps.length) * 100) : 0 + const isBusy = isSavingEnv || isUploadingResume || settingsLoading || isValidatingOpenrouter || isValidatingRxresume || isValidatingResume + const canGoBack = stepIndex > 0 + const primaryLabel = currentStep === "resume" + ? (resumeValidation.valid ? "Finish" : "Upload and validate") + : currentStep === "openrouter" + ? (openrouterValidation.valid ? "Revalidate" : "Validate") + : currentStep === "rxresume" + ? (rxresumeValidation.valid ? "Revalidate" : "Validate") + : "Validate" + + const handlePrimaryAction = async () => { + if (!currentStep) return + if (currentStep === "openrouter") { + await handleSaveOpenrouter() + return + } + if (currentStep === "rxresume") { + await handleSaveRxresume() + return + } + if (currentStep === "resume") { + if (hasBaseResume) { + await handleRefresh() + return + } + await handleUploadResume() + } + } + + const handleBack = () => { + if (!canGoBack) return + setCurrentStep(steps[stepIndex - 1]?.id ?? currentStep) + } + + if (!shouldOpen || !currentStep) return null + + return ( + + event.preventDefault()} + > +
+ + Welcome to Job Ops + + Let’s get your workspace ready. Add your keys and resume once, then the pipeline can run end-to-end. + + + + + + {steps.map((step, index) => { + const isActive = step.id === currentStep + const isComplete = step.complete + + return ( + + + + + {step.label} + {step.subtitle} + + + {isComplete ? : index + 1} + + + + + ) + })} + + + +
+

Connect OpenRouter

+

Used for job scoring, summaries, and tailoring.

+
+ setOpenrouterApiKey(event.target.value), + }} + type="password" + placeholder="sk-or-v1..." + current={openrouterCurrent} + helper="Create a key at openrouter.ai" + disabled={isSavingEnv} + /> +
+ + +
+

Link your RxResume account

+

Used to export tailored PDFs.

+
+
+ setRxresumeEmail(event.target.value), + }} + placeholder="you@example.com" + current={rxresumeEmailCurrent} + disabled={isSavingEnv} + /> + setRxresumePassword(event.target.value), + }} + type="password" + placeholder="Enter password" + current={rxresumePasswordCurrent} + disabled={isSavingEnv} + /> +
+
+ + +
+

Upload your resume JSON

+

Use the JSON export you downloaded from v4.rxresu.me.

+
+
+
+ + setResumeFile(event.target.files?.[0] ?? null)} + disabled={isUploadingResume} + /> + {resumeFileName && ( +

Selected: {resumeFileName}

+ )} +
+
+
+
+ +
+ +
+ + +
+
+ + + +
+ Friendly heads-up: pipelines can be slow or a little flaky in alpha. If anything feels off, open a GitHub issue and + we will take a look.{" "} + + Open an issue + + . +
+
+
+
+ ) +} diff --git a/orchestrator/src/components/ui/alert-dialog.tsx b/orchestrator/src/components/ui/alert-dialog.tsx index fa2b442..6b537d7 100644 --- a/orchestrator/src/components/ui/alert-dialog.tsx +++ b/orchestrator/src/components/ui/alert-dialog.tsx @@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef< ) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...props} + /> + ) +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-slot=field-group]]:gap-4", + className + )} + {...props} + /> + ) +} + +const fieldVariants = cva( + "group/field data-[invalid=true]:text-destructive flex w-full gap-3", + { + variants: { + orientation: { + vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px has-[>[data-slot=field-content]]:items-start", + ], + responsive: [ + "@md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto flex-col [&>*]:w-full [&>.sr-only]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + }, + }, + defaultVariants: { + orientation: "vertical", + }, + } +) + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +