From 7f7e76dc3f1b02f9a4ccc8f02f06871875513735 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Thu, 22 Jan 2026 18:57:11 +0000 Subject: [PATCH] Initial commit for UI --- docker-compose.yml | 2 - orchestrator/src/client/App.tsx | 2 + orchestrator/src/client/api/client.ts | 12 +- .../src/client/components/OnboardingGate.tsx | 339 ++++++++++++++++++ orchestrator/src/server/api/routes/profile.ts | 39 +- orchestrator/src/server/app.ts | 2 +- orchestrator/src/server/services/settings.ts | 5 +- orchestrator/src/shared/types.ts | 5 + 8 files changed, 400 insertions(+), 6 deletions(-) create mode 100644 orchestrator/src/client/components/OnboardingGate.tsx diff --git a/docker-compose.yml b/docker-compose.yml index d29e177..203c2d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,8 +16,6 @@ services: - ./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/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 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..795b3bd --- /dev/null +++ b/orchestrator/src/client/components/OnboardingGate.tsx @@ -0,0 +1,339 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { toast } from "sonner" + +import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +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 { ProfileStatusResponse, ResumeProfile } from "@shared/types" + +type RequirementRowProps = { + label: string + helper?: string + complete: boolean +} + +const RequirementRow: React.FC = ({ label, helper, complete }) => ( +
+
+

{label}

+ {helper &&

{helper}

} +
+ + {complete ? "Ready" : "Next"} + +
+) + +export const OnboardingGate: React.FC = () => { + const { settings, isLoading: settingsLoading, refreshSettings } = useSettings() + const [profileStatus, setProfileStatus] = useState(null) + const [isCheckingProfile, setIsCheckingProfile] = useState(false) + const [isSavingEnv, setIsSavingEnv] = useState(false) + const [isUploadingResume, setIsUploadingResume] = useState(false) + + const [openrouterApiKey, setOpenrouterApiKey] = useState("") + const [rxresumeEmail, setRxresumeEmail] = useState("") + const [rxresumePassword, setRxresumePassword] = useState("") + const [resumeFile, setResumeFile] = useState(null) + const fileInputRef = useRef(null) + + const refreshProfileStatus = useCallback(async () => { + setIsCheckingProfile(true) + try { + const status = await api.getProfileStatus() + setProfileStatus(status) + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to check base resume" + setProfileStatus({ exists: false, error: message }) + } finally { + setIsCheckingProfile(false) + } + }, []) + + useEffect(() => { + void refreshProfileStatus() + }, [refreshProfileStatus]) + + const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint) + const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim()) + const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint) + const hasRxresumeCredentials = hasRxresumeEmail && hasRxresumePassword + const hasBaseResume = Boolean(profileStatus?.exists) + + const shouldOpen = Boolean(settings && profileStatus && !settingsLoading && !isCheckingProfile) + && !(hasOpenrouterKey && hasRxresumeCredentials && hasBaseResume) + + 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 handleRefresh = async () => { + const results = await Promise.allSettled([refreshSettings(), refreshProfileStatus()]) + 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 handleSaveCredentials = async () => { + if (!settings) return + const update: { openrouterApiKey?: string; rxresumeEmail?: string; rxresumePassword?: string } = {} + const openrouterValue = openrouterApiKey.trim() + const emailValue = rxresumeEmail.trim() + const passwordValue = rxresumePassword.trim() + + const missing: string[] = [] + + if (!hasOpenrouterKey && !openrouterValue) { + missing.push("OpenRouter API key") + } + + if (!hasRxresumeCredentials) { + 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 + } + + if (openrouterValue) update.openrouterApiKey = openrouterValue + if (emailValue) update.rxresumeEmail = emailValue + if (passwordValue) update.rxresumePassword = passwordValue + + if (Object.keys(update).length === 0) { + toast.info("Nothing new to save") + return + } + + try { + setIsSavingEnv(true) + await api.updateSettings(update) + await refreshSettings() + setOpenrouterApiKey("") + setRxresumePassword("") + toast.success("Credentials saved") + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to save credentials" + toast.error(message) + } finally { + setIsSavingEnv(false) + } + } + + const handleUploadResume = async () => { + if (!resumeFile) { + toast.info("Choose your base.json file") + return + } + + 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 refreshProfileStatus() + setResumeFile(null) + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + toast.success("Resume uploaded") + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to upload resume" + toast.error(message) + } finally { + setIsUploadingResume(false) + } + } + + const resumeFileName = resumeFile?.name || "" + + const checklist = useMemo( + () => [ + { + label: "OpenRouter API key", + helper: "Needed for scoring + tailoring", + complete: hasOpenrouterKey, + }, + { + label: "RxResume credentials", + helper: "Used to export PDFs", + complete: hasRxresumeCredentials, + }, + { + label: "Base resume JSON", + helper: "Upload resume-generator/base.json", + complete: hasBaseResume, + }, + ], + [hasBaseResume, hasOpenrouterKey, hasRxresumeCredentials] + ) + + if (!shouldOpen) return null + + return ( + + event.preventDefault()} + onPointerDownOutside={(event) => event.preventDefault()} + onInteractOutside={(event) => 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. + + + +
+
+
+

Quick setup checklist

+ +
+
+ {checklist.map((item) => ( + + ))} +
+
+ + + +
+
+

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} + /> +
+ + + +
+
+

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} + /> +
+
+ +
+
+ + + +
+
+

Base resume JSON

+

Upload your RxResume export named base.json.

+
+
+
+ + 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/server/api/routes/profile.ts b/orchestrator/src/server/api/routes/profile.ts index e802cd0..ad531b9 100644 --- a/orchestrator/src/server/api/routes/profile.ts +++ b/orchestrator/src/server/api/routes/profile.ts @@ -1,6 +1,8 @@ import { Router, Request, Response } from 'express'; +import { access, mkdir, writeFile } from 'fs/promises'; +import { dirname } from 'path'; import { extractProjectsFromProfile } from '../../services/resumeProjects.js'; -import { getProfile } from '../../services/profile.js'; +import { clearProfileCache, DEFAULT_PROFILE_PATH, getProfile } from '../../services/profile.js'; export const profileRouter = Router(); @@ -30,3 +32,38 @@ profileRouter.get('/', async (req: Request, res: Response) => { res.status(500).json({ success: false, error: message }); } }); + +/** + * GET /api/profile/status - Check if base resume exists + */ +profileRouter.get('/status', async (_req: Request, res: Response) => { + try { + await access(DEFAULT_PROFILE_PATH); + res.json({ success: true, data: { exists: true, error: null } }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + res.json({ success: true, data: { exists: false, error: message } }); + } +}); + +/** + * POST /api/profile/upload - Upload base resume JSON + */ +profileRouter.post('/upload', async (req: Request, res: Response) => { + try { + const profile = (req.body && typeof req.body === 'object' ? (req.body as Record).profile : null) as unknown; + + if (!profile || typeof profile !== 'object' || Array.isArray(profile)) { + throw new Error('Invalid profile payload. Expected a JSON object.'); + } + + await mkdir(dirname(DEFAULT_PROFILE_PATH), { recursive: true }); + await writeFile(DEFAULT_PROFILE_PATH, JSON.stringify(profile, null, 2), 'utf-8'); + clearProfileCache(); + + res.json({ success: true, data: { exists: true, error: null } }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + res.status(400).json({ success: false, error: message }); + } +}); diff --git a/orchestrator/src/server/app.ts b/orchestrator/src/server/app.ts index 4f39077..1cd20e7 100644 --- a/orchestrator/src/server/app.ts +++ b/orchestrator/src/server/app.ts @@ -74,7 +74,7 @@ export function createApp() { const authGuard = createBasicAuthGuard(); app.use(cors()); - app.use(express.json()); + app.use(express.json({ limit: '5mb' })); // Logging middleware app.use((req, res, next) => { diff --git a/orchestrator/src/server/services/settings.ts b/orchestrator/src/server/services/settings.ts index fd82c7e..76e47b1 100644 --- a/orchestrator/src/server/services/settings.ts +++ b/orchestrator/src/server/services/settings.ts @@ -11,7 +11,10 @@ export async function getEffectiveSettings(): Promise { // Parallelize slow operations const [overrides, profile] = await Promise.all([ settingsRepo.getAllSettings(), - getProfile(), + getProfile().catch((error) => { + console.warn('Failed to load base resume profile for settings:', error); + return {}; + }), ]); const envSettings = await getEnvSettingsData(overrides); diff --git a/orchestrator/src/shared/types.ts b/orchestrator/src/shared/types.ts index fbafbf6..5d69d1e 100644 --- a/orchestrator/src/shared/types.ts +++ b/orchestrator/src/shared/types.ts @@ -331,6 +331,11 @@ export interface ResumeProfile { [key: string]: any; } +export interface ProfileStatusResponse { + exists: boolean; + error: string | null; +} + export interface AppSettings { model: string; defaultModel: string;