Merge branch 'main' into reactive-resume-v5

This commit is contained in:
DaKheera47
2026-01-22 22:57:37 +00:00
104 changed files with 9322 additions and 2585 deletions
+2
View File
@@ -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 (
<>
<OnboardingGate />
<SwitchTransition mode="out-in">
<CSSTransition
key={pageKey}
+50 -2
View File
@@ -8,7 +8,6 @@ import type {
JobsListResponse,
PipelineStatusResponse,
JobSource,
PipelineRun,
AppSettings,
ResumeProjectsSettings,
ResumeProjectCatalogItem,
@@ -20,6 +19,9 @@ import type {
VisaSponsorSearchResponse,
VisaSponsorStatusResponse,
VisaSponsor,
ResumeProfile,
ProfileStatusResponse,
ValidationResult,
} from '../../shared/types';
import { trackEvent } from "@/lib/analytics";
@@ -86,6 +88,12 @@ export async function generateJobPdf(id: string): Promise<Job> {
});
}
export async function checkSponsor(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/check-sponsor`, {
method: 'POST',
});
}
export async function markAsApplied(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/apply`, {
method: 'POST',
@@ -168,6 +176,38 @@ export async function getProfileProjects(): Promise<ResumeProjectCatalogItem[]>
return fetchApi<ResumeProjectCatalogItem[]>('/profile/projects');
}
export async function getProfile(): Promise<ResumeProfile> {
return fetchApi<ResumeProfile>('/profile');
}
export async function getProfileStatus(): Promise<ProfileStatusResponse> {
return fetchApi<ProfileStatusResponse>('/profile/status');
}
export async function uploadProfile(profile: ResumeProfile): Promise<ProfileStatusResponse> {
return fetchApi<ProfileStatusResponse>('/profile/upload', {
method: 'POST',
body: JSON.stringify({ profile }),
});
}
export async function validateOpenrouter(apiKey?: string): Promise<ValidationResult> {
return fetchApi<ValidationResult>('/onboarding/validate/openrouter', {
method: 'POST',
body: JSON.stringify({ apiKey }),
});
}
export async function validateRxresume(email?: string, password?: string): Promise<ValidationResult> {
return fetchApi<ValidationResult>('/onboarding/validate/rxresume', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
}
export async function validateResumeJson(): Promise<ValidationResult> {
return fetchApi<ValidationResult>('/onboarding/validate/resume');
}
export async function updateSettings(update: {
model?: string | null
@@ -186,7 +226,15 @@ export async function updateSettings(update: {
jobspyCountryIndeed?: string | null
jobspySites?: string[] | null
jobspyLinkedinFetchDescription?: boolean | null
rxResumeBaseResumeId?: string | null
showSponsorInfo?: boolean | null
openrouterApiKey?: string | null
rxresumeEmail?: string | null
rxresumePassword?: string | null
basicAuthUser?: string | null
basicAuthPassword?: string | null
ukvisajobsEmail?: string | null
ukvisajobsPassword?: string | null
webhookSecret?: string | null
}): Promise<AppSettings> {
return fetchApi<AppSettings>('/settings', {
method: 'PATCH',
@@ -0,0 +1,104 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { JobHeader } from "./JobHeader";
import { useSettings } from "../hooks/useSettings";
import type { Job } from "../../shared/types";
// Mock useSettings
vi.mock("../hooks/useSettings", () => ({
useSettings: vi.fn(),
}));
// Mock api
vi.mock("../api", () => ({
checkSponsor: vi.fn(),
}));
// Mock Tooltip components to simplify testing
vi.mock("@/components/ui/tooltip", () => ({
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tooltip-content">{children}</div>
),
}));
const mockJob: Job = {
id: "job-1",
title: "Software Engineer",
employer: "Tech Corp",
location: "London",
salary: "£60,000",
deadline: "2025-12-31",
status: "discovered",
source: "linkedin",
suitabilityScore: 85,
suitabilityReason: "Strong match",
sponsorMatchScore: null,
sponsorMatchNames: null,
// Other fields...
} as Job;
describe("JobHeader", () => {
beforeEach(() => {
vi.clearAllMocks();
(useSettings as any).mockReturnValue({
showSponsorInfo: true,
});
});
it("renders basic job information", () => {
render(<JobHeader job={mockJob} />);
expect(screen.getByText("Software Engineer")).toBeInTheDocument();
expect(screen.getByText("Tech Corp")).toBeInTheDocument();
expect(screen.getByText("London")).toBeInTheDocument();
expect(screen.getByText("£60,000")).toBeInTheDocument();
});
it("shows 'Check Sponsorship Status' button when sponsorMatchScore is null", async () => {
const onCheckSponsor = vi.fn().mockResolvedValue(undefined);
render(<JobHeader job={mockJob} onCheckSponsor={onCheckSponsor} />);
const button = screen.getByText("Check Sponsorship Status");
expect(button).toBeInTheDocument();
fireEvent.click(button);
expect(onCheckSponsor).toHaveBeenCalled();
});
it("shows 'Confirmed Sponsor' when score >= 95", () => {
const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98, sponsorMatchNames: '["Tech Corp Ltd"]' };
render(<JobHeader job={jobWithSponsor} />);
expect(screen.getByText("Confirmed Sponsor")).toBeInTheDocument();
});
it("shows 'Potential Sponsor' when score is between 80 and 94", () => {
const jobWithPotential = { ...mockJob, sponsorMatchScore: 85, sponsorMatchNames: '["Techy Corp"]' };
render(<JobHeader job={jobWithPotential} />);
expect(screen.getByText("Potential Sponsor")).toBeInTheDocument();
});
it("shows 'Sponsor Not Found' when score < 80", () => {
const jobNoSponsor = { ...mockJob, sponsorMatchScore: 40, sponsorMatchNames: '["Other Corp"]' };
render(<JobHeader job={jobNoSponsor} />);
expect(screen.getByText("Sponsor Not Found")).toBeInTheDocument();
});
it("hides sponsor info when showSponsorInfo is false", () => {
(useSettings as any).mockReturnValue({
showSponsorInfo: false,
});
const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 };
render(<JobHeader job={jobWithSponsor} />);
expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument();
expect(screen.queryByText("Check Sponsorship Status")).not.toBeInTheDocument();
});
});
@@ -1,13 +1,18 @@
import React from "react";
import { Calendar, DollarSign, MapPin } from "lucide-react";
import React, { useMemo, useState } from "react";
import { Calendar, DollarSign, Loader2, MapPin, Search } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn, formatDate, sourceLabel } from "@/lib/utils";
import type { Job, JobStatus } from "../../shared/types";
import { defaultStatusToken, statusTokens } from "../pages/orchestrator/constants";
import { useSettings } from "../hooks/useSettings";
interface JobHeaderProps {
job: Job;
className?: string;
onCheckSponsor?: () => Promise<void>;
}
const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => {
@@ -42,7 +47,101 @@ const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => {
);
};
export const JobHeader: React.FC<JobHeaderProps> = ({ job, className }) => {
interface SponsorPillProps {
score: number | null;
names: string | null;
onCheck?: () => Promise<void>;
}
const SponsorPill: React.FC<SponsorPillProps> = ({ score, names, onCheck }) => {
const [isChecking, setIsChecking] = useState(false);
const parsedNames = useMemo(() => {
if (!names) return [];
try {
return JSON.parse(names) as string[];
} catch {
return [];
}
}, [names]);
const handleCheck = async () => {
if (!onCheck) return;
setIsChecking(true);
try {
await onCheck();
} finally {
setIsChecking(false);
}
};
// Show "Check" button if no score and callback provided
if (score == null && onCheck) {
return (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-5 px-1.5 text-xs font-medium text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
onClick={handleCheck}
disabled={isChecking}
>
{isChecking ? (
<Loader2 className="h-2 w-2 animate-spin" />
) : (
<Search className="h-2 w-2" />
)}
<span>{isChecking ? "Checking..." : "Check Sponsorship Status"}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">Check if employer is a visa sponsor</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
if (score == null) {
return null;
}
const getStatus = (s: number) => {
if (s >= 95) return { label: "Confirmed Sponsor", dot: "bg-emerald-500", color: "text-emerald-400" };
if (s >= 80) return { label: "Potential Sponsor", dot: "bg-amber-500", color: "text-amber-400" };
return { label: "Sponsor Not Found", dot: "bg-slate-500", color: "text-slate-400" };
};
const status = getStatus(score);
const tooltipContent = `${score}% match`;
return (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80 cursor-help">
<span className={cn("h-1.5 w-1.5 rounded-full opacity-80", status.dot)} />
{status.label}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{parsedNames.length > 0 && (
<p className="text-xs font-medium space-x-1">
<span className="opacity-70">Matched</span>
<span>{parsedNames.join(", ")}</span>
</p>
)}
<p className="opacity-80 mt-1 text-[10px]">{tooltipContent}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
export const JobHeader: React.FC<JobHeaderProps> = ({ job, className, onCheckSponsor }) => {
const { showSponsorInfo } = useSettings();
const deadline = formatDate(job.deadline);
return (
@@ -51,7 +150,9 @@ export const JobHeader: React.FC<JobHeaderProps> = ({ job, className }) => {
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-base font-semibold text-foreground/90">{job.title}</div>
<div className="text-xs text-muted-foreground">{job.employer}</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{job.employer}</span>
</div>
</div>
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50">
{sourceLabel[job.source]}
@@ -82,7 +183,16 @@ export const JobHeader: React.FC<JobHeaderProps> = ({ job, className }) => {
{/* Status and score: single line, subdued */}
<div className="flex items-center justify-between gap-2 py-1 border-y border-border/30">
<StatusPill status={job.status} />
<div className="flex items-center gap-4">
<StatusPill status={job.status} />
{showSponsorInfo && (
<SponsorPill
score={job.sponsorMatchScore}
names={job.sponsorMatchNames}
onCheck={onCheckSponsor}
/>
)}
</div>
<ScoreMeter score={job.suitabilityScore} />
</div>
</div>
@@ -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<ValidationState>({
valid: false,
message: null,
checked: false,
})
const [rxresumeValidation, setRxresumeValidation] = useState<ValidationState>({
valid: false,
message: null,
checked: false,
})
const [resumeValidation, setResumeValidation] = useState<ValidationState>({
valid: false,
message: null,
checked: false,
})
const [currentStep, setCurrentStep] = useState<string | null>(null)
const [openrouterApiKey, setOpenrouterApiKey] = useState("")
const [rxresumeEmail, setRxresumeEmail] = useState("")
const [rxresumePassword, setRxresumePassword] = useState("")
const [resumeFile, setResumeFile] = useState<File | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(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<boolean> => {
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<boolean> => {
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<boolean> => {
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 (
<AlertDialog open>
<AlertDialogContent
className="max-w-3xl max-h-[90vh] overflow-hidden p-0"
onEscapeKeyDown={(event) => event.preventDefault()}
>
<div className="space-y-6 px-6 py-6 max-h-[calc(90vh-3.5rem)] overflow-y-auto">
<AlertDialogHeader>
<AlertDialogTitle>Welcome to Job Ops</AlertDialogTitle>
<AlertDialogDescription>
Let’s get your workspace ready. Add your keys and resume once, then the pipeline can run end-to-end.
</AlertDialogDescription>
</AlertDialogHeader>
<Tabs value={currentStep} onValueChange={setCurrentStep}>
<TabsList className="grid h-auto w-full grid-cols-1 gap-2 border-b border-border/60 bg-transparent p-0 text-left sm:grid-cols-3">
{steps.map((step, index) => {
const isActive = step.id === currentStep
const isComplete = step.complete
return (
<FieldLabel
key={step.id}
className="w-full [&>[data-slot=field]]:border-0 [&>[data-slot=field]]:p-0 [&>[data-slot=field]]:rounded-none"
>
<TabsTrigger
value={step.id}
className={cn(
"w-full rounded-none border-b-2 border-transparent px-3 py-4 text-left shadow-none",
isActive ? "border-primary bg-muted/60 text-foreground" : "text-muted-foreground"
)}
>
<Field orientation="horizontal" className="items-start">
<FieldContent>
<FieldTitle>{step.label}</FieldTitle>
<FieldDescription>{step.subtitle}</FieldDescription>
</FieldContent>
<span
className={cn(
"mt-0.5 flex h-6 w-6 items-center justify-center rounded-md text-xs font-semibold",
isComplete
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}
>
{isComplete ? <Check className="h-3.5 w-3.5" /> : index + 1}
</span>
</Field>
</TabsTrigger>
</FieldLabel>
)
})}
</TabsList>
<TabsContent value="openrouter" className="space-y-4 pt-6">
<div>
<p className="text-sm font-semibold">Connect OpenRouter</p>
<p className="text-xs text-muted-foreground">Used for job scoring, summaries, and tailoring.</p>
</div>
<SettingsInput
label="OpenRouter API key"
inputProps={{
name: "openrouterApiKey",
value: openrouterApiKey,
onChange: (event) => setOpenrouterApiKey(event.target.value),
}}
type="password"
placeholder="sk-or-v1..."
current={openrouterCurrent}
helper="Create a key at openrouter.ai"
disabled={isSavingEnv}
/>
</TabsContent>
<TabsContent value="rxresume" className="space-y-4 pt-6">
<div>
<p className="text-sm font-semibold">Link your RxResume account</p>
<p className="text-xs text-muted-foreground">Used to export tailored PDFs.</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<SettingsInput
label="Email"
inputProps={{
name: "rxresumeEmail",
value: rxresumeEmail,
onChange: (event) => setRxresumeEmail(event.target.value),
}}
placeholder="you@example.com"
current={rxresumeEmailCurrent}
disabled={isSavingEnv}
/>
<SettingsInput
label="Password"
inputProps={{
name: "rxresumePassword",
value: rxresumePassword,
onChange: (event) => setRxresumePassword(event.target.value),
}}
type="password"
placeholder="Enter password"
current={rxresumePasswordCurrent}
disabled={isSavingEnv}
/>
</div>
</TabsContent>
<TabsContent value="resume" className="space-y-4 pt-6">
<div>
<p className="text-sm font-semibold">Upload your resume JSON</p>
<p className="text-xs text-muted-foreground">Use the JSON export you downloaded from v4.rxresu.me.</p>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
<div className="space-y-2">
<label htmlFor="resumeFile" className="text-sm font-medium">
Resume JSON
</label>
<Input
id="resumeFile"
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={(event) => setResumeFile(event.target.files?.[0] ?? null)}
disabled={isUploadingResume}
/>
{resumeFileName && (
<p className="text-xs text-muted-foreground">Selected: {resumeFileName}</p>
)}
</div>
</div>
</TabsContent>
</Tabs>
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleBack} disabled={!canGoBack || isBusy}>
Back
</Button>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={handleRefresh} disabled={isBusy}>
Refresh status
</Button>
<Button onClick={handlePrimaryAction} disabled={isBusy}>
{isBusy ? "Working..." : primaryLabel}
</Button>
</div>
</div>
<Progress value={progressValue} className="h-2" />
<div className="rounded-lg border border-muted bg-muted/30 p-3 text-xs text-muted-foreground">
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.{" "}
<a
className="font-semibold text-foreground underline underline-offset-2"
href="https://github.com/DaKheera47/job-ops/issues"
target="_blank"
rel="noreferrer"
>
Open an issue
</a>
.
</div>
</div>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -3,6 +3,8 @@
*
* Designed for a single, fast, repeatable workflow: verify → download → apply → mark applied.
* The PDF is the primary artifact, represented abstractly through an Application Kit summary.
*
* Now includes inline tailoring mode for editing and regenerating PDFs without switching tabs.
*/
import React, { useCallback, useEffect, useMemo, useState } from "react";
@@ -42,14 +44,16 @@ import {
import { cn, copyTextToClipboard, formatJobForWebhook } from "@/lib/utils";
import * as api from "../api";
import { FitAssessment, JobHeader, TailoredSummary } from ".";
import { TailorMode } from "./discovered-panel/TailorMode";
import { useProfile } from "../hooks/useProfile";
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
type PanelMode = "ready" | "tailor";
interface ReadyPanelProps {
job: Job | null;
onJobUpdated: () => void | Promise<void>;
onJobMoved: (jobId: string) => void;
onEditTailoring: () => void;
onEditDescription: () => void;
}
const safeFilenamePart = (value: string | null | undefined) =>
@@ -59,9 +63,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
job,
onJobUpdated,
onJobMoved,
onEditTailoring,
onEditDescription,
}) => {
const [mode, setMode] = useState<PanelMode>("ready");
const [isMarkingApplied, setIsMarkingApplied] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
@@ -72,11 +75,18 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
timeoutId: ReturnType<typeof setTimeout>;
} | null>(null);
const { personName } = useProfile();
// Load project catalog once
useEffect(() => {
api.getProfileProjects().then(setCatalog).catch(console.error);
}, []);
// Reset mode when job changes
useEffect(() => {
setMode("ready");
}, [job?.id]);
// Compute derived values
const pdfHref = job
? `/pdfs/resume_${job.id}.pdf?v=${encodeURIComponent(job.updatedAt)}`
@@ -141,7 +151,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
// Revert to ready status
await api.updateJob(jobId, { status: "ready" });
toast.success("Reverted to Ready");
if (recentlyApplied?.timeoutId) {
clearTimeout(recentlyApplied.timeoutId);
}
@@ -198,6 +208,23 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
}
}, [job]);
// Handler for regenerating PDF after tailoring edits
const handleTailorFinalize = useCallback(async () => {
if (!job) return;
try {
setIsRegenerating(true);
await api.generateJobPdf(job.id);
toast.success("PDF regenerated");
await onJobUpdated();
setMode("ready");
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to regenerate PDF";
toast.error(message);
} finally {
setIsRegenerating(false);
}
}, [job, onJobUpdated]);
// Empty state
if (!job) {
return (
@@ -213,9 +240,29 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
);
}
// Tailor mode - reuse the same TailorMode component with 'ready' variant
if (mode === "tailor") {
return (
<TailorMode
job={job}
onBack={() => setMode("ready")}
onFinalize={handleTailorFinalize}
isFinalizing={isRegenerating}
variant="ready"
/>
);
}
return (
<div className="flex flex-col h-full">
<JobHeader job={job} className="pb-4 border-b border-border/40" />
<JobHeader
job={job}
className="pb-4 border-b border-border/40"
onCheckSponsor={async () => {
await api.checkSponsor(job.id);
await onJobUpdated();
}}
/>
{/* ─────────────────────────────────────────────────────────────────────
PRIMARY ACTION CLUSTER
@@ -235,7 +282,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
<Button asChild variant="outline" className="h-9 w-full gap-1 px-2 text-xs">
<a
href={pdfHref}
download={`Shaheer_Sarfaraz_${safeFilenamePart(job.employer)}.pdf`}
download={`${safeFilenamePart(personName)}_${safeFilenamePart(job.employer)}.pdf`}
>
<Download className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Download PDF</span>
@@ -325,7 +372,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-56">
{/* Fix/Edit actions */}
<DropdownMenuItem onSelect={onEditTailoring}>
<DropdownMenuItem onSelect={() => setMode("tailor")}>
<Edit2 className="mr-2 h-4 w-4" />
Edit tailoring
</DropdownMenuItem>
@@ -338,11 +385,6 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
{isRegenerating ? "Regenerating..." : "Regenerate PDF"}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onEditDescription}>
<Edit2 className="mr-2 h-4 w-4" />
Edit job description
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Utility actions */}
@@ -14,6 +14,7 @@ interface DecideModeProps {
onTailor: () => void;
onSkip: () => void;
isSkipping: boolean;
onCheckSponsor?: () => Promise<void>;
}
export const DecideMode: React.FC<DecideModeProps> = ({
@@ -21,6 +22,7 @@ export const DecideMode: React.FC<DecideModeProps> = ({
onTailor,
onSkip,
isSkipping,
onCheckSponsor,
}) => {
const [showDescription, setShowDescription] = useState(false);
const jobLink = job.applicationLink || job.jobUrl;
@@ -33,7 +35,10 @@ export const DecideMode: React.FC<DecideModeProps> = ({
return (
<div className='flex flex-col h-full'>
<div className='space-y-4 pb-4'>
<JobHeader job={job} />
<JobHeader
job={job}
onCheckSponsor={onCheckSponsor}
/>
<div className='flex flex-col gap-2.5 pt-2 sm:flex-row'>
<Button
@@ -85,6 +85,10 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
onTailor={() => setMode("tailor")}
onSkip={handleSkip}
isSkipping={isSkipping}
onCheckSponsor={async () => {
await api.checkSponsor(job.id);
await onJobUpdated();
}}
/>
) : (
<TailorMode
@@ -15,6 +15,8 @@ interface TailorModeProps {
onBack: () => void;
onFinalize: () => void;
isFinalizing: boolean;
/** Variant controls the finalize button text. Default is 'discovered'. */
variant?: 'discovered' | 'ready';
}
export const TailorMode: React.FC<TailorModeProps> = ({
@@ -22,6 +24,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
onBack,
onFinalize,
isFinalizing,
variant = 'discovered',
}) => {
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
const [summary, setSummary] = useState(job.tailoredSummary || "");
@@ -274,7 +277,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
<div className='space-y-2'>
{!canFinalize && (
<p className='text-[10px] text-center text-muted-foreground'>
Add a summary and select at least one project to finalize.
Add a summary and select at least one project to {variant === 'ready' ? 'regenerate' : 'finalize'}.
</p>
)}
<Button
@@ -285,17 +288,19 @@ export const TailorMode: React.FC<TailorModeProps> = ({
{isFinalizing ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Finalizing & generating PDF...
{variant === 'ready' ? 'Regenerating PDF...' : 'Finalizing & generating PDF...'}
</>
) : (
<>
<Check className='mr-2 h-4 w-4' />
Finalize & Move to Ready
{variant === 'ready' ? 'Regenerate PDF' : 'Finalize & Move to Ready'}
</>
)}
</Button>
<p className='text-[10px] text-center text-muted-foreground/70'>
This will generate your tailored PDF and move the job to Ready.
{variant === 'ready'
? 'This will save your changes and regenerate the tailored PDF.'
: 'This will generate your tailored PDF and move the job to Ready.'}
</p>
</div>
</div>
@@ -0,0 +1,91 @@
import { useEffect, useState } from 'react';
import * as api from '../api';
import type { ResumeProfile } from '../../shared/types';
let profileCache: ResumeProfile | null = null;
let profileError: Error | null = null;
let subscribers: Set<(profile: ResumeProfile | null, error: Error | null) => void> = new Set();
let isFetching = false;
/**
* Hook to get the full profile data from base.json.
* Caches the result to avoid re-fetching.
*/
export function useProfile() {
const [profile, setProfile] = useState<ResumeProfile | null>(profileCache);
const [error, setError] = useState<Error | null>(profileError);
useEffect(() => {
if (profileCache) {
setProfile(profileCache);
}
if (profileError) {
setError(profileError);
}
const handleUpdate = (newProfile: ResumeProfile | null, newError: Error | null) => {
setProfile(newProfile);
setError(newError);
};
subscribers.add(handleUpdate);
if (!profileCache && !isFetching) {
isFetching = true;
profileError = null;
api.getProfile()
.then((data) => {
profileCache = data;
profileError = null;
subscribers.forEach(sub => sub(data, null));
})
.catch((err) => {
profileError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach(sub => sub(profileCache, profileError));
})
.finally(() => {
isFetching = false;
});
}
return () => {
subscribers.delete(handleUpdate);
};
}, []);
const refreshProfile = async () => {
isFetching = true;
profileError = null;
subscribers.forEach(sub => sub(profileCache, null));
try {
const data = await api.getProfile();
profileCache = data;
profileError = null;
subscribers.forEach(sub => sub(data, null));
return data;
} catch (err) {
profileError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach(sub => sub(profileCache, profileError));
throw profileError;
} finally {
isFetching = false;
}
};
return {
profile,
error,
isLoading: !profile && isFetching && !error,
personName: profile?.basics?.name || 'Resume',
refreshProfile,
};
}
/** @internal For testing only */
export function _resetProfileCache() {
profileCache = null;
profileError = null;
isFetching = false;
subscribers.clear();
}
@@ -0,0 +1,80 @@
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useSettings, _resetSettingsCache } from './useSettings';
import * as api from '../api';
vi.mock('../api', () => ({
getSettings: vi.fn(),
}));
describe('useSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
_resetSettingsCache();
});
it('fetches settings on mount if not already cached', async () => {
const mockSettings = { showSponsorInfo: false };
(api.getSettings as any).mockResolvedValue(mockSettings);
const { result } = renderHook(() => useSettings());
// Should start in loading state
expect(result.current.settings).toBeNull();
await waitFor(() => {
expect(result.current.settings).toEqual(mockSettings);
});
expect(result.current.showSponsorInfo).toBe(false);
expect(api.getSettings).toHaveBeenCalledTimes(1);
});
it('uses default values when settings are null', async () => {
(api.getSettings as any).mockResolvedValue(null);
const { result } = renderHook(() => useSettings());
await waitFor(() => {
// settings is null, so showSponsorInfo should default to true
expect(result.current.showSponsorInfo).toBe(true);
});
});
it('provides a refresh function that updates settings', async () => {
const initialSettings = { showSponsorInfo: true };
const updatedSettings = { showSponsorInfo: false };
(api.getSettings as any).mockResolvedValueOnce(initialSettings);
(api.getSettings as any).mockResolvedValueOnce(updatedSettings);
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settings).toEqual(initialSettings);
});
let refreshed;
await waitFor(async () => {
refreshed = await result.current.refreshSettings();
});
expect(refreshed).toEqual(updatedSettings);
expect(result.current.settings).toEqual(updatedSettings);
expect(result.current.showSponsorInfo).toBe(false);
});
it('handles errors when fetching settings', async () => {
const mockError = new Error('Failed to fetch');
(api.getSettings as any).mockRejectedValue(mockError);
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.error).toEqual(mockError);
});
expect(result.current.isLoading).toBe(false);
expect(result.current.settings).toBeNull();
});
});
@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react';
import type { AppSettings } from '../../shared/types';
import * as api from '../api';
let settingsCache: AppSettings | null = null;
let settingsError: Error | null = null;
let subscribers: Set<(settings: AppSettings | null, error: Error | null) => void> = new Set();
let isFetching = false;
export function useSettings() {
const [settings, setSettings] = useState<AppSettings | null>(settingsCache);
const [error, setError] = useState<Error | null>(settingsError);
useEffect(() => {
if (settingsCache) {
setSettings(settingsCache);
}
if (settingsError) {
setError(settingsError);
}
const handleUpdate = (newSettings: AppSettings | null, newError: Error | null) => {
setSettings(newSettings);
setError(newError);
};
subscribers.add(handleUpdate);
if (!settingsCache && !isFetching) {
isFetching = true;
settingsError = null;
api.getSettings()
.then((data) => {
settingsCache = data;
settingsError = null;
subscribers.forEach(sub => sub(data, null));
})
.catch((err) => {
settingsError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach(sub => sub(settingsCache, settingsError));
})
.finally(() => {
isFetching = false;
});
}
return () => {
subscribers.delete(handleUpdate);
};
}, []);
const refreshSettings = async () => {
isFetching = true;
settingsError = null;
subscribers.forEach(sub => sub(settingsCache, null));
try {
const data = await api.getSettings();
settingsCache = data;
settingsError = null;
subscribers.forEach(sub => sub(data, null));
return data;
} catch (err) {
settingsError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach(sub => sub(settingsCache, settingsError));
throw settingsError;
} finally {
isFetching = false;
}
};
return {
settings,
error,
isLoading: !settings && isFetching && !error,
showSponsorInfo: settings?.showSponsorInfo ?? true,
refreshSettings,
};
}
/** @internal For testing only */
export function _resetSettingsCache() {
settingsCache = null;
settingsError = null;
isFetching = false;
subscribers.clear();
}
@@ -33,6 +33,8 @@ const jobFixture: Job = {
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
@@ -198,7 +200,7 @@ describe("OrchestratorPage", () => {
// Clicking job-2 should update URL
const job2Button = screen.getByTestId("select-job-2");
fireEvent.click(job2Button);
// Wait for URL to update
await waitFor(() => {
expect(locationText()).toContain("/all/job-2");
@@ -23,14 +23,14 @@ vi.mock("sonner", () => ({
}))
const baseSettings: AppSettings = {
model: "openai/gpt-4o-mini",
defaultModel: "openai/gpt-4o-mini",
model: "google/gemini-3-flash-preview",
defaultModel: "google/gemini-3-flash-preview",
overrideModel: null,
modelScorer: "openai/gpt-4o-mini",
modelScorer: "google/gemini-3-flash-preview",
overrideModelScorer: null,
modelTailoring: "openai/gpt-4o-mini",
modelTailoring: "google/gemini-3-flash-preview",
overrideModelTailoring: null,
modelProjectSelection: "openai/gpt-4o-mini",
modelProjectSelection: "google/gemini-3-flash-preview",
overrideModelProjectSelection: null,
pipelineWebhookUrl: "",
defaultPipelineWebhookUrl: "",
@@ -92,6 +92,18 @@ const baseSettings: AppSettings = {
jobspyLinkedinFetchDescription: true,
defaultJobspyLinkedinFetchDescription: true,
overrideJobspyLinkedinFetchDescription: null,
showSponsorInfo: true,
defaultShowSponsorInfo: true,
overrideShowSponsorInfo: null,
openrouterApiKeyHint: null,
rxresumeEmail: "",
rxresumePasswordHint: null,
basicAuthUser: "",
basicAuthPasswordHint: null,
ukvisajobsEmail: "",
ukvisajobsPasswordHint: null,
webhookSecretHint: null,
basicAuthActive: false,
}
const renderPage = () => {
@@ -138,6 +150,28 @@ describe("SettingsPage", () => {
expect(toast.success).toHaveBeenCalledWith("Settings saved")
})
it("shows validation error for too long model override", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
renderPage()
const modelTrigger = await screen.findByRole("button", { name: /model/i })
fireEvent.click(modelTrigger)
const modelField = screen.getByText("Override model").parentElement ?? screen.getByRole("main")
const modelInput = within(modelField).getByRole("textbox")
// Change to > 200 chars
fireEvent.change(modelInput, { target: { value: "a".repeat(201) } })
// Should see error message
expect(await screen.findByText(/String must contain at most 200 character\(s\)/i)).toBeInTheDocument()
// Save button should be disabled due to validation error (isValid will be false)
const saveButton = screen.getByRole("button", { name: /^save$/i })
expect(saveButton).toBeDisabled()
})
it("clears jobs by status and summarizes results", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
vi.mocked(api.deleteJobsByStatus).mockResolvedValue({ message: "", count: 2 })
@@ -161,4 +195,89 @@ describe("SettingsPage", () => {
})
)
})
it("enables save button when model is changed", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
renderPage()
const saveButton = screen.getByRole("button", { name: /^save$/i })
expect(saveButton).toBeDisabled()
const modelTrigger = await screen.findByRole("button", { name: /model/i })
fireEvent.click(modelTrigger)
const modelInput = screen.getByLabelText(/override model/i)
fireEvent.change(modelInput, { target: { value: "new-model" } })
expect(saveButton).toBeEnabled()
})
it("enables save button when numeric setting is changed", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
renderPage()
const saveButton = screen.getByRole("button", { name: /^save$/i })
const visaTrigger = await screen.findByRole("button", { name: /ukvisajobs extractor/i })
fireEvent.click(visaTrigger)
const maxJobsInput = screen.getByLabelText(/max jobs to fetch/i)
fireEvent.change(maxJobsInput, { target: { value: "100" } })
expect(saveButton).toBeEnabled()
})
it("enables save button when display setting is changed", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
renderPage()
const saveButton = screen.getByRole("button", { name: /^save$/i })
const displayTrigger = await screen.findByRole("button", { name: /display settings/i })
fireEvent.click(displayTrigger)
const sponsorCheckbox = screen.getByLabelText(/show visa sponsor information/i)
fireEvent.click(sponsorCheckbox)
expect(saveButton).toBeEnabled()
})
it("enables save button when basic auth toggle is changed", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
renderPage()
const saveButton = screen.getByRole("button", { name: /^save$/i })
const envTrigger = await screen.findByRole("button", { name: /environment & accounts/i })
fireEvent.click(envTrigger)
const authCheckbox = screen.getByLabelText(/enable basic authentication/i)
fireEvent.click(authCheckbox)
expect(saveButton).toBeEnabled()
})
it("wipes basic auth credentials when toggle is disabled and saved", async () => {
// Initial state: Basic Auth is active
const activeSettings = {
...baseSettings,
basicAuthActive: true,
basicAuthUser: "admin",
basicAuthPasswordHint: "pass",
}
vi.mocked(api.getSettings).mockResolvedValue(activeSettings)
vi.mocked(api.updateSettings).mockResolvedValue(baseSettings)
renderPage()
const envTrigger = await screen.findByRole("button", { name: /environment & accounts/i })
fireEvent.click(envTrigger)
const authCheckbox = screen.getByLabelText(/enable basic authentication/i)
expect(authCheckbox).toBeChecked()
// Disable it
fireEvent.click(authCheckbox)
expect(authCheckbox).not.toBeChecked()
const saveButton = screen.getByRole("button", { name: /^save$/i })
expect(saveButton).toBeEnabled()
fireEvent.click(saveButton)
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled())
expect(api.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
basicAuthUser: null,
basicAuthPassword: null,
})
)
})
})
+368 -332
View File
@@ -1,52 +1,244 @@
/**
* Settings page.
*/
import React, { useEffect, useMemo, useState } from "react"
import React, { useEffect, useState } from "react"
import { Settings } from "lucide-react"
import { toast } from "sonner"
import { useForm, FormProvider } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { PageHeader } from "../components/layout"
import { PageHeader } from "@client/components/layout"
import { Accordion } from "@/components/ui/accordion"
import { Button } from "@/components/ui/button"
import type { AppSettings, JobStatus, ResumeProjectsSettings } from "../../shared/types"
import * as api from "../api"
import type { AppSettings, JobStatus } from "@shared/types"
import { updateSettingsSchema, type UpdateSettingsInput } from "@shared/settings-schema"
import * as api from "@client/api"
import { arraysEqual } from "@/lib/utils"
import { resumeProjectsEqual } from "./settings/utils"
import { DangerZoneSection } from "./settings/components/DangerZoneSection"
import { GradcrackerSection } from "./settings/components/GradcrackerSection"
import { JobCompleteWebhookSection } from "./settings/components/JobCompleteWebhookSection"
import { JobspySection } from "./settings/components/JobspySection"
import { ModelSettingsSection } from "./settings/components/ModelSettingsSection"
import { PipelineWebhookSection } from "./settings/components/PipelineWebhookSection"
import { ResumeProjectsSection } from "./settings/components/ResumeProjectsSection"
import { SearchTermsSection } from "./settings/components/SearchTermsSection"
import { UkvisajobsSection } from "./settings/components/UkvisajobsSection"
import { ReactiveResumeSection } from "./settings/components/ReactiveResumeSection"
import { resumeProjectsEqual } from "@client/pages/settings/utils"
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection"
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection"
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection"
import { GradcrackerSection } from "@client/pages/settings/components/GradcrackerSection"
import { JobspySection } from "@client/pages/settings/components/JobspySection"
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection"
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection"
import { ResumeProjectsSection } from "@client/pages/settings/components/ResumeProjectsSection"
import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection"
import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection"
const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
model: "",
modelScorer: "",
modelTailoring: "",
modelProjectSelection: "",
pipelineWebhookUrl: "",
jobCompleteWebhookUrl: "",
resumeProjects: null,
ukvisajobsMaxJobs: null,
gradcrackerMaxJobsPerTerm: null,
searchTerms: null,
jobspyLocation: null,
jobspyResultsWanted: null,
jobspyHoursOld: null,
jobspyCountryIndeed: null,
jobspySites: null,
jobspyLinkedinFetchDescription: null,
showSponsorInfo: null,
openrouterApiKey: "",
rxresumeEmail: "",
rxresumePassword: "",
basicAuthUser: "",
basicAuthPassword: "",
ukvisajobsEmail: "",
ukvisajobsPassword: "",
webhookSecret: "",
enableBasicAuth: false,
}
const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
model: null,
modelScorer: null,
modelTailoring: null,
modelProjectSelection: null,
pipelineWebhookUrl: null,
jobCompleteWebhookUrl: null,
resumeProjects: null,
ukvisajobsMaxJobs: null,
gradcrackerMaxJobsPerTerm: null,
searchTerms: null,
jobspyLocation: null,
jobspyResultsWanted: null,
jobspyHoursOld: null,
jobspyCountryIndeed: null,
jobspySites: null,
jobspyLinkedinFetchDescription: null,
showSponsorInfo: null,
openrouterApiKey: null,
rxresumeEmail: null,
rxresumePassword: null,
basicAuthUser: null,
basicAuthPassword: null,
ukvisajobsEmail: null,
ukvisajobsPassword: null,
webhookSecret: null,
enableBasicAuth: undefined,
}
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
model: data.overrideModel ?? "",
modelScorer: data.overrideModelScorer ?? "",
modelTailoring: data.overrideModelTailoring ?? "",
modelProjectSelection: data.overrideModelProjectSelection ?? "",
pipelineWebhookUrl: data.overridePipelineWebhookUrl ?? "",
jobCompleteWebhookUrl: data.overrideJobCompleteWebhookUrl ?? "",
resumeProjects: data.resumeProjects,
ukvisajobsMaxJobs: data.overrideUkvisajobsMaxJobs,
gradcrackerMaxJobsPerTerm: data.overrideGradcrackerMaxJobsPerTerm,
searchTerms: data.overrideSearchTerms,
jobspyLocation: data.overrideJobspyLocation,
jobspyResultsWanted: data.overrideJobspyResultsWanted,
jobspyHoursOld: data.overrideJobspyHoursOld,
jobspyCountryIndeed: data.overrideJobspyCountryIndeed,
jobspySites: data.overrideJobspySites,
jobspyLinkedinFetchDescription: data.overrideJobspyLinkedinFetchDescription,
showSponsorInfo: data.overrideShowSponsorInfo,
openrouterApiKey: "",
rxresumeEmail: data.rxresumeEmail ?? "",
rxresumePassword: "",
basicAuthUser: data.basicAuthUser ?? "",
basicAuthPassword: "",
ukvisajobsEmail: data.ukvisajobsEmail ?? "",
ukvisajobsPassword: "",
webhookSecret: "",
enableBasicAuth: data.basicAuthActive,
})
const normalizeString = (value: string | null | undefined) => {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
const normalizePrivateInput = (value: string | null | undefined) => {
const trimmed = value?.trim()
if (trimmed === "") return null
return trimmed || undefined
}
const isSameStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => {
if (!left && !right) return true
if (!left || !right) return false
return arraysEqual(left, right)
}
const isSameSortedStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => {
if (!left && !right) return true
if (!left || !right) return false
return arraysEqual(left.slice().sort(), right.slice().sort())
}
const nullIfSame = <T,>(value: T | null | undefined, defaultValue: T) =>
value === defaultValue ? null : value ?? null
const nullIfSameList = (value: string[] | null | undefined, defaultValue: string[]) =>
isSameStringList(value, defaultValue) ? null : value ?? null
const nullIfSameSortedList = (value: string[] | null | undefined, defaultValue: string[]) =>
isSameSortedStringList(value, defaultValue) ? null : value ?? null
const getDerivedSettings = (settings: AppSettings | null) => {
const profileProjects = settings?.profileProjects ?? []
return {
model: {
effective: settings?.model ?? "",
default: settings?.defaultModel ?? "",
scorer: settings?.modelScorer ?? "",
tailoring: settings?.modelTailoring ?? "",
projectSelection: settings?.modelProjectSelection ?? "",
},
pipelineWebhook: {
effective: settings?.pipelineWebhookUrl ?? "",
default: settings?.defaultPipelineWebhookUrl ?? "",
},
jobCompleteWebhook: {
effective: settings?.jobCompleteWebhookUrl ?? "",
default: settings?.defaultJobCompleteWebhookUrl ?? "",
},
ukvisajobs: {
effective: settings?.ukvisajobsMaxJobs ?? 50,
default: settings?.defaultUkvisajobsMaxJobs ?? 50,
},
gradcracker: {
effective: settings?.gradcrackerMaxJobsPerTerm ?? 50,
default: settings?.defaultGradcrackerMaxJobsPerTerm ?? 50,
},
searchTerms: {
effective: settings?.searchTerms ?? [],
default: settings?.defaultSearchTerms ?? [],
},
jobspy: {
location: {
effective: settings?.jobspyLocation ?? "",
default: settings?.defaultJobspyLocation ?? "",
},
resultsWanted: {
effective: settings?.jobspyResultsWanted ?? 200,
default: settings?.defaultJobspyResultsWanted ?? 200,
},
hoursOld: {
effective: settings?.jobspyHoursOld ?? 72,
default: settings?.defaultJobspyHoursOld ?? 72,
},
countryIndeed: {
effective: settings?.jobspyCountryIndeed ?? "",
default: settings?.defaultJobspyCountryIndeed ?? "",
},
sites: {
effective: settings?.jobspySites ?? ["indeed", "linkedin"],
default: settings?.defaultJobspySites ?? ["indeed", "linkedin"],
},
linkedinFetchDescription: {
effective: settings?.jobspyLinkedinFetchDescription ?? true,
default: settings?.defaultJobspyLinkedinFetchDescription ?? true,
},
},
display: {
effective: settings?.showSponsorInfo ?? true,
default: settings?.defaultShowSponsorInfo ?? true,
},
envSettings: {
readable: {
rxresumeEmail: settings?.rxresumeEmail ?? "",
ukvisajobsEmail: settings?.ukvisajobsEmail ?? "",
basicAuthUser: settings?.basicAuthUser ?? "",
},
private: {
openrouterApiKeyHint: settings?.openrouterApiKeyHint ?? null,
rxresumePasswordHint: settings?.rxresumePasswordHint ?? null,
ukvisajobsPasswordHint: settings?.ukvisajobsPasswordHint ?? null,
basicAuthPasswordHint: settings?.basicAuthPasswordHint ?? null,
webhookSecretHint: settings?.webhookSecretHint ?? null,
},
basicAuthActive: settings?.basicAuthActive ?? false,
},
defaultResumeProjects: settings?.defaultResumeProjects ?? null,
profileProjects,
maxProjectsTotal: profileProjects.length,
}
}
export const SettingsPage: React.FC = () => {
const [settings, setSettings] = useState<AppSettings | null>(null)
const [modelDraft, setModelDraft] = useState("")
const [modelScorerDraft, setModelScorerDraft] = useState("")
const [modelTailoringDraft, setModelTailoringDraft] = useState("")
const [modelProjectSelectionDraft, setModelProjectSelectionDraft] = useState("")
const [pipelineWebhookUrlDraft, setPipelineWebhookUrlDraft] = useState("")
const [jobCompleteWebhookUrlDraft, setJobCompleteWebhookUrlDraft] = useState("")
const [resumeProjectsDraft, setResumeProjectsDraft] = useState<ResumeProjectsSettings | null>(null)
const [ukvisajobsMaxJobsDraft, setUkvisajobsMaxJobsDraft] = useState<number | null>(null)
const [gradcrackerMaxJobsPerTermDraft, setGradcrackerMaxJobsPerTermDraft] = useState<number | null>(null)
const [searchTermsDraft, setSearchTermsDraft] = useState<string[] | null>(null)
const [jobspyLocationDraft, setJobspyLocationDraft] = useState<string | null>(null)
const [jobspyResultsWantedDraft, setJobspyResultsWantedDraft] = useState<number | null>(null)
const [jobspyHoursOldDraft, setJobspyHoursOldDraft] = useState<number | null>(null)
const [jobspyCountryIndeedDraft, setJobspyCountryIndeedDraft] = useState<string | null>(null)
const [jobspySitesDraft, setJobspySitesDraft] = useState<string[] | null>(null)
const [jobspyLinkedinFetchDescriptionDraft, setJobspyLinkedinFetchDescriptionDraft] = useState<boolean | null>(null)
const [rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft] = useState<string | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>(['discovered'])
const methods = useForm<UpdateSettingsInput>({
resolver: zodResolver(updateSettingsSchema),
mode: "onChange",
defaultValues: DEFAULT_FORM_VALUES,
})
const { handleSubmit, reset, setError, watch, formState: { isDirty, errors, isValid, dirtyFields } } = methods
useEffect(() => {
let isMounted = true
setIsLoading(true)
@@ -55,23 +247,7 @@ export const SettingsPage: React.FC = () => {
.then((data) => {
if (!isMounted) return
setSettings(data)
setModelDraft(data.overrideModel ?? "")
setModelScorerDraft(data.overrideModelScorer ?? "")
setModelTailoringDraft(data.overrideModelTailoring ?? "")
setModelProjectSelectionDraft(data.overrideModelProjectSelection ?? "")
setPipelineWebhookUrlDraft(data.overridePipelineWebhookUrl ?? "")
setJobCompleteWebhookUrlDraft(data.overrideJobCompleteWebhookUrl ?? "")
setResumeProjectsDraft(data.resumeProjects)
setUkvisajobsMaxJobsDraft(data.overrideUkvisajobsMaxJobs)
setGradcrackerMaxJobsPerTermDraft(data.overrideGradcrackerMaxJobsPerTerm)
setSearchTermsDraft(data.overrideSearchTerms)
setJobspyLocationDraft(data.overrideJobspyLocation)
setJobspyResultsWantedDraft(data.overrideJobspyResultsWanted)
setJobspyHoursOldDraft(data.overrideJobspyHoursOld)
setJobspyCountryIndeedDraft(data.overrideJobspyCountryIndeed)
setJobspySitesDraft(data.overrideJobspySites)
setJobspyLinkedinFetchDescriptionDraft(data.overrideJobspyLinkedinFetchDescription)
setRxResumeBaseResumeIdDraft(data.rxResumeBaseResumeId)
reset(mapSettingsToForm(data))
})
.catch((error) => {
const message = error instanceof Error ? error.message : "Failed to load settings"
@@ -85,186 +261,126 @@ export const SettingsPage: React.FC = () => {
return () => {
isMounted = false
}
}, [])
}, [reset])
const effectiveModel = settings?.model ?? ""
const defaultModel = settings?.defaultModel ?? ""
const overrideModel = settings?.overrideModel
const effectiveModelScorer = settings?.modelScorer ?? ""
const overrideModelScorer = settings?.overrideModelScorer
const effectiveModelTailoring = settings?.modelTailoring ?? ""
const overrideModelTailoring = settings?.overrideModelTailoring
const effectiveModelProjectSelection = settings?.modelProjectSelection ?? ""
const overrideModelProjectSelection = settings?.overrideModelProjectSelection
const effectivePipelineWebhookUrl = settings?.pipelineWebhookUrl ?? ""
const defaultPipelineWebhookUrl = settings?.defaultPipelineWebhookUrl ?? ""
const overridePipelineWebhookUrl = settings?.overridePipelineWebhookUrl
const effectiveJobCompleteWebhookUrl = settings?.jobCompleteWebhookUrl ?? ""
const defaultJobCompleteWebhookUrl = settings?.defaultJobCompleteWebhookUrl ?? ""
const overrideJobCompleteWebhookUrl = settings?.overrideJobCompleteWebhookUrl
const effectiveUkvisajobsMaxJobs = settings?.ukvisajobsMaxJobs ?? 50
const defaultUkvisajobsMaxJobs = settings?.defaultUkvisajobsMaxJobs ?? 50
const overrideUkvisajobsMaxJobs = settings?.overrideUkvisajobsMaxJobs
const effectiveGradcrackerMaxJobsPerTerm = settings?.gradcrackerMaxJobsPerTerm ?? 50
const defaultGradcrackerMaxJobsPerTerm = settings?.defaultGradcrackerMaxJobsPerTerm ?? 50
const overrideGradcrackerMaxJobsPerTerm = settings?.overrideGradcrackerMaxJobsPerTerm
const effectiveSearchTerms = settings?.searchTerms ?? []
const defaultSearchTerms = settings?.defaultSearchTerms ?? []
const overrideSearchTerms = settings?.overrideSearchTerms
const effectiveJobspyLocation = settings?.jobspyLocation ?? ""
const defaultJobspyLocation = settings?.defaultJobspyLocation ?? ""
const overrideJobspyLocation = settings?.overrideJobspyLocation
const effectiveJobspyResultsWanted = settings?.jobspyResultsWanted ?? 200
const defaultJobspyResultsWanted = settings?.defaultJobspyResultsWanted ?? 200
const overrideJobspyResultsWanted = settings?.overrideJobspyResultsWanted
const effectiveJobspyHoursOld = settings?.jobspyHoursOld ?? 72
const defaultJobspyHoursOld = settings?.defaultJobspyHoursOld ?? 72
const overrideJobspyHoursOld = settings?.overrideJobspyHoursOld
const effectiveJobspyCountryIndeed = settings?.jobspyCountryIndeed ?? ""
const defaultJobspyCountryIndeed = settings?.defaultJobspyCountryIndeed ?? ""
const overrideJobspyCountryIndeed = settings?.overrideJobspyCountryIndeed
const effectiveJobspySites = settings?.jobspySites ?? ["indeed", "linkedin"]
const defaultJobspySites = settings?.defaultJobspySites ?? ["indeed", "linkedin"]
const overrideJobspySites = settings?.overrideJobspySites
const effectiveJobspyLinkedinFetchDescription = settings?.jobspyLinkedinFetchDescription ?? true
const defaultJobspyLinkedinFetchDescription = settings?.defaultJobspyLinkedinFetchDescription ?? true
const overrideJobspyLinkedinFetchDescription = settings?.overrideJobspyLinkedinFetchDescription
const profileProjects = settings?.profileProjects ?? []
const maxProjectsTotal = profileProjects.length
const lockedCount = resumeProjectsDraft?.lockedProjectIds.length ?? 0
const derived = getDerivedSettings(settings)
const {
model,
pipelineWebhook,
jobCompleteWebhook,
ukvisajobs,
gradcracker,
searchTerms,
jobspy,
display,
envSettings,
defaultResumeProjects,
profileProjects,
maxProjectsTotal,
} = derived
const canSave = useMemo(() => {
if (!settings || !resumeProjectsDraft) return false
const next = modelDraft.trim()
const current = (overrideModel ?? "").trim()
const nextScorer = modelScorerDraft.trim()
const currentScorer = (overrideModelScorer ?? "").trim()
const nextTailoring = modelTailoringDraft.trim()
const currentTailoring = (overrideModelTailoring ?? "").trim()
const nextProjectSelection = modelProjectSelectionDraft.trim()
const currentProjectSelection = (overrideModelProjectSelection ?? "").trim()
const nextWebhook = pipelineWebhookUrlDraft.trim()
const currentWebhook = (overridePipelineWebhookUrl ?? "").trim()
const nextJobCompleteWebhook = jobCompleteWebhookUrlDraft.trim()
const currentJobCompleteWebhook = (overrideJobCompleteWebhookUrl ?? "").trim()
const ukvisajobsChanged = ukvisajobsMaxJobsDraft !== (overrideUkvisajobsMaxJobs ?? null)
const gradcrackerChanged = gradcrackerMaxJobsPerTermDraft !== (overrideGradcrackerMaxJobsPerTerm ?? null)
const searchTermsChanged = JSON.stringify(searchTermsDraft) !== JSON.stringify(overrideSearchTerms ?? null)
return (
next !== current ||
nextScorer !== currentScorer ||
nextTailoring !== currentTailoring ||
nextProjectSelection !== currentProjectSelection ||
nextWebhook !== currentWebhook ||
nextJobCompleteWebhook !== currentJobCompleteWebhook ||
!resumeProjectsEqual(resumeProjectsDraft, settings.resumeProjects) ||
ukvisajobsChanged ||
gradcrackerChanged ||
searchTermsChanged ||
jobspyLocationDraft !== (overrideJobspyLocation ?? null) ||
jobspyResultsWantedDraft !== (overrideJobspyResultsWanted ?? null) ||
jobspyHoursOldDraft !== (overrideJobspyHoursOld ?? null) ||
jobspyCountryIndeedDraft !== (overrideJobspyCountryIndeed ?? null) ||
JSON.stringify((jobspySitesDraft ?? []).slice().sort()) !== JSON.stringify((overrideJobspySites ?? []).slice().sort()) ||
jobspyLinkedinFetchDescriptionDraft !== (overrideJobspyLinkedinFetchDescription ?? null) ||
rxResumeBaseResumeIdDraft !== (settings.rxResumeBaseResumeId ?? null)
)
}, [
settings,
modelDraft,
modelScorerDraft,
modelTailoringDraft,
modelProjectSelectionDraft,
pipelineWebhookUrlDraft,
jobCompleteWebhookUrlDraft,
overrideModel,
overrideModelScorer,
overrideModelTailoring,
overrideModelProjectSelection,
overridePipelineWebhookUrl,
overrideJobCompleteWebhookUrl,
resumeProjectsDraft,
ukvisajobsMaxJobsDraft,
overrideUkvisajobsMaxJobs,
gradcrackerMaxJobsPerTermDraft,
overrideGradcrackerMaxJobsPerTerm,
searchTermsDraft,
overrideSearchTerms,
jobspyLocationDraft,
jobspyResultsWantedDraft,
jobspyHoursOldDraft,
jobspyCountryIndeedDraft,
jobspySitesDraft,
jobspyLinkedinFetchDescriptionDraft,
overrideJobspyLocation,
overrideJobspyResultsWanted,
overrideJobspyHoursOld,
overrideJobspyCountryIndeed,
overrideJobspySites,
overrideJobspyLinkedinFetchDescription,
rxResumeBaseResumeIdDraft,
])
const watchedValues = watch()
const lockedCount = watchedValues.resumeProjects?.lockedProjectIds.length ?? 0
const handleSave = async () => {
if (!settings || !resumeProjectsDraft) return
const canSave = isDirty && isValid
const onSave = async (data: UpdateSettingsInput) => {
if (!settings) return
if (data.enableBasicAuth && !settings.basicAuthActive) {
const password = data.basicAuthPassword?.trim() ?? ""
if (!password) {
setError("basicAuthPassword", {
type: "manual",
message: "Password is required when basic auth is enabled",
})
return
}
}
try {
setIsSaving(true)
const trimmed = modelDraft.trim()
const trimmedScorer = modelScorerDraft.trim()
const trimmedTailoring = modelTailoringDraft.trim()
const trimmedProjectSelection = modelProjectSelectionDraft.trim()
const webhookTrimmed = pipelineWebhookUrlDraft.trim()
const jobCompleteTrimmed = jobCompleteWebhookUrlDraft.trim()
const resumeProjectsOverride = resumeProjectsEqual(resumeProjectsDraft, settings.defaultResumeProjects)
// Prepare payload: nullify if equal to default
const resumeProjectsData = data.resumeProjects
const resumeProjectsOverride = (resumeProjectsData && defaultResumeProjects && resumeProjectsEqual(resumeProjectsData, defaultResumeProjects))
? null
: resumeProjectsDraft
const ukvisajobsMaxJobsOverride = ukvisajobsMaxJobsDraft === defaultUkvisajobsMaxJobs ? null : ukvisajobsMaxJobsDraft
const gradcrackerMaxJobsPerTermOverride = gradcrackerMaxJobsPerTermDraft === defaultGradcrackerMaxJobsPerTerm ? null : gradcrackerMaxJobsPerTermDraft
const searchTermsOverride = arraysEqual(searchTermsDraft ?? [], defaultSearchTerms) ? null : searchTermsDraft
const jobspyLocationOverride = jobspyLocationDraft === defaultJobspyLocation ? null : jobspyLocationDraft
const jobspyResultsWantedOverride = jobspyResultsWantedDraft === defaultJobspyResultsWanted ? null : jobspyResultsWantedDraft
const jobspyHoursOldOverride = jobspyHoursOldDraft === defaultJobspyHoursOld ? null : jobspyHoursOldDraft
const jobspyCountryIndeedOverride = jobspyCountryIndeedDraft === defaultJobspyCountryIndeed ? null : jobspyCountryIndeedDraft
const jobspySitesOverride = arraysEqual((jobspySitesDraft ?? []).slice().sort(), (defaultJobspySites ?? []).slice().sort()) ? null : jobspySitesDraft
const jobspyLinkedinFetchDescriptionOverride = jobspyLinkedinFetchDescriptionDraft === defaultJobspyLinkedinFetchDescription ? null : jobspyLinkedinFetchDescriptionDraft
const rxResumeBaseResumeIdOverride = rxResumeBaseResumeIdDraft
const updated = await api.updateSettings({
model: trimmed.length > 0 ? trimmed : null,
modelScorer: trimmedScorer.length > 0 ? trimmedScorer : null,
modelTailoring: trimmedTailoring.length > 0 ? trimmedTailoring : null,
modelProjectSelection: trimmedProjectSelection.length > 0 ? trimmedProjectSelection : null,
pipelineWebhookUrl: webhookTrimmed.length > 0 ? webhookTrimmed : null,
jobCompleteWebhookUrl: jobCompleteTrimmed.length > 0 ? jobCompleteTrimmed : null,
: resumeProjectsData
const envPayload: Partial<UpdateSettingsInput> = {}
if (dirtyFields.rxresumeEmail || dirtyFields.rxresumePassword) {
envPayload.rxresumeEmail = normalizeString(data.rxresumeEmail)
}
if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) {
envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail)
}
if (data.enableBasicAuth === false) {
envPayload.basicAuthUser = null
envPayload.basicAuthPassword = null
} else if (dirtyFields.enableBasicAuth || dirtyFields.basicAuthUser || dirtyFields.basicAuthPassword) {
// If enabling basic auth or changing either field, ensure we send at least the username
// to keep the pair consistent in the backend.
envPayload.basicAuthUser = normalizeString(data.basicAuthUser)
if (dirtyFields.basicAuthPassword) {
const value = normalizePrivateInput(data.basicAuthPassword)
if (value !== undefined) envPayload.basicAuthPassword = value
}
}
if (dirtyFields.openrouterApiKey) {
const value = normalizePrivateInput(data.openrouterApiKey)
if (value !== undefined) envPayload.openrouterApiKey = value
}
if (dirtyFields.rxresumePassword) {
const value = normalizePrivateInput(data.rxresumePassword)
if (value !== undefined) envPayload.rxresumePassword = value
}
if (dirtyFields.ukvisajobsPassword) {
const value = normalizePrivateInput(data.ukvisajobsPassword)
if (value !== undefined) envPayload.ukvisajobsPassword = value
}
if (dirtyFields.webhookSecret) {
const value = normalizePrivateInput(data.webhookSecret)
if (value !== undefined) envPayload.webhookSecret = value
}
const payload: UpdateSettingsInput = {
model: normalizeString(data.model),
modelScorer: normalizeString(data.modelScorer),
modelTailoring: normalizeString(data.modelTailoring),
modelProjectSelection: normalizeString(data.modelProjectSelection),
pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl),
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
resumeProjects: resumeProjectsOverride,
ukvisajobsMaxJobs: ukvisajobsMaxJobsOverride,
gradcrackerMaxJobsPerTerm: gradcrackerMaxJobsPerTermOverride,
searchTerms: searchTermsOverride,
jobspyLocation: jobspyLocationOverride,
jobspyResultsWanted: jobspyResultsWantedOverride,
jobspyHoursOld: jobspyHoursOldOverride,
jobspyCountryIndeed: jobspyCountryIndeedOverride,
jobspySites: jobspySitesOverride,
jobspyLinkedinFetchDescription: jobspyLinkedinFetchDescriptionOverride,
rxResumeBaseResumeId: rxResumeBaseResumeIdOverride,
})
ukvisajobsMaxJobs: nullIfSame(data.ukvisajobsMaxJobs, ukvisajobs.default),
gradcrackerMaxJobsPerTerm: nullIfSame(data.gradcrackerMaxJobsPerTerm, gradcracker.default),
searchTerms: nullIfSameList(data.searchTerms, searchTerms.default),
jobspyLocation: nullIfSame(data.jobspyLocation, jobspy.location.default),
jobspyResultsWanted: nullIfSame(data.jobspyResultsWanted, jobspy.resultsWanted.default),
jobspyHoursOld: nullIfSame(data.jobspyHoursOld, jobspy.hoursOld.default),
jobspyCountryIndeed: nullIfSame(data.jobspyCountryIndeed, jobspy.countryIndeed.default),
jobspySites: nullIfSameSortedList(data.jobspySites, jobspy.sites.default),
jobspyLinkedinFetchDescription: nullIfSame(
data.jobspyLinkedinFetchDescription,
jobspy.linkedinFetchDescription.default
),
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
...envPayload,
}
// Remove virtual field because the backend doesn't expect it
// this exists only to toggle the UI
// need to track it so that the save button is enabled when it changes
delete payload.enableBasicAuth
const updated = await api.updateSettings(payload)
setSettings(updated)
setModelDraft(updated.overrideModel ?? "")
setModelScorerDraft(updated.overrideModelScorer ?? "")
setModelTailoringDraft(updated.overrideModelTailoring ?? "")
setModelProjectSelectionDraft(updated.overrideModelProjectSelection ?? "")
setPipelineWebhookUrlDraft(updated.overridePipelineWebhookUrl ?? "")
setJobCompleteWebhookUrlDraft(updated.overrideJobCompleteWebhookUrl ?? "")
setResumeProjectsDraft(updated.resumeProjects)
setUkvisajobsMaxJobsDraft(updated.overrideUkvisajobsMaxJobs)
setGradcrackerMaxJobsPerTermDraft(updated.overrideGradcrackerMaxJobsPerTerm)
setSearchTermsDraft(updated.overrideSearchTerms)
setJobspyLocationDraft(updated.overrideJobspyLocation)
setJobspyResultsWantedDraft(updated.overrideJobspyResultsWanted)
setJobspyHoursOldDraft(updated.overrideJobspyHoursOld)
setJobspyCountryIndeedDraft(updated.overrideJobspyCountryIndeed)
setJobspySitesDraft(updated.overrideJobspySites)
setJobspyLinkedinFetchDescriptionDraft(updated.overrideJobspyLinkedinFetchDescription)
setRxResumeBaseResumeIdDraft(updated.rxResumeBaseResumeId)
reset(mapSettingsToForm(updated))
toast.success("Settings saved")
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to save settings"
@@ -273,6 +389,7 @@ export const SettingsPage: React.FC = () => {
setIsSaving(false)
}
}
const handleClearDatabase = async () => {
try {
setIsSaving(true)
@@ -331,43 +448,9 @@ export const SettingsPage: React.FC = () => {
const handleReset = async () => {
try {
setIsSaving(true)
const updated = await api.updateSettings({
model: null,
modelScorer: null,
modelTailoring: null,
modelProjectSelection: null,
pipelineWebhookUrl: null,
jobCompleteWebhookUrl: null,
resumeProjects: null,
ukvisajobsMaxJobs: null,
gradcrackerMaxJobsPerTerm: null,
searchTerms: null,
jobspyLocation: null,
jobspyResultsWanted: null,
jobspyHoursOld: null,
jobspyCountryIndeed: null,
jobspySites: null,
jobspyLinkedinFetchDescription: null,
rxResumeBaseResumeId: null,
})
const updated = await api.updateSettings(NULL_SETTINGS_PAYLOAD)
setSettings(updated)
setModelDraft("")
setModelScorerDraft("")
setModelTailoringDraft("")
setModelProjectSelectionDraft("")
setPipelineWebhookUrlDraft("")
setJobCompleteWebhookUrlDraft("")
setResumeProjectsDraft(updated.resumeProjects)
setUkvisajobsMaxJobsDraft(null)
setGradcrackerMaxJobsPerTermDraft(null)
setSearchTermsDraft(null)
setJobspyLocationDraft(null)
setJobspyResultsWantedDraft(null)
setJobspyHoursOldDraft(null)
setJobspyCountryIndeedDraft(null)
setJobspySitesDraft(null)
setJobspyLinkedinFetchDescriptionDraft(null)
setRxResumeBaseResumeIdDraft(null)
reset(mapSettingsToForm(updated))
toast.success("Reset to default")
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to reset settings"
@@ -378,7 +461,7 @@ export const SettingsPage: React.FC = () => {
}
return (
<>
<FormProvider {...methods}>
<PageHeader
icon={Settings}
title="Settings"
@@ -388,103 +471,51 @@ export const SettingsPage: React.FC = () => {
<main className="container mx-auto max-w-3xl space-y-6 px-4 py-6 pb-12">
<Accordion type="multiple" className="w-full space-y-4">
<ModelSettingsSection
modelDraft={modelDraft}
setModelDraft={setModelDraft}
modelScorerDraft={modelScorerDraft}
setModelScorerDraft={setModelScorerDraft}
modelTailoringDraft={modelTailoringDraft}
setModelTailoringDraft={setModelTailoringDraft}
modelProjectSelectionDraft={modelProjectSelectionDraft}
setModelProjectSelectionDraft={setModelProjectSelectionDraft}
effectiveModel={effectiveModel}
effectiveModelScorer={effectiveModelScorer}
effectiveModelTailoring={effectiveModelTailoring}
effectiveModelProjectSelection={effectiveModelProjectSelection}
defaultModel={defaultModel}
values={model}
isLoading={isLoading}
isSaving={isSaving}
/>
<PipelineWebhookSection
pipelineWebhookUrlDraft={pipelineWebhookUrlDraft}
setPipelineWebhookUrlDraft={setPipelineWebhookUrlDraft}
defaultPipelineWebhookUrl={defaultPipelineWebhookUrl}
effectivePipelineWebhookUrl={effectivePipelineWebhookUrl}
isLoading={isLoading}
isSaving={isSaving}
/>
<JobCompleteWebhookSection
jobCompleteWebhookUrlDraft={jobCompleteWebhookUrlDraft}
setJobCompleteWebhookUrlDraft={setJobCompleteWebhookUrlDraft}
defaultJobCompleteWebhookUrl={defaultJobCompleteWebhookUrl}
effectiveJobCompleteWebhookUrl={effectiveJobCompleteWebhookUrl}
<WebhooksSection
pipelineWebhook={pipelineWebhook}
jobCompleteWebhook={jobCompleteWebhook}
webhookSecretHint={envSettings.private.webhookSecretHint}
isLoading={isLoading}
isSaving={isSaving}
/>
<UkvisajobsSection
ukvisajobsMaxJobsDraft={ukvisajobsMaxJobsDraft}
setUkvisajobsMaxJobsDraft={setUkvisajobsMaxJobsDraft}
defaultUkvisajobsMaxJobs={defaultUkvisajobsMaxJobs}
effectiveUkvisajobsMaxJobs={effectiveUkvisajobsMaxJobs}
values={ukvisajobs}
isLoading={isLoading}
isSaving={isSaving}
/>
<GradcrackerSection
gradcrackerMaxJobsPerTermDraft={gradcrackerMaxJobsPerTermDraft}
setGradcrackerMaxJobsPerTermDraft={setGradcrackerMaxJobsPerTermDraft}
defaultGradcrackerMaxJobsPerTerm={defaultGradcrackerMaxJobsPerTerm}
effectiveGradcrackerMaxJobsPerTerm={effectiveGradcrackerMaxJobsPerTerm}
values={gradcracker}
isLoading={isLoading}
isSaving={isSaving}
/>
<SearchTermsSection
searchTermsDraft={searchTermsDraft}
setSearchTermsDraft={setSearchTermsDraft}
defaultSearchTerms={defaultSearchTerms}
effectiveSearchTerms={effectiveSearchTerms}
values={searchTerms}
isLoading={isLoading}
isSaving={isSaving}
/>
<JobspySection
jobspySitesDraft={jobspySitesDraft}
setJobspySitesDraft={setJobspySitesDraft}
defaultJobspySites={defaultJobspySites}
effectiveJobspySites={effectiveJobspySites}
jobspyLocationDraft={jobspyLocationDraft}
setJobspyLocationDraft={setJobspyLocationDraft}
defaultJobspyLocation={defaultJobspyLocation}
effectiveJobspyLocation={effectiveJobspyLocation}
jobspyResultsWantedDraft={jobspyResultsWantedDraft}
setJobspyResultsWantedDraft={setJobspyResultsWantedDraft}
defaultJobspyResultsWanted={defaultJobspyResultsWanted}
effectiveJobspyResultsWanted={effectiveJobspyResultsWanted}
jobspyHoursOldDraft={jobspyHoursOldDraft}
setJobspyHoursOldDraft={setJobspyHoursOldDraft}
defaultJobspyHoursOld={defaultJobspyHoursOld}
effectiveJobspyHoursOld={effectiveJobspyHoursOld}
jobspyCountryIndeedDraft={jobspyCountryIndeedDraft}
setJobspyCountryIndeedDraft={setJobspyCountryIndeedDraft}
defaultJobspyCountryIndeed={defaultJobspyCountryIndeed}
effectiveJobspyCountryIndeed={effectiveJobspyCountryIndeed}
jobspyLinkedinFetchDescriptionDraft={jobspyLinkedinFetchDescriptionDraft}
setJobspyLinkedinFetchDescriptionDraft={setJobspyLinkedinFetchDescriptionDraft}
defaultJobspyLinkedinFetchDescription={defaultJobspyLinkedinFetchDescription}
effectiveJobspyLinkedinFetchDescription={effectiveJobspyLinkedinFetchDescription}
values={jobspy}
isLoading={isLoading}
isSaving={isSaving}
/>
<ResumeProjectsSection
resumeProjectsDraft={resumeProjectsDraft}
setResumeProjectsDraft={setResumeProjectsDraft}
profileProjects={profileProjects}
lockedCount={lockedCount}
maxProjectsTotal={maxProjectsTotal}
isLoading={isLoading}
isSaving={isSaving}
/>
<ReactiveResumeSection
rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft}
setRxResumeBaseResumeIdDraft={setRxResumeBaseResumeIdDraft}
hasRxResumeApiKey={settings?.hasRxResumeApiKey ?? false}
<DisplaySettingsSection
values={display}
isLoading={isLoading}
isSaving={isSaving}
/>
<EnvironmentSettingsSection
values={envSettings}
isLoading={isLoading}
isSaving={isSaving}
/>
@@ -499,14 +530,19 @@ export const SettingsPage: React.FC = () => {
</Accordion>
<div className="flex flex-wrap gap-2">
<Button onClick={handleSave} disabled={isLoading || isSaving || !canSave}>
<Button onClick={handleSubmit(onSave)} disabled={isLoading || isSaving || !canSave}>
{isSaving ? "Saving..." : "Save"}
</Button>
<Button variant="outline" onClick={handleReset} disabled={isLoading || isSaving || !settings}>
Reset to default
</Button>
</div>
{Object.keys(errors).length > 0 && (
<div className="text-destructive text-sm mt-2">
Please fix the errors before saving.
</div>
)}
</main>
</>
</FormProvider>
)
}
@@ -31,6 +31,9 @@ vi.mock("../../components", () => ({
DiscoveredPanel: ({ job }: { job: Job | null }) => (
<div data-testid="discovered-panel">{job?.id ?? "no-job"}</div>
),
JobHeader: () => <div data-testid="job-header" />,
FitAssessment: () => <div data-testid="fit-assessment" />,
TailoredSummary: () => <div data-testid="tailored-summary" />,
}));
vi.mock("../../components/ReadyPanel", () => ({
@@ -63,6 +66,7 @@ vi.mock("../../api", () => ({
generateJobPdf: vi.fn(),
markAsApplied: vi.fn(),
skipJob: vi.fn(),
getProfile: vi.fn().mockResolvedValue({}),
}));
vi.mock("sonner", () => ({
@@ -100,6 +104,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
@@ -154,23 +160,7 @@ describe("JobDetailPanel", () => {
expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99");
});
it("wires ready panel edit actions back to the page", () => {
const onSetActiveTab = vi.fn();
render(
<JobDetailPanel
activeTab="ready"
activeJobs={[]}
selectedJob={createJob({ status: "ready" })}
onSelectJobId={vi.fn()}
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
onSetActiveTab={onSetActiveTab}
/>
);
fireEvent.click(screen.getByRole("button", { name: /edit description/i }));
expect(onSetActiveTab).toHaveBeenCalledWith("discovered");
});
it("shows an empty state when no job is selected", () => {
render(
@@ -30,6 +30,7 @@ import { copyTextToClipboard, formatJobForWebhook, safeFilenamePart, stripHtml }
import { DiscoveredPanel, FitAssessment, JobHeader, TailoredSummary } from "../../components";
import { ReadyPanel } from "../../components/ReadyPanel";
import { TailoringEditor } from "../../components/TailoringEditor";
import { useProfile } from "../../hooks/useProfile";
import * as api from "../../api";
import type { Job } from "../../../shared/types";
import type { FilterTab } from "./constants";
@@ -59,6 +60,8 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
const [processingJobId, setProcessingJobId] = useState<string | null>(null);
const saveTailoringRef = useRef<null | (() => Promise<void>)>(null);
const { personName } = useProfile();
useEffect(() => {
setHasUnsavedTailoring(false);
saveTailoringRef.current = null;
@@ -243,17 +246,6 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
job={selectedJob}
onJobUpdated={onJobUpdated}
onJobMoved={handleJobMoved}
onEditTailoring={() => {
onSetActiveTab("discovered");
setTimeout(() => setDetailTab("tailoring"), 50);
}}
onEditDescription={() => {
onSetActiveTab("discovered");
setTimeout(() => {
setDetailTab("description");
setIsEditingDescription(true);
}, 50);
}}
/>
);
}
@@ -269,7 +261,13 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
return (
<div className="space-y-3">
<JobHeader job={selectedJob} />
<JobHeader
job={selectedJob}
onCheckSponsor={async () => {
await api.checkSponsor(selectedJob.id);
await onJobUpdated();
}}
/>
<div className="flex flex-wrap items-center gap-1.5">
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">
@@ -368,7 +366,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
<DropdownMenuItem asChild>
<a
href={selectedPdfHref}
download={`Shaheer_Sarfaraz_${safeFilenamePart(selectedJob.employer)}.pdf`}
download={`${personName.replace(/\s+/g, '_')}_${safeFilenamePart(selectedJob.employer)}.pdf`}
>
<FileText className="mr-2 h-4 w-4" />
Download PDF
@@ -31,6 +31,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
@@ -16,7 +16,7 @@ import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import type { JobStatus } from "@shared/types"
import { ALL_JOB_STATUSES, STATUS_DESCRIPTIONS } from "../constants"
import { ALL_JOB_STATUSES, STATUS_DESCRIPTIONS } from "@client/pages/settings/constants"
type DangerZoneSectionProps = {
statusesToClear: JobStatus[]
@@ -0,0 +1,81 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { DisplayValues } from "@client/pages/settings/types"
type DisplaySettingsSectionProps = {
values: DisplayValues
isLoading: boolean
isSaving: boolean
}
export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
values,
isLoading,
isSaving,
}) => {
const { default: defaultShowSponsorInfo, effective: effectiveShowSponsorInfo } = values
const { control } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="display" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Display Settings</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="flex items-start space-x-3">
<Controller
name="showSponsorInfo"
control={control}
render={({ field }) => (
<Checkbox
id="showSponsorInfo"
checked={field.value ?? defaultShowSponsorInfo}
onCheckedChange={(checked) => {
field.onChange(checked === "indeterminate" ? null : checked === true)
}}
disabled={isLoading || isSaving}
/>
)}
/>
<div className="flex flex-col gap-1.5">
<label
htmlFor="showSponsorInfo"
className="text-sm font-medium leading-none cursor-pointer"
>
Show visa sponsor information
</label>
<p className="text-xs text-muted-foreground">
Display a badge next to the employer name showing the match
percentage with the UK visa sponsor list. This helps identify
employers that are licensed to sponsor work visas.
</p>
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">
{effectiveShowSponsorInfo ? "Enabled" : "Disabled"}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default</div>
<div className="break-words font-mono text-xs font-semibold">
{defaultShowSponsorInfo ? "Enabled" : "Disabled"}
</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}
@@ -0,0 +1,71 @@
import { render, screen } from "@testing-library/react"
import { useForm, FormProvider } from "react-hook-form"
import { Accordion } from "@/components/ui/accordion"
import { EnvironmentSettingsSection } from "./EnvironmentSettingsSection"
import { UpdateSettingsInput } from "@shared/settings-schema"
const EnvironmentSettingsHarness = () => {
const methods = useForm<UpdateSettingsInput>({
defaultValues: {
rxresumeEmail: "resume@example.com",
ukvisajobsEmail: "visa@example.com",
basicAuthUser: "admin",
openrouterApiKey: "",
rxresumePassword: "",
ukvisajobsPassword: "",
basicAuthPassword: "",
webhookSecret: "",
enableBasicAuth: true,
}
})
return (
<FormProvider {...methods}>
<Accordion type="multiple" defaultValue={["environment"]}>
<EnvironmentSettingsSection
values={{
readable: {
rxresumeEmail: "resume@example.com",
ukvisajobsEmail: "visa@example.com",
basicAuthUser: "admin",
},
private: {
openrouterApiKeyHint: "sk-1",
rxresumePasswordHint: null,
ukvisajobsPasswordHint: "pass",
basicAuthPasswordHint: "abcd",
webhookSecretHint: "sec-",
},
basicAuthActive: true,
}}
isLoading={false}
isSaving={false}
/>
</Accordion>
</FormProvider>
)
}
describe("EnvironmentSettingsSection", () => {
it("renders values grouped logically and masks private secrets with hints", () => {
render(<EnvironmentSettingsHarness />)
expect(screen.getByDisplayValue("resume@example.com")).toBeInTheDocument()
expect(screen.getByDisplayValue("visa@example.com")).toBeInTheDocument()
expect(screen.getByText(/sk-1\*{8}/)).toBeInTheDocument()
expect(screen.getByText(/pass\*{8}/)).toBeInTheDocument()
expect(screen.getByText(/abcd\*{8}/)).toBeInTheDocument()
expect(screen.getByText("Not set")).toBeInTheDocument()
// Basic Auth
expect(screen.getByLabelText("Enable basic authentication")).toBeChecked()
expect(screen.getByDisplayValue("admin")).toBeInTheDocument()
// Sections
expect(screen.getByText("External Services")).toBeInTheDocument()
expect(screen.getByText("Service Accounts")).toBeInTheDocument()
expect(screen.getByText("Security")).toBeInTheDocument()
})
})
@@ -0,0 +1,159 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { EnvSettingsValues } from "@client/pages/settings/types"
import { formatSecretHint } from "@client/pages/settings/utils"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
type EnvironmentSettingsSectionProps = {
values: EnvSettingsValues
isLoading: boolean
isSaving: boolean
}
export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProps> = ({
values,
isLoading,
isSaving,
}) => {
const { register, control, watch, formState: { errors } } = useFormContext<UpdateSettingsInput>()
const { private: privateValues } = values
const isBasicAuthEnabled = watch("enableBasicAuth")
return (
<AccordionItem value="environment" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Environment & Accounts</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-8">
{/* External Services */}
<div className="space-y-4">
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">External Services</div>
<div className="grid gap-4 md:grid-cols-2">
<SettingsInput
label="OpenRouter API key"
inputProps={register("openrouterApiKey")}
type="password"
placeholder="Enter new key"
disabled={isLoading || isSaving}
error={errors.openrouterApiKey?.message as string | undefined}
current={formatSecretHint(privateValues.openrouterApiKeyHint)}
/>
</div>
</div>
<Separator />
{/* Service Accounts */}
<div className="space-y-6">
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">Service Accounts</div>
<div className="space-y-4">
<div className="text-sm font-semibold">RxResume</div>
<div className="grid gap-4 md:grid-cols-2">
<SettingsInput
label="Email"
inputProps={register("rxresumeEmail")}
placeholder="you@example.com"
disabled={isLoading || isSaving}
error={errors.rxresumeEmail?.message as string | undefined}
/>
<SettingsInput
label="Password"
inputProps={register("rxresumePassword")}
type="password"
placeholder="Enter new password"
disabled={isLoading || isSaving}
error={errors.rxresumePassword?.message as string | undefined}
current={formatSecretHint(privateValues.rxresumePasswordHint)}
/>
</div>
</div>
<div className="space-y-4">
<div className="text-sm font-semibold">UKVisaJobs</div>
<div className="grid gap-4 md:grid-cols-2">
<SettingsInput
label="Email"
inputProps={register("ukvisajobsEmail")}
placeholder="you@example.com"
disabled={isLoading || isSaving}
error={errors.ukvisajobsEmail?.message as string | undefined}
/>
<SettingsInput
label="Password"
inputProps={register("ukvisajobsPassword")}
type="password"
placeholder="Enter new password"
disabled={isLoading || isSaving}
error={errors.ukvisajobsPassword?.message as string | undefined}
current={formatSecretHint(privateValues.ukvisajobsPasswordHint)}
/>
</div>
</div>
</div>
<Separator />
{/* Security */}
<div className="space-y-4">
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">Security</div>
<div className="flex items-start space-x-3">
<Controller
name="enableBasicAuth"
control={control}
render={({ field }) => (
<Checkbox
id="enableBasicAuth"
checked={field.value}
onCheckedChange={field.onChange}
disabled={isLoading || isSaving}
/>
)}
/>
<div className="flex flex-col gap-1.5">
<label
htmlFor="enableBasicAuth"
className="text-sm font-medium leading-none cursor-pointer"
>
Enable basic authentication
</label>
<p className="text-xs text-muted-foreground">
Require a username and password for write operations.
</p>
</div>
</div>
{isBasicAuthEnabled && (
<div className="grid gap-4 md:grid-cols-2 pt-2">
<SettingsInput
label="Username"
inputProps={register("basicAuthUser")}
placeholder="username"
disabled={isLoading || isSaving}
error={errors.basicAuthUser?.message as string | undefined}
/>
<SettingsInput
label="Password"
inputProps={register("basicAuthPassword")}
type="password"
placeholder="Enter new password"
disabled={isLoading || isSaving}
error={errors.basicAuthPassword?.message as string | undefined}
current={formatSecretHint(privateValues.basicAuthPasswordHint)}
/>
</div>
)}
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}
@@ -1,26 +1,25 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { NumericSettingValues } from "@client/pages/settings/types"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
type GradcrackerSectionProps = {
gradcrackerMaxJobsPerTermDraft: number | null
setGradcrackerMaxJobsPerTermDraft: (value: number | null) => void
defaultGradcrackerMaxJobsPerTerm: number
effectiveGradcrackerMaxJobsPerTerm: number
values: NumericSettingValues
isLoading: boolean
isSaving: boolean
}
export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
gradcrackerMaxJobsPerTermDraft,
setGradcrackerMaxJobsPerTermDraft,
defaultGradcrackerMaxJobsPerTerm,
effectiveGradcrackerMaxJobsPerTerm,
values,
isLoading,
isSaving,
}) => {
const { effective: effectiveGradcrackerMaxJobsPerTerm, default: defaultGradcrackerMaxJobsPerTerm } = values
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="gradcracker" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@@ -28,41 +27,35 @@ export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max jobs per search term</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={gradcrackerMaxJobsPerTermDraft ?? defaultGradcrackerMaxJobsPerTerm}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setGradcrackerMaxJobsPerTermDraft(null)
} else {
setGradcrackerMaxJobsPerTermDraft(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Maximum number of jobs to fetch for EACH search term from Gradcracker. Range: 1-1000.
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectiveGradcrackerMaxJobsPerTerm}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default</div>
<div className="break-words font-mono text-xs font-semibold">{defaultGradcrackerMaxJobsPerTerm}</div>
</div>
</div>
<Controller
name="gradcrackerMaxJobsPerTerm"
control={control}
render={({ field }) => (
<SettingsInput
label="Max jobs per search term"
type="number"
inputProps={{
...field,
inputMode: "numeric",
min: 1,
max: 1000,
value: field.value ?? defaultGradcrackerMaxJobsPerTerm,
onChange: (event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
field.onChange(null)
} else {
field.onChange(Math.min(1000, Math.max(1, value)))
}
},
}}
disabled={isLoading || isSaving}
error={errors.gradcrackerMaxJobsPerTerm?.message as string | undefined}
helper={`Maximum number of jobs to fetch for EACH search term from Gradcracker. Default: ${defaultGradcrackerMaxJobsPerTerm}. Range: 1-1000.`}
current={String(effectiveGradcrackerMaxJobsPerTerm)}
/>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
@@ -1,60 +0,0 @@
import React from "react"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
type JobCompleteWebhookSectionProps = {
jobCompleteWebhookUrlDraft: string
setJobCompleteWebhookUrlDraft: (value: string) => void
defaultJobCompleteWebhookUrl: string
effectiveJobCompleteWebhookUrl: string
isLoading: boolean
isSaving: boolean
}
export const JobCompleteWebhookSection: React.FC<JobCompleteWebhookSectionProps> = ({
jobCompleteWebhookUrlDraft,
setJobCompleteWebhookUrlDraft,
defaultJobCompleteWebhookUrl,
effectiveJobCompleteWebhookUrl,
isLoading,
isSaving,
}) => {
return (
<AccordionItem value="job-complete-webhook" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Job Complete Webhook</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Job completion webhook URL</div>
<Input
value={jobCompleteWebhookUrlDraft}
onChange={(event) => setJobCompleteWebhookUrlDraft(event.target.value)}
placeholder={defaultJobCompleteWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
When set, the server sends a POST when you mark a job as applied (includes the job description).
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectiveJobCompleteWebhookUrl || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{defaultJobCompleteWebhookUrl || "—"}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}
@@ -1,52 +1,44 @@
import { describe, it, expect } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import { useState } from "react"
import { useForm, FormProvider } from "react-hook-form"
import { Accordion } from "@/components/ui/accordion"
import { JobspySection } from "./JobspySection"
import { UpdateSettingsInput } from "@shared/settings-schema"
const JobspyHarness = () => {
const [jobspySitesDraft, setJobspySitesDraft] = useState<string[] | null>(null)
const [jobspyLocationDraft, setJobspyLocationDraft] = useState<string | null>(null)
const [jobspyResultsWantedDraft, setJobspyResultsWantedDraft] = useState<number | null>(null)
const [jobspyHoursOldDraft, setJobspyHoursOldDraft] = useState<number | null>(null)
const [jobspyCountryIndeedDraft, setJobspyCountryIndeedDraft] = useState<string | null>(null)
const [jobspyLinkedinFetchDescriptionDraft, setJobspyLinkedinFetchDescriptionDraft] = useState<boolean | null>(null)
const methods = useForm<UpdateSettingsInput>({
defaultValues: {
jobspySites: ["indeed", "linkedin"],
jobspyLocation: "UK",
jobspyResultsWanted: 200,
jobspyHoursOld: 72,
jobspyCountryIndeed: "UK",
jobspyLinkedinFetchDescription: true,
}
})
return (
<Accordion type="multiple" defaultValue={["jobspy"]}>
<JobspySection
jobspySitesDraft={jobspySitesDraft}
setJobspySitesDraft={setJobspySitesDraft}
defaultJobspySites={["indeed", "linkedin"]}
effectiveJobspySites={["indeed", "linkedin"]}
jobspyLocationDraft={jobspyLocationDraft}
setJobspyLocationDraft={setJobspyLocationDraft}
defaultJobspyLocation="UK"
effectiveJobspyLocation="UK"
jobspyResultsWantedDraft={jobspyResultsWantedDraft}
setJobspyResultsWantedDraft={setJobspyResultsWantedDraft}
defaultJobspyResultsWanted={200}
effectiveJobspyResultsWanted={200}
jobspyHoursOldDraft={jobspyHoursOldDraft}
setJobspyHoursOldDraft={setJobspyHoursOldDraft}
defaultJobspyHoursOld={72}
effectiveJobspyHoursOld={72}
jobspyCountryIndeedDraft={jobspyCountryIndeedDraft}
setJobspyCountryIndeedDraft={setJobspyCountryIndeedDraft}
defaultJobspyCountryIndeed="UK"
effectiveJobspyCountryIndeed="UK"
jobspyLinkedinFetchDescriptionDraft={jobspyLinkedinFetchDescriptionDraft}
setJobspyLinkedinFetchDescriptionDraft={setJobspyLinkedinFetchDescriptionDraft}
defaultJobspyLinkedinFetchDescription={true}
effectiveJobspyLinkedinFetchDescription={true}
isLoading={false}
isSaving={false}
/>
</Accordion>
<FormProvider {...methods}>
<Accordion type="multiple" defaultValue={["jobspy"]}>
<JobspySection
values={{
sites: { default: ["indeed", "linkedin"], effective: ["indeed", "linkedin"] },
location: { default: "UK", effective: "UK" },
resultsWanted: { default: 200, effective: 200 },
hoursOld: { default: 72, effective: 72 },
countryIndeed: { default: "UK", effective: "UK" },
linkedinFetchDescription: { default: true, effective: true },
}}
isLoading={false}
isSaving={false}
/>
</Accordion>
</FormProvider>
)
}
describe("JobspySection", () => {
it("toggles scraped sites and keeps checkboxes in sync", () => {
render(<JobspyHarness />)
@@ -72,8 +64,8 @@ describe("JobspySection", () => {
const resultsWantedInput = numericInputs[0]
const hoursOldInput = numericInputs[1]
fireEvent.change(resultsWantedInput, { target: { value: "999" } })
expect(resultsWantedInput).toHaveValue(500)
fireEvent.change(resultsWantedInput, { target: { value: "1001" } })
expect(resultsWantedInput).toHaveValue(1000)
fireEvent.change(hoursOldInput, { target: { value: "0" } })
expect(hoursOldInput).toHaveValue(1)
@@ -1,67 +1,34 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { JobspyValues } from "@client/pages/settings/types"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
type JobspySectionProps = {
jobspySitesDraft: string[] | null
setJobspySitesDraft: (value: string[] | null) => void
defaultJobspySites: string[]
effectiveJobspySites: string[]
jobspyLocationDraft: string | null
setJobspyLocationDraft: (value: string | null) => void
defaultJobspyLocation: string
effectiveJobspyLocation: string
jobspyResultsWantedDraft: number | null
setJobspyResultsWantedDraft: (value: number | null) => void
defaultJobspyResultsWanted: number
effectiveJobspyResultsWanted: number
jobspyHoursOldDraft: number | null
setJobspyHoursOldDraft: (value: number | null) => void
defaultJobspyHoursOld: number
effectiveJobspyHoursOld: number
jobspyCountryIndeedDraft: string | null
setJobspyCountryIndeedDraft: (value: string | null) => void
defaultJobspyCountryIndeed: string
effectiveJobspyCountryIndeed: string
jobspyLinkedinFetchDescriptionDraft: boolean | null
setJobspyLinkedinFetchDescriptionDraft: (value: boolean | null) => void
defaultJobspyLinkedinFetchDescription: boolean
effectiveJobspyLinkedinFetchDescription: boolean
values: JobspyValues
isLoading: boolean
isSaving: boolean
}
export const JobspySection: React.FC<JobspySectionProps> = ({
jobspySitesDraft,
setJobspySitesDraft,
defaultJobspySites,
effectiveJobspySites,
jobspyLocationDraft,
setJobspyLocationDraft,
defaultJobspyLocation,
effectiveJobspyLocation,
jobspyResultsWantedDraft,
setJobspyResultsWantedDraft,
defaultJobspyResultsWanted,
effectiveJobspyResultsWanted,
jobspyHoursOldDraft,
setJobspyHoursOldDraft,
defaultJobspyHoursOld,
effectiveJobspyHoursOld,
jobspyCountryIndeedDraft,
setJobspyCountryIndeedDraft,
defaultJobspyCountryIndeed,
effectiveJobspyCountryIndeed,
jobspyLinkedinFetchDescriptionDraft,
setJobspyLinkedinFetchDescriptionDraft,
defaultJobspyLinkedinFetchDescription,
effectiveJobspyLinkedinFetchDescription,
values,
isLoading,
isSaving,
}) => {
const {
sites,
location,
resultsWanted,
hoursOld,
countryIndeed,
linkedinFetchDescription,
} = values
const { control, register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="jobspy" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@@ -73,149 +40,160 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
<div className="text-sm font-medium">Scraped Sites</div>
<div className="flex gap-6">
<div className="flex items-center space-x-2">
<Checkbox
id="site-indeed"
checked={jobspySitesDraft?.includes('indeed') ?? defaultJobspySites.includes('indeed')}
onCheckedChange={(checked) => {
const current = jobspySitesDraft ?? defaultJobspySites
let next = [...current]
if (checked) {
if (!next.includes('indeed')) next.push('indeed')
} else {
next = next.filter(s => s !== 'indeed')
}
setJobspySitesDraft(next)
}}
disabled={isLoading || isSaving}
<Controller
name="jobspySites"
control={control}
render={({ field }) => (
<Checkbox
id="site-indeed"
checked={field.value?.includes('indeed') ?? sites.default.includes('indeed')}
onCheckedChange={(checked) => {
const current = field.value ?? sites.default
let next = [...current]
if (checked) {
if (!next.includes('indeed')) next.push('indeed')
} else {
next = next.filter(s => s !== 'indeed')
}
field.onChange(next)
}}
disabled={isLoading || isSaving}
/>
)}
/>
<label htmlFor="site-indeed" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Indeed</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="site-linkedin"
checked={jobspySitesDraft?.includes('linkedin') ?? defaultJobspySites.includes('linkedin')}
onCheckedChange={(checked) => {
const current = jobspySitesDraft ?? defaultJobspySites
let next = [...current]
if (checked) {
if (!next.includes('linkedin')) next.push('linkedin')
} else {
next = next.filter(s => s !== 'linkedin')
}
setJobspySitesDraft(next)
}}
disabled={isLoading || isSaving}
<Controller
name="jobspySites"
control={control}
render={({ field }) => (
<Checkbox
id="site-linkedin"
checked={field.value?.includes('linkedin') ?? sites.default.includes('linkedin')}
onCheckedChange={(checked) => {
const current = field.value ?? sites.default
let next = [...current]
if (checked) {
if (!next.includes('linkedin')) next.push('linkedin')
} else {
next = next.filter(s => s !== 'linkedin')
}
field.onChange(next)
}}
disabled={isLoading || isSaving}
/>
)}
/>
<label htmlFor="site-linkedin" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">LinkedIn</label>
</div>
</div>
{errors.jobspySites && <p className="text-xs text-destructive">{errors.jobspySites.message}</p>}
<div className="text-xs text-muted-foreground">
Select which sites JobSpy should scrape.
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {(effectiveJobspySites || []).join(', ') || "None"}</span>
<span>Default: {(defaultJobspySites || []).join(', ')}</span>
<span>Effective: {(sites.effective || []).join(', ') || "None"}</span>
<span>Default: {(sites.default || []).join(', ')}</span>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<div className="text-sm font-medium">Location</div>
<Input
value={jobspyLocationDraft ?? defaultJobspyLocation}
onChange={(event) => setJobspyLocationDraft(event.target.value)}
placeholder={defaultJobspyLocation || "UK"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Location to search for jobs (e.g. "UK", "London", "Remote").
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyLocation || "—"}</span>
<span>Default: {defaultJobspyLocation || "—"}</span>
</div>
</div>
<SettingsInput
label="Location"
inputProps={register("jobspyLocation")}
placeholder={location.default || "UK"}
disabled={isLoading || isSaving}
error={errors.jobspyLocation?.message as string | undefined}
helper={'Location to search for jobs (e.g. "UK", "London", "Remote").'}
current={`Effective: ${location.effective || "—"} | Default: ${location.default || "—"}`}
/>
<div className="space-y-2">
<div className="text-sm font-medium">Results Wanted</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={500}
value={jobspyResultsWantedDraft ?? defaultJobspyResultsWanted}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setJobspyResultsWantedDraft(null)
} else {
setJobspyResultsWantedDraft(Math.min(500, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Number of results to fetch per term per site. Max 500.
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyResultsWanted}</span>
<span>Default: {defaultJobspyResultsWanted}</span>
</div>
</div>
<Controller
name="jobspyResultsWanted"
control={control}
render={({ field }) => (
<SettingsInput
label="Results Wanted"
type="number"
inputProps={{
...field,
inputMode: "numeric",
min: 1,
max: 1000,
value: field.value ?? resultsWanted.default,
onChange: (event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
field.onChange(null)
} else {
field.onChange(Math.min(1000, Math.max(1, value)))
}
},
}}
disabled={isLoading || isSaving}
error={errors.jobspyResultsWanted?.message as string | undefined}
helper={`Number of results to fetch per term per site. Default: ${resultsWanted.default}. Max 1000.`}
current={`Effective: ${resultsWanted.effective} | Default: ${resultsWanted.default}`}
/>
)}
/>
<div className="space-y-2">
<div className="text-sm font-medium">Hours Old</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={168}
value={jobspyHoursOldDraft ?? defaultJobspyHoursOld}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setJobspyHoursOldDraft(null)
} else {
setJobspyHoursOldDraft(Math.min(168, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Max age of jobs in hours (e.g. 72 for 3 days).
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyHoursOld}h</span>
<span>Default: {defaultJobspyHoursOld}h</span>
</div>
</div>
<Controller
name="jobspyHoursOld"
control={control}
render={({ field }) => (
<SettingsInput
label="Hours Old"
type="number"
inputProps={{
...field,
inputMode: "numeric",
min: 1,
max: 720,
value: field.value ?? hoursOld.default,
onChange: (event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
field.onChange(null)
} else {
field.onChange(Math.min(720, Math.max(1, value)))
}
},
}}
disabled={isLoading || isSaving}
error={errors.jobspyHoursOld?.message as string | undefined}
helper={`Max age of jobs in hours (e.g. 72 for 3 days). Default: ${hoursOld.default}. Max 720.`}
current={`Effective: ${hoursOld.effective}h | Default: ${hoursOld.default}h`}
/>
)}
/>
<div className="space-y-2">
<div className="text-sm font-medium">Indeed Country</div>
<Input
value={jobspyCountryIndeedDraft ?? defaultJobspyCountryIndeed}
onChange={(event) => setJobspyCountryIndeedDraft(event.target.value)}
placeholder={defaultJobspyCountryIndeed || "UK"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Country domain for Indeed (e.g. "UK" for indeed.co.uk).
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyCountryIndeed || "—"}</span>
<span>Default: {defaultJobspyCountryIndeed || "—"}</span>
</div>
</div>
<SettingsInput
label="Indeed Country"
inputProps={register("jobspyCountryIndeed")}
placeholder={countryIndeed.default || "UK"}
disabled={isLoading || isSaving}
error={errors.jobspyCountryIndeed?.message as string | undefined}
helper={'Country domain for Indeed (e.g. "UK" for indeed.co.uk).'}
current={`Effective: ${countryIndeed.effective || "—"} | Default: ${countryIndeed.default || "—"}`}
/>
</div>
<Separator />
<div className="flex items-center space-x-2">
<Checkbox
id="linkedin-desc"
checked={jobspyLinkedinFetchDescriptionDraft ?? defaultJobspyLinkedinFetchDescription}
onCheckedChange={(checked) => setJobspyLinkedinFetchDescriptionDraft(!!checked)}
disabled={isLoading || isSaving}
<Controller
name="jobspyLinkedinFetchDescription"
control={control}
render={({ field }) => (
<Checkbox
id="linkedin-desc"
checked={field.value ?? linkedinFetchDescription.default}
onCheckedChange={(checked) => field.onChange(!!checked)}
disabled={isLoading || isSaving}
/>
)}
/>
<div className="grid gap-1.5 leading-none">
<label
@@ -228,8 +206,8 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
If enabled, JobSpy will make extra requests to fetch full descriptions. Slower but better data.
</p>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
<span>Default: {defaultJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
<span>Effective: {linkedinFetchDescription.effective ? "Yes" : "No"}</span>
<span>Default: {linkedinFetchDescription.default ? "Yes" : "No"}</span>
</div>
</div>
</div>
@@ -1,44 +1,26 @@
import React from "react"
import { useFormContext } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { ModelValues } from "@client/pages/settings/types"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
type ModelSettingsSectionProps = {
modelDraft: string
setModelDraft: (value: string) => void
modelScorerDraft: string
setModelScorerDraft: (value: string) => void
modelTailoringDraft: string
setModelTailoringDraft: (value: string) => void
modelProjectSelectionDraft: string
setModelProjectSelectionDraft: (value: string) => void
effectiveModel: string
effectiveModelScorer: string
effectiveModelTailoring: string
effectiveModelProjectSelection: string
defaultModel: string
values: ModelValues
isLoading: boolean
isSaving: boolean
}
export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
modelDraft,
setModelDraft,
modelScorerDraft,
setModelScorerDraft,
modelTailoringDraft,
setModelTailoringDraft,
modelProjectSelectionDraft,
setModelProjectSelectionDraft,
effectiveModel,
effectiveModelScorer,
effectiveModelTailoring,
effectiveModelProjectSelection,
defaultModel,
values,
isLoading,
isSaving,
}) => {
const { effective, default: defaultModel, scorer, tailoring, projectSelection } = values
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="model" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@@ -46,18 +28,15 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Override model</div>
<Input
value={modelDraft}
onChange={(event) => setModelDraft(event.target.value)}
placeholder={defaultModel || "openai/gpt-4o-mini"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Leave blank to use the default from server env (`MODEL`).
</div>
</div>
<SettingsInput
label="Override model"
inputProps={register("model")}
placeholder={defaultModel || "google/gemini-3-flash-preview"}
disabled={isLoading || isSaving}
error={errors.model?.message as string | undefined}
helper="Leave blank to use the default from server env (`MODEL`)."
current={effective || "—"}
/>
<Separator />
@@ -65,44 +44,32 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
<div className="text-sm font-medium">Task-Specific Overrides</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="space-y-2">
<div className="text-sm">Scoring Model</div>
<Input
value={modelScorerDraft}
onChange={(event) => setModelScorerDraft(event.target.value)}
placeholder={effectiveModel || "inherit"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelScorer || effectiveModel}</span>
</div>
</div>
<SettingsInput
label="Scoring Model"
inputProps={register("modelScorer")}
placeholder={effective || "inherit"}
disabled={isLoading || isSaving}
error={errors.modelScorer?.message as string | undefined}
current={scorer || effective || "—"}
/>
<div className="space-y-2">
<div className="text-sm">Tailoring Model</div>
<Input
value={modelTailoringDraft}
onChange={(event) => setModelTailoringDraft(event.target.value)}
placeholder={effectiveModel || "inherit"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelTailoring || effectiveModel}</span>
</div>
</div>
<SettingsInput
label="Tailoring Model"
inputProps={register("modelTailoring")}
placeholder={effective || "inherit"}
disabled={isLoading || isSaving}
error={errors.modelTailoring?.message as string | undefined}
current={tailoring || effective || "—"}
/>
<div className="space-y-2">
<div className="text-sm">Project Selection Model</div>
<Input
value={modelProjectSelectionDraft}
onChange={(event) => setModelProjectSelectionDraft(event.target.value)}
placeholder={effectiveModel || "inherit"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelProjectSelection || effectiveModel}</span>
</div>
</div>
<SettingsInput
label="Project Selection Model"
inputProps={register("modelProjectSelection")}
placeholder={effective || "inherit"}
disabled={isLoading || isSaving}
error={errors.modelProjectSelection?.message as string | undefined}
current={projectSelection || effective || "—"}
/>
</div>
</div>
@@ -111,7 +78,7 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Global Effective</div>
<div className="break-words font-mono text-xs">{effectiveModel || "—"}</div>
<div className="break-words font-mono text-xs">{effective || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
@@ -1,60 +0,0 @@
import React from "react"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
type PipelineWebhookSectionProps = {
pipelineWebhookUrlDraft: string
setPipelineWebhookUrlDraft: (value: string) => void
defaultPipelineWebhookUrl: string
effectivePipelineWebhookUrl: string
isLoading: boolean
isSaving: boolean
}
export const PipelineWebhookSection: React.FC<PipelineWebhookSectionProps> = ({
pipelineWebhookUrlDraft,
setPipelineWebhookUrlDraft,
defaultPipelineWebhookUrl,
effectivePipelineWebhookUrl,
isLoading,
isSaving,
}) => {
return (
<AccordionItem value="pipeline-webhook" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Pipeline Webhook</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Pipeline status webhook URL</div>
<Input
value={pipelineWebhookUrlDraft}
onChange={(event) => setPipelineWebhookUrlDraft(event.target.value)}
placeholder={defaultPipelineWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
When set, the server sends a POST on pipeline completion/failure. Leave blank to disable.
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectivePipelineWebhookUrl || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{defaultPipelineWebhookUrl || "—"}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}
@@ -1,10 +1,11 @@
import { describe, it, expect } from "vitest"
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { useState } from "react"
import { useForm, FormProvider } from "react-hook-form"
import { Accordion } from "@/components/ui/accordion"
import { ResumeProjectsSection } from "./ResumeProjectsSection"
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types"
import type { ResumeProjectCatalogItem } from "@shared/types"
import { UpdateSettingsInput } from "@shared/settings-schema"
const profileProjects: ResumeProjectCatalogItem[] = [
{
@@ -23,25 +24,31 @@ const profileProjects: ResumeProjectCatalogItem[] = [
},
]
const ResumeProjectsHarness = ({ initialDraft }: { initialDraft: ResumeProjectsSettings | null }) => {
const [draft, setDraft] = useState<ResumeProjectsSettings | null>(initialDraft)
const lockedCount = draft?.lockedProjectIds.length ?? 0
const ResumeProjectsHarness = ({ initialDraft }: { initialDraft: UpdateSettingsInput["resumeProjects"] }) => {
const methods = useForm<UpdateSettingsInput>({
defaultValues: {
resumeProjects: initialDraft
}
})
const watched = methods.watch()
const lockedCount = watched.resumeProjects?.lockedProjectIds.length ?? 0
return (
<Accordion type="multiple" defaultValue={["resume-projects"]}>
<ResumeProjectsSection
resumeProjectsDraft={draft}
setResumeProjectsDraft={setDraft}
profileProjects={profileProjects}
lockedCount={lockedCount}
maxProjectsTotal={profileProjects.length}
isLoading={false}
isSaving={false}
/>
</Accordion>
<FormProvider {...methods}>
<Accordion type="multiple" defaultValue={["resume-projects"]}>
<ResumeProjectsSection
profileProjects={profileProjects}
lockedCount={lockedCount}
maxProjectsTotal={profileProjects.length}
isLoading={false}
isSaving={false}
/>
</Accordion>
</FormProvider>
)
}
describe("ResumeProjectsSection", () => {
it("clamps max projects to the locked count", async () => {
render(
@@ -1,16 +1,16 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types"
import type { ResumeProjectCatalogItem } from "@shared/types"
import { clampInt } from "@/lib/utils"
import { UpdateSettingsInput } from "@shared/settings-schema"
type ResumeProjectsSectionProps = {
resumeProjectsDraft: ResumeProjectsSettings | null
setResumeProjectsDraft: (value: ResumeProjectsSettings | null) => void
profileProjects: ResumeProjectCatalogItem[]
lockedCount: number
maxProjectsTotal: number
@@ -19,14 +19,14 @@ type ResumeProjectsSectionProps = {
}
export const ResumeProjectsSection: React.FC<ResumeProjectsSectionProps> = ({
resumeProjectsDraft,
setResumeProjectsDraft,
profileProjects,
lockedCount,
maxProjectsTotal,
isLoading,
isSaving,
}) => {
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="resume-projects" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@@ -36,113 +36,126 @@ export const ResumeProjectsSection: React.FC<ResumeProjectsSectionProps> = ({
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max projects included</div>
<Input
type="number"
inputMode="numeric"
min={lockedCount}
max={maxProjectsTotal}
value={resumeProjectsDraft?.maxProjects ?? 0}
onChange={(event) => {
if (!resumeProjectsDraft) return
const next = Number(event.target.value)
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
setResumeProjectsDraft({ ...resumeProjectsDraft, maxProjects: clamped })
}}
disabled={isLoading || isSaving || !resumeProjectsDraft}
<Controller
name="resumeProjects"
control={control}
render={({ field }) => (
<Input
type="number"
inputMode="numeric"
min={lockedCount}
max={maxProjectsTotal}
value={field.value?.maxProjects ?? 0}
onChange={(event) => {
if (!field.value) return
const next = Number(event.target.value)
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
field.onChange({ ...field.value, maxProjects: clamped })
}}
disabled={isLoading || isSaving || !field.value}
/>
)}
/>
{errors.resumeProjects?.maxProjects && <p className="text-xs text-destructive">{errors.resumeProjects.maxProjects.message}</p>}
<div className="text-xs text-muted-foreground">
Locked projects always count towards this cap. Locked: {lockedCount} · AI pool:{" "}
{resumeProjectsDraft?.aiSelectableProjectIds.length ?? 0} · Total projects: {maxProjectsTotal}
AI pool (max projects AI can use): {maxProjectsTotal}. Locked projects always count towards this cap. Locked: {lockedCount} · Total profile projects: {profileProjects.length}
</div>
</div>
<Separator />
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead className="w-[110px]">Base visible</TableHead>
<TableHead className="w-[90px]">Locked</TableHead>
<TableHead className="w-[140px]">AI selectable</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profileProjects.map((project) => {
const locked = Boolean(resumeProjectsDraft?.lockedProjectIds.includes(project.id))
const aiSelectable = Boolean(resumeProjectsDraft?.aiSelectableProjectIds.includes(project.id))
const excluded = !locked && !aiSelectable
return (
<TableRow key={project.id}>
<TableCell>
<div className="space-y-0.5">
<div className="font-medium">{project.name || project.id}</div>
<div className="text-xs text-muted-foreground">
{[project.description, project.date].filter(Boolean).join(" · ")}
{excluded ? " · Excluded" : ""}
</div>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
<TableCell>
<Checkbox
checked={locked}
disabled={isLoading || isSaving || !resumeProjectsDraft}
onCheckedChange={(checked) => {
if (!resumeProjectsDraft) return
const isChecked = checked === true
const lockedIds = resumeProjectsDraft.lockedProjectIds.slice()
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
if (isChecked) {
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
const nextSelectable = selectableIds.filter((id) => id !== project.id)
const minCap = lockedIds.length
setResumeProjectsDraft({
...resumeProjectsDraft,
lockedProjectIds: lockedIds,
aiSelectableProjectIds: nextSelectable,
maxProjects: Math.max(resumeProjectsDraft.maxProjects, minCap),
})
return
}
const nextLocked = lockedIds.filter((id) => id !== project.id)
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
setResumeProjectsDraft({
...resumeProjectsDraft,
lockedProjectIds: nextLocked,
aiSelectableProjectIds: selectableIds,
maxProjects: clampInt(resumeProjectsDraft.maxProjects, nextLocked.length, maxProjectsTotal),
})
}}
/>
</TableCell>
<TableCell>
<Checkbox
checked={locked ? true : aiSelectable}
disabled={locked || isLoading || isSaving || !resumeProjectsDraft}
onCheckedChange={(checked) => {
if (!resumeProjectsDraft) return
const isChecked = checked === true
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
const nextSelectable = isChecked
? selectableIds.includes(project.id)
? selectableIds
: [...selectableIds, project.id]
: selectableIds.filter((id) => id !== project.id)
setResumeProjectsDraft({ ...resumeProjectsDraft, aiSelectableProjectIds: nextSelectable })
}}
/>
</TableCell>
<Controller
name="resumeProjects"
control={control}
render={({ field }) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead className="w-[110px]">Base visible</TableHead>
<TableHead className="w-[90px]">Locked</TableHead>
<TableHead className="w-[140px]">AI selectable</TableHead>
</TableRow>
)
})}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{profileProjects.map((project) => {
const locked = Boolean(field.value?.lockedProjectIds.includes(project.id))
const aiSelectable = Boolean(field.value?.aiSelectableProjectIds.includes(project.id))
const excluded = !locked && !aiSelectable
return (
<TableRow key={project.id}>
<TableCell>
<div className="space-y-0.5">
<div className="font-medium">{project.name || project.id}</div>
<div className="text-xs text-muted-foreground">
{[project.description, project.date].filter(Boolean).join(" · ")}
{excluded ? " · Excluded" : ""}
</div>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
<TableCell>
<Checkbox
checked={locked}
disabled={isLoading || isSaving || !field.value}
onCheckedChange={(checked) => {
if (!field.value) return
const isChecked = checked === true
const lockedIds = field.value.lockedProjectIds.slice()
const selectableIds = field.value.aiSelectableProjectIds.slice()
if (isChecked) {
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
const nextSelectable = selectableIds.filter((id) => id !== project.id)
const minCap = lockedIds.length
field.onChange({
...field.value,
lockedProjectIds: lockedIds,
aiSelectableProjectIds: nextSelectable,
maxProjects: Math.max(field.value.maxProjects, minCap),
})
return
}
const nextLocked = lockedIds.filter((id) => id !== project.id)
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
field.onChange({
...field.value,
lockedProjectIds: nextLocked,
aiSelectableProjectIds: selectableIds,
maxProjects: clampInt(field.value.maxProjects, nextLocked.length, maxProjectsTotal),
})
}}
/>
</TableCell>
<TableCell>
<Checkbox
checked={locked ? true : aiSelectable}
disabled={locked || isLoading || isSaving || !field.value}
onCheckedChange={(checked) => {
if (!field.value) return
const isChecked = checked === true
const selectableIds = field.value.aiSelectableProjectIds.slice()
const nextSelectable = isChecked
? selectableIds.includes(project.id)
? selectableIds
: [...selectableIds, project.id]
: selectableIds.filter((id) => id !== project.id)
field.onChange({ ...field.value, aiSelectableProjectIds: nextSelectable })
}}
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
)
}
@@ -1,25 +1,25 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { SearchTermsValues } from "@client/pages/settings/types"
type SearchTermsSectionProps = {
searchTermsDraft: string[] | null
setSearchTermsDraft: (value: string[] | null) => void
defaultSearchTerms: string[]
effectiveSearchTerms: string[]
values: SearchTermsValues
isLoading: boolean
isSaving: boolean
}
export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
searchTermsDraft,
setSearchTermsDraft,
defaultSearchTerms,
effectiveSearchTerms,
values,
isLoading,
isSaving,
}) => {
const { default: defaultSearchTerms, effective: effectiveSearchTerms } = values
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="search-terms" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@@ -29,24 +29,30 @@ export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Global search terms</div>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={searchTermsDraft ? searchTermsDraft.join('\n') : (defaultSearchTerms ?? []).join('\n')}
onChange={(event) => {
const text = event.target.value
const terms = text.split('\n') // Don't filter here to allow empty lines while typing
setSearchTermsDraft(terms)
}}
onBlur={() => {
// Clean up on blur
if (searchTermsDraft) {
setSearchTermsDraft(searchTermsDraft.map(t => t.trim()).filter(Boolean))
}
}}
placeholder="e.g. web developer"
disabled={isLoading || isSaving}
rows={5}
<Controller
name="searchTerms"
control={control}
render={({ field }) => (
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={field.value ? field.value.join('\n') : (defaultSearchTerms ?? []).join('\n')}
onChange={(event) => {
const text = event.target.value
const terms = text.split('\n')
field.onChange(terms)
}}
onBlur={() => {
if (field.value) {
field.onChange(field.value.map(t => t.trim()).filter(Boolean))
}
}}
placeholder="e.g. web developer"
disabled={isLoading || isSaving}
rows={5}
/>
)}
/>
{errors.searchTerms && <p className="text-xs text-destructive">{errors.searchTerms.message}</p>}
<div className="text-xs text-muted-foreground">
One term per line. Applies to UKVisaJobs and other supported extractors.
</div>
@@ -0,0 +1,45 @@
import React from "react"
import { Input } from "@/components/ui/input"
type SettingsInputProps = {
label: string
inputProps: React.InputHTMLAttributes<HTMLInputElement>
placeholder?: string
type?: React.HTMLInputTypeAttribute
disabled?: boolean
error?: string
helper?: string
current?: string | null
}
export const SettingsInput: React.FC<SettingsInputProps> = ({
label,
inputProps,
placeholder,
type = "text",
disabled,
error,
helper,
current,
}) => {
const id = inputProps.id || inputProps.name
return (
<div className="space-y-2">
{label && (
<label htmlFor={id} className="text-sm font-medium">
{label}
</label>
)}
<Input {...inputProps} id={id} type={type} placeholder={placeholder} disabled={disabled} />
{error && <p className="text-xs text-destructive">{error}</p>}
{helper && <div className="text-xs text-muted-foreground">{helper}</div>}
{current !== undefined && (
<div className="text-xs text-muted-foreground">
Current: <span className="font-mono">{current}</span>
</div>
)}
</div>
)
}
@@ -1,26 +1,25 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { NumericSettingValues } from "@client/pages/settings/types"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
type UkvisajobsSectionProps = {
ukvisajobsMaxJobsDraft: number | null
setUkvisajobsMaxJobsDraft: (value: number | null) => void
defaultUkvisajobsMaxJobs: number
effectiveUkvisajobsMaxJobs: number
values: NumericSettingValues
isLoading: boolean
isSaving: boolean
}
export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
ukvisajobsMaxJobsDraft,
setUkvisajobsMaxJobsDraft,
defaultUkvisajobsMaxJobs,
effectiveUkvisajobsMaxJobs,
values,
isLoading,
isSaving,
}) => {
const { effective: effectiveUkvisajobsMaxJobs, default: defaultUkvisajobsMaxJobs } = values
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="ukvisajobs" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@@ -28,41 +27,35 @@ export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max jobs to fetch</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={ukvisajobsMaxJobsDraft ?? defaultUkvisajobsMaxJobs}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setUkvisajobsMaxJobsDraft(null)
} else {
setUkvisajobsMaxJobsDraft(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Range: 1-1000.
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectiveUkvisajobsMaxJobs}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default</div>
<div className="break-words font-mono text-xs font-semibold">{defaultUkvisajobsMaxJobs}</div>
</div>
</div>
<Controller
name="ukvisajobsMaxJobs"
control={control}
render={({ field }) => (
<SettingsInput
label="Max jobs to fetch"
type="number"
inputProps={{
...field,
inputMode: "numeric",
min: 1,
max: 1000,
value: field.value ?? defaultUkvisajobsMaxJobs,
onChange: (event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
field.onChange(null)
} else {
field.onChange(Math.min(1000, Math.max(1, value)))
}
},
}}
disabled={isLoading || isSaving}
error={errors.ukvisajobsMaxJobs?.message as string | undefined}
helper={`Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Default: ${defaultUkvisajobsMaxJobs}. Range: 1-1000.`}
current={String(effectiveUkvisajobsMaxJobs)}
/>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
@@ -0,0 +1,50 @@
import { render, screen } from "@testing-library/react"
import { useForm, FormProvider } from "react-hook-form"
import { Accordion } from "@/components/ui/accordion"
import { WebhooksSection } from "./WebhooksSection"
import { UpdateSettingsInput } from "@shared/settings-schema"
const WebhooksHarness = () => {
const methods = useForm<UpdateSettingsInput>({
defaultValues: {
pipelineWebhookUrl: "https://pipeline.com",
jobCompleteWebhookUrl: "https://job.com",
webhookSecret: "",
}
})
return (
<FormProvider {...methods}>
<Accordion type="multiple" defaultValue={["webhooks"]}>
<WebhooksSection
pipelineWebhook={{
default: "https://default-p.com",
effective: "https://pipeline.com",
}}
jobCompleteWebhook={{
default: "https://default-j.com",
effective: "https://job.com",
}}
webhookSecretHint="sec-"
isLoading={false}
isSaving={false}
/>
</Accordion>
</FormProvider>
)
}
describe("WebhooksSection", () => {
it("renders both webhook sections and the secret", () => {
render(<WebhooksHarness />)
expect(screen.getByText("Pipeline Status")).toBeInTheDocument()
expect(screen.getByText("Job Completion")).toBeInTheDocument()
expect(screen.getByDisplayValue("https://pipeline.com")).toBeInTheDocument()
expect(screen.getByDisplayValue("https://job.com")).toBeInTheDocument()
expect(screen.getByText("sec-********")).toBeInTheDocument()
})
})
@@ -0,0 +1,79 @@
import React from "react"
import { useFormContext } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { WebhookValues } from "@client/pages/settings/types"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
import { formatSecretHint } from "@client/pages/settings/utils"
type WebhooksSectionProps = {
pipelineWebhook: WebhookValues
jobCompleteWebhook: WebhookValues
webhookSecretHint: string | null
isLoading: boolean
isSaving: boolean
}
export const WebhooksSection: React.FC<WebhooksSectionProps> = ({
pipelineWebhook,
jobCompleteWebhook,
webhookSecretHint,
isLoading,
isSaving,
}) => {
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="webhooks" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Webhooks</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-6">
<div className="space-y-4">
<div className="text-sm font-medium">Pipeline Status</div>
<SettingsInput
label="Webhook URL"
inputProps={register("pipelineWebhookUrl")}
placeholder={pipelineWebhook.default || "https://..."}
disabled={isLoading || isSaving}
error={errors.pipelineWebhookUrl?.message as string | undefined}
helper={`When set, the server sends a POST on pipeline completion/failure. Default: ${pipelineWebhook.default || "—"}.`}
current={pipelineWebhook.effective || "—"}
/>
</div>
<Separator />
<div className="space-y-4">
<div className="text-sm font-medium">Job Completion</div>
<div className="space-y-4">
<SettingsInput
label="Webhook URL"
inputProps={register("jobCompleteWebhookUrl")}
placeholder={jobCompleteWebhook.default || "https://..."}
disabled={isLoading || isSaving}
error={errors.jobCompleteWebhookUrl?.message as string | undefined}
helper={`When set, the server sends a POST when you mark a job as applied (includes the job description). Default: ${jobCompleteWebhook.default || "—"}.`}
current={jobCompleteWebhook.effective || "—"}
/>
<SettingsInput
label="Webhook Secret"
inputProps={register("webhookSecret")}
type="password"
placeholder="Enter new secret"
disabled={isLoading || isSaving}
error={errors.webhookSecret?.message as string | undefined}
helper="Secret sent to webhook (Bearer token)"
current={formatSecretHint(webhookSecretHint)}
/>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}
@@ -0,0 +1,40 @@
export type EffectiveDefault<T> = {
effective: T
default: T
}
export type ModelValues = EffectiveDefault<string> & {
scorer: string
tailoring: string
projectSelection: string
}
export type WebhookValues = EffectiveDefault<string>
export type NumericSettingValues = EffectiveDefault<number>
export type SearchTermsValues = EffectiveDefault<string[]>
export type DisplayValues = EffectiveDefault<boolean>
export type JobspyValues = {
sites: EffectiveDefault<string[]>
location: EffectiveDefault<string>
resultsWanted: EffectiveDefault<number>
hoursOld: EffectiveDefault<number>
countryIndeed: EffectiveDefault<string>
linkedinFetchDescription: EffectiveDefault<boolean>
}
export type EnvSettingsValues = {
readable: {
rxresumeEmail: string
ukvisajobsEmail: string
basicAuthUser: string
}
private: {
openrouterApiKeyHint: string | null
rxresumePasswordHint: string | null
ukvisajobsPasswordHint: string | null
basicAuthPasswordHint: string | null
webhookSecretHint: string | null
}
basicAuthActive: boolean
}
@@ -12,3 +12,5 @@ export function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjects
arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds)
)
}
export const formatSecretHint = (hint: string | null) => (hint ? `${hint}********` : "Not set")