diff --git a/docs-site/docs/features/reactive-resume.md b/docs-site/docs/features/reactive-resume.md index 73adaa5..a7dd8b8 100644 --- a/docs-site/docs/features/reactive-resume.md +++ b/docs-site/docs/features/reactive-resume.md @@ -94,6 +94,7 @@ Important: Configure in **Settings → Reactive Resume**: - `rxresumeMode` (`v5` or `v4`) +- `rxresumeUrl` (optional shared URL for cloud or self-hosted deployments) - `rxresumeApiKey` (for v5) - `rxresumeEmail` + `rxresumePassword` (for v4) @@ -105,6 +106,8 @@ Or via environment variables: - `RXRESUME_PASSWORD` - optional `RXRESUME_URL` (works for both modes; v5 OpenAPI path is added automatically) +If you leave the URL blank in the dashboard, JobOps uses `RXRESUME_URL` when it is set; if not set, it falls back to the public cloud default for the selected mode. + ### 2) Select base resume In **Settings → Reactive Resume**: diff --git a/docs-site/docs/features/settings.md b/docs-site/docs/features/settings.md index 36eebe1..f6688b5 100644 --- a/docs-site/docs/features/settings.md +++ b/docs-site/docs/features/settings.md @@ -109,6 +109,8 @@ Defaults and constraints: ![Reactive Resume settings section](/img/features/settings-reactive-resume-section.png) +- Configure a shared RxResume URL for cloud or self-hosted deployments +- Configure v4 email/password or v5 API key in the same section - Select a template/base resume - Configure project selection behavior: - Max projects @@ -210,7 +212,9 @@ curl -X POST "http://localhost:3001/api/backups" ### RxResume controls are disabled -- Configure RxResume credentials in Environment & Accounts first. +- JobOps resolves the RxResume URL in this order: the value saved in **Settings → Reactive Resume**, then the `RXRESUME_URL` environment variable (if set), and finally the public cloud default. +- Open **Settings → Reactive Resume** and configure the shared RxResume URL if you use a self-hosted instance. +- If you leave the URL blank, JobOps will fall back to `RXRESUME_URL` when it is configured; otherwise it uses the public cloud default. - Then refresh available resumes from the Reactive Resume section. ### RxResume projects look empty in the RxResume UI diff --git a/orchestrator/src/client/components/OnboardingGate.test.tsx b/orchestrator/src/client/components/OnboardingGate.test.tsx index 84affaf..c5285f5 100644 --- a/orchestrator/src/client/components/OnboardingGate.test.tsx +++ b/orchestrator/src/client/components/OnboardingGate.test.tsx @@ -1,6 +1,6 @@ import * as api from "@client/api"; import { useSettings } from "@client/hooks/useSettings"; -import { screen, waitFor } from "@testing-library/react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; import type React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithQueryClient } from "../test/renderWithQueryClient"; @@ -22,7 +22,18 @@ vi.mock("@client/hooks/useSettings", () => ({ })); vi.mock("@client/pages/settings/components/SettingsInput", () => ({ - SettingsInput: ({ label }: { label: string }) =>
{label}
, + SettingsInput: ({ + label, + inputProps, + }: { + label: string; + inputProps?: React.InputHTMLAttributes; + }) => ( + + ), })); vi.mock("@client/pages/settings/components/BaseResumeSelection", () => ({ @@ -95,6 +106,7 @@ const settingsResponse = { llmProvider: { value: "openrouter", default: "openrouter", override: null }, llmApiKeyHint: null, rxresumeEmail: "", + rxresumeUrl: "", rxresumeApiKeyHint: null, rxresumePasswordHint: null, rxresumeBaseResumeId: null, @@ -193,4 +205,48 @@ describe("OnboardingGate", () => { }); expect(screen.queryByText("LLM API key")).not.toBeInTheDocument(); }); + + it("renders the RxResume URL field and includes it in validation", async () => { + vi.mocked(useSettings).mockReturnValue({ + ...settingsResponse, + settings: { + ...settingsResponse.settings, + rxresumeUrl: "https://resume.example.com", + rxresumeApiKeyHint: "abcd1234", + }, + } as any); + vi.mocked(api.validateLlm).mockResolvedValue({ + valid: false, + message: "Invalid", + }); + vi.mocked(api.validateRxresume).mockResolvedValue({ + valid: true, + message: null, + }); + vi.mocked(api.validateResumeConfig).mockResolvedValue({ + valid: true, + message: null, + }); + + render(); + + await waitFor(() => + expect(screen.getByLabelText("RxResume URL")).toBeInTheDocument(), + ); + await waitFor(() => + expect(api.validateRxresume).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: "https://resume.example.com", + }), + ), + ); + + fireEvent.change(screen.getByLabelText("RxResume URL"), { + target: { value: "https://self-hosted.example.com" }, + }); + + expect( + screen.getByDisplayValue("https://self-hosted.example.com"), + ).toBeInTheDocument(); + }); }); diff --git a/orchestrator/src/client/components/OnboardingGate.tsx b/orchestrator/src/client/components/OnboardingGate.tsx index 0aae3b6..b3dfc66 100644 --- a/orchestrator/src/client/components/OnboardingGate.tsx +++ b/orchestrator/src/client/components/OnboardingGate.tsx @@ -59,6 +59,7 @@ type OnboardingFormData = { llmApiKey: string; rxresumeMode: RxResumeMode; rxresumeEmail: string; + rxresumeUrl: string; rxresumePassword: string; rxresumeApiKey: string; rxresumeBaseResumeId: string | null; @@ -135,6 +136,7 @@ export const OnboardingGate: React.FC = () => { llmApiKey: "", rxresumeMode: "v5", rxresumeEmail: "", + rxresumeUrl: "", rxresumePassword: "", rxresumeApiKey: "", rxresumeBaseResumeId: null, @@ -287,6 +289,7 @@ export const OnboardingGate: React.FC = () => { llmApiKey: "", rxresumeMode: initialMode, rxresumeEmail: "", + rxresumeUrl: settings.rxresumeUrl ?? "", rxresumePassword: "", rxresumeApiKey: "", rxresumeBaseResumeId: selectedId, @@ -769,6 +772,10 @@ export const OnboardingGate: React.FC = () => { apiKey: watch("rxresumeApiKey"), onApiKeyChange: (value) => setValue("rxresumeApiKey", value), }} + shared={{ + baseUrl: watch("rxresumeUrl"), + onBaseUrlChange: (value) => setValue("rxresumeUrl", value), + }} v4={{ email: watch("rxresumeEmail"), onEmailChange: (value) => setValue("rxresumeEmail", value), diff --git a/orchestrator/src/client/components/ReactiveResumeConfigPanel.tsx b/orchestrator/src/client/components/ReactiveResumeConfigPanel.tsx index d5e2a5d..1a8c2ba 100644 --- a/orchestrator/src/client/components/ReactiveResumeConfigPanel.tsx +++ b/orchestrator/src/client/components/ReactiveResumeConfigPanel.tsx @@ -62,6 +62,13 @@ type ReactiveResumeConfigPanelProps = { helper?: string; placeholder?: string; }; + shared: { + baseUrl: string; + onBaseUrlChange: (value: string) => void; + baseUrlError?: string; + baseUrlHelper?: string; + baseUrlPlaceholder?: string; + }; v4: { email: string; onEmailChange: (value: string) => void; @@ -110,6 +117,7 @@ export const ReactiveResumeConfigPanel: React.FC< showValidationStatus = false, validationStatuses, intro, + shared, v5, v4, projectSelection, @@ -151,6 +159,25 @@ export const ReactiveResumeConfigPanel: React.FC< {mode === "v5" ? (
+ + shared.onBaseUrlChange(event.currentTarget.value), + }} + type="url" + placeholder={ + shared.baseUrlPlaceholder ?? "https://resume.example.com" + } + helper={ + shared.baseUrlHelper ?? + "Leave blank to use the default for the selected mode (or the RXRESUME_URL environment override, if set)." + } + disabled={disabled} + error={shared.baseUrlError} + /> ) : (
+
+ + shared.onBaseUrlChange(event.currentTarget.value), + }} + type="url" + placeholder={ + shared.baseUrlPlaceholder ?? "https://resume.example.com" + } + helper={ + shared.baseUrlHelper ?? + "Leave blank to use the public cloud default for the selected mode." + } + disabled={disabled} + error={shared.baseUrlError} + /> +
({ email: input.rxresumeEmail?.trim() ?? "", + baseUrl: input.rxresumeUrl?.trim() ?? "", password: input.rxresumePassword?.trim() ?? "", apiKey: input.rxresumeApiKey?.trim() ?? "", }); @@ -116,6 +119,7 @@ export const toRxResumeValidationPayload = ( draft: RxResumeCredentialDrafts, ) => ({ email: draft.email || undefined, + baseUrl: draft.baseUrl || undefined, password: draft.password || undefined, apiKey: draft.apiKey || undefined, }); @@ -126,6 +130,7 @@ export const buildRxResumeSettingsUpdate = ( ): Partial => { const update: Partial = { rxresumeMode: mode, + rxresumeUrl: draft.baseUrl || null, }; if (draft.email) update.rxresumeEmail = draft.email; if (draft.password) update.rxresumePassword = draft.password; diff --git a/orchestrator/src/client/pages/SettingsPage.test.tsx b/orchestrator/src/client/pages/SettingsPage.test.tsx index 968ca90..8d64155 100644 --- a/orchestrator/src/client/pages/SettingsPage.test.tsx +++ b/orchestrator/src/client/pages/SettingsPage.test.tsx @@ -276,6 +276,38 @@ describe("SettingsPage", () => { ); }); + it("saves a shared RxResume URL from the Reactive Resume section", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings); + vi.mocked(api.updateSettings).mockResolvedValue({ + ...baseSettings, + rxresumeUrl: "https://resume.example.com", + }); + + renderPage(); + + const reactiveResumeTrigger = await screen.findByRole("button", { + name: /reactive resume/i, + }); + fireEvent.click(reactiveResumeTrigger); + + const urlInput = screen.getByLabelText(/rxresume url/i); + await waitFor(() => expect(urlInput).toBeEnabled()); + fireEvent.change(urlInput, { + target: { value: "https://resume.example.com" }, + }); + + const saveButton = screen.getByRole("button", { name: /^save$/i }); + await waitFor(() => expect(saveButton).toBeEnabled()); + fireEvent.click(saveButton); + + await waitFor(() => expect(api.updateSettings).toHaveBeenCalled()); + expect(api.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + rxresumeUrl: "https://resume.example.com", + }), + ); + }); + it("saves the writing language mode through the settings page", async () => { vi.mocked(api.getSettings).mockResolvedValue(baseSettings); vi.mocked(api.updateSettings).mockResolvedValue( diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 3a68a7c..8aa222e 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -75,6 +75,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = { chatStyleLanguageMode: null, chatStyleManualLanguage: null, rxresumeEmail: "", + rxresumeUrl: "", rxresumePassword: "", rxresumeApiKey: "", basicAuthUser: "", @@ -132,6 +133,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { chatStyleLanguageMode: null, chatStyleManualLanguage: null, rxresumeEmail: null, + rxresumeUrl: null, rxresumePassword: null, rxresumeApiKey: null, basicAuthUser: null, @@ -174,6 +176,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ chatStyleLanguageMode: data.chatStyleLanguageMode.override ?? null, chatStyleManualLanguage: data.chatStyleManualLanguage.override ?? null, rxresumeEmail: data.rxresumeEmail ?? "", + rxresumeUrl: data.rxresumeUrl ?? "", rxresumePassword: "", rxresumeApiKey: "", basicAuthUser: data.basicAuthUser ?? "", @@ -715,6 +718,10 @@ export const SettingsPage: React.FC = () => { envPayload.rxresumeEmail = normalizeString(data.rxresumeEmail); } + if (dirtyFields.rxresumeUrl) { + envPayload.rxresumeUrl = normalizeString(data.rxresumeUrl); + } + if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) { envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail); } diff --git a/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx b/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx index 400e6c8..8e8f1be 100644 --- a/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx +++ b/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx @@ -57,6 +57,7 @@ export const ReactiveResumeSection: React.FC = ({ const rxresumeApiKeyValue = useWatch({ control, name: "rxresumeApiKey" }) ?? ""; const rxresumeEmailValue = useWatch({ control, name: "rxresumeEmail" }) ?? ""; + const rxresumeUrlValue = useWatch({ control, name: "rxresumeUrl" }) ?? ""; const rxresumePasswordValue = useWatch({ control, name: "rxresumePassword" }) ?? ""; const resumeProjectsValue = useWatch({ control, name: "resumeProjects" }); @@ -85,6 +86,12 @@ export const ReactiveResumeSection: React.FC = ({ hasRxResumeAccess={hasRxResumeAccess} showValidationStatus={Boolean(validationStatuses)} validationStatuses={validationStatuses} + shared={{ + baseUrl: rxresumeUrlValue, + onBaseUrlChange: (value) => + setDirtyTouchedValue("rxresumeUrl", value), + baseUrlError: errors.rxresumeUrl?.message as string | undefined, + }} v5={{ apiKey: rxresumeApiKeyValue, onApiKeyChange: (value) => diff --git a/orchestrator/src/server/api/routes/settings.test.ts b/orchestrator/src/server/api/routes/settings.test.ts index f1a0d88..8faa03a 100644 --- a/orchestrator/src/server/api/routes/settings.test.ts +++ b/orchestrator/src/server/api/routes/settings.test.ts @@ -69,6 +69,7 @@ describe.sequential("Settings API routes", () => { env: { LLM_API_KEY: "secret-key", RXRESUME_EMAIL: "resume@example.com", + RXRESUME_URL: "https://env.rxresume.example.com", }, })); }); @@ -84,6 +85,7 @@ describe.sequential("Settings API routes", () => { expect(body.data.model.default).toBe("test-model"); expect(Array.isArray(body.data.searchTerms.value)).toBe(true); expect(body.data.rxresumeEmail).toBe("resume@example.com"); + expect(body.data.rxresumeUrl).toBe("https://env.rxresume.example.com"); expect(body.data.llmApiKeyHint).toBe("secr"); expect(body.data.basicAuthActive).toBe(false); }); @@ -124,6 +126,7 @@ describe.sequential("Settings API routes", () => { body: JSON.stringify({ searchTerms: ["engineer"], rxresumeEmail: "updated@example.com", + rxresumeUrl: "https://resume.example.com", llmApiKey: "updated-secret", }), }); @@ -132,6 +135,7 @@ describe.sequential("Settings API routes", () => { expect(patchBody.data.searchTerms.value).toEqual(["engineer"]); expect(patchBody.data.searchTerms.override).toEqual(["engineer"]); expect(patchBody.data.rxresumeEmail).toBe("updated@example.com"); + expect(patchBody.data.rxresumeUrl).toBe("https://resume.example.com"); expect(patchBody.data.llmApiKeyHint).toBe("upda"); }); diff --git a/orchestrator/src/server/services/rxresume/index.test.ts b/orchestrator/src/server/services/rxresume/index.test.ts index 38b2e9b..1758a30 100644 --- a/orchestrator/src/server/services/rxresume/index.test.ts +++ b/orchestrator/src/server/services/rxresume/index.test.ts @@ -56,6 +56,7 @@ describe("rxresume adapter", () => { delete process.env.RXRESUME_API_KEY; delete process.env.RXRESUME_EMAIL; delete process.env.RXRESUME_PASSWORD; + delete process.env.RXRESUME_URL; delete process.env.RXRESUME_MODE; mockSettings({}); }); @@ -157,6 +158,44 @@ describe("rxresume adapter", () => { expect(result).toEqual({ ok: true, mode: "v4" }); }); + it("prefers stored rxresumeUrl over environment values", async () => { + process.env.RXRESUME_URL = "https://env.rxresume.example.com"; + mockSettings({ + rxresumeMode: "v4", + rxresumeEmail: "user@example.com", + rxresumePassword: "pw", + rxresumeUrl: "https://stored.rxresume.example.com", + }); + vi.mocked(RxResumeClient.verifyCredentials).mockResolvedValue({ ok: true }); + + await validateCredentials(); + + expect(RxResumeClient.verifyCredentials).toHaveBeenCalledWith( + "user@example.com", + "pw", + "https://stored.rxresume.example.com", + ); + }); + + it("falls back to the default v4 URL when no env or stored URL is configured", async () => { + mockSettings({ + rxresumeMode: "v4", + rxresumeEmail: "user@example.com", + rxresumePassword: "pw", + }); + vi.mocked(RxResumeClient.verifyCredentials).mockResolvedValue({ ok: true }); + + await validateCredentials({ + v4: { baseUrl: " " }, + }); + + expect(RxResumeClient.verifyCredentials).toHaveBeenCalledWith( + "user@example.com", + "pw", + "https://v4.rxresu.me", + ); + }); + it("does not fall back to v4 validation when explicit v5 validation fails", async () => { mockSettings({ rxresumeMode: "v5", diff --git a/orchestrator/src/server/services/rxresume/index.ts b/orchestrator/src/server/services/rxresume/index.ts index 8fb5e27..507299e 100644 --- a/orchestrator/src/server/services/rxresume/index.ts +++ b/orchestrator/src/server/services/rxresume/index.ts @@ -172,36 +172,42 @@ async function readConfiguredMode(): Promise { } async function readV4Credentials(overrides?: ResolveModeOptions["v4"]) { - const [storedEmail, storedPassword] = await Promise.all([ + const [storedEmail, storedPassword, storedBaseUrl] = await Promise.all([ getSetting("rxresumeEmail"), getSetting("rxresumePassword"), + getSetting("rxresumeUrl"), ]); const email = overrides?.email?.trim() || - process.env.RXRESUME_EMAIL?.trim() || storedEmail?.trim() || + process.env.RXRESUME_EMAIL?.trim() || ""; const password = overrides?.password?.trim() || - process.env.RXRESUME_PASSWORD?.trim() || storedPassword?.trim() || + process.env.RXRESUME_PASSWORD?.trim() || ""; const baseUrl = overrides?.baseUrl?.trim() || + storedBaseUrl?.trim() || process.env.RXRESUME_URL?.trim() || "https://v4.rxresu.me"; return { email, password, baseUrl, available: Boolean(email && password) }; } async function readV5Credentials(overrides?: ResolveModeOptions["v5"]) { - const [storedApiKey] = await Promise.all([getSetting("rxresumeApiKey")]); + const [storedApiKey, storedBaseUrl] = await Promise.all([ + getSetting("rxresumeApiKey"), + getSetting("rxresumeUrl"), + ]); const apiKey = overrides?.apiKey?.trim() || - process.env.RXRESUME_API_KEY?.trim() || storedApiKey?.trim() || + process.env.RXRESUME_API_KEY?.trim() || ""; const baseUrl = overrides?.baseUrl?.trim() || + storedBaseUrl?.trim() || process.env.RXRESUME_URL?.trim() || "https://rxresu.me"; return { apiKey, baseUrl, available: Boolean(apiKey) }; diff --git a/shared/src/settings-registry.test.ts b/shared/src/settings-registry.test.ts index c5ded82..6a2f74d 100644 --- a/shared/src/settings-registry.test.ts +++ b/shared/src/settings-registry.test.ts @@ -116,6 +116,10 @@ describe("settingsRegistry helpers", () => { it("has env-backed v5 api key secret setting", () => { expect(settingsRegistry.rxresumeApiKey.envKey).toBe("RXRESUME_API_KEY"); }); + + it("has env-backed rxresumeUrl string setting", () => { + expect(settingsRegistry.rxresumeUrl.envKey).toBe("RXRESUME_URL"); + }); }); describe("writing-style language settings", () => { diff --git a/shared/src/settings-registry.ts b/shared/src/settings-registry.ts index 97342bb..4bafe2d 100644 --- a/shared/src/settings-registry.ts +++ b/shared/src/settings-registry.ts @@ -462,6 +462,14 @@ export const settingsRegistry = { envKey: "RXRESUME_EMAIL", schema: z.string().trim().max(200), }, + rxresumeUrl: { + kind: "string" as const, + envKey: "RXRESUME_URL", + schema: z.preprocess( + (value) => (value === "" ? null : value), + z.string().trim().url().max(2000).nullable(), + ), + }, ukvisajobsEmail: { kind: "string" as const, envKey: "UKVISAJOBS_EMAIL", diff --git a/shared/src/settings-schema.test.ts b/shared/src/settings-schema.test.ts index d7d1a7a..f5677c3 100644 --- a/shared/src/settings-schema.test.ts +++ b/shared/src/settings-schema.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { updateSettingsSchema } from "./settings-schema"; -describe("updateSettingsSchema language settings", () => { +describe("updateSettingsSchema", () => { it("accepts supported language mode and manual language values", () => { expect( updateSettingsSchema.parse({ @@ -43,4 +43,32 @@ describe("updateSettingsSchema language settings", () => { result.error.flatten().fieldErrors.chatStyleManualLanguage, ).toBeDefined(); }); + + it("accepts a nullable rxresumeUrl and rejects invalid URLs", () => { + expect( + updateSettingsSchema.parse({ + rxresumeUrl: "https://resume.example.com", + }), + ).toEqual({ + rxresumeUrl: "https://resume.example.com", + }); + + expect( + updateSettingsSchema.parse({ + rxresumeUrl: null, + }), + ).toEqual({ + rxresumeUrl: null, + }); + + const result = updateSettingsSchema.safeParse({ + rxresumeUrl: "not-a-url", + }); + + expect(result.success).toBe(false); + if (result.success) { + return; + } + expect(result.error.flatten().fieldErrors.rxresumeUrl).toBeDefined(); + }); }); diff --git a/shared/src/testing/factories.ts b/shared/src/testing/factories.ts index 459b847..900facd 100644 --- a/shared/src/testing/factories.ts +++ b/shared/src/testing/factories.ts @@ -201,6 +201,7 @@ export const createAppSettings = ( llmApiKeyHint: null, rxresumeApiKeyHint: null, rxresumeEmail: null, + rxresumeUrl: null, rxresumePasswordHint: null, basicAuthUser: null, basicAuthPasswordHint: null, diff --git a/shared/src/types/settings.ts b/shared/src/types/settings.ts index 669baec..a884a14 100644 --- a/shared/src/types/settings.ts +++ b/shared/src/types/settings.ts @@ -183,6 +183,7 @@ export interface AppSettings { rxresumeBaseResumeIdV4: string | null; rxresumeBaseResumeIdV5: string | null; rxresumeEmail: string | null; + rxresumeUrl: string | null; ukvisajobsEmail: string | null; adzunaAppId: string | null; basicAuthUser: string | null;