Add RxResume URL setting to dashboard (#258)

* Add RxResume URL setting to dashboard

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Shaheer Sarfaraz 2026-03-14 20:39:15 +00:00 committed by GitHub
parent f92b80dfe2
commit f5aef7af24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 269 additions and 9 deletions

View File

@ -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**:

View File

@ -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

View File

@ -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 }) => <div>{label}</div>,
SettingsInput: ({
label,
inputProps,
}: {
label: string;
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
}) => (
<label>
<span>{label}</span>
<input {...inputProps} />
</label>
),
}));
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(<OnboardingGate />);
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();
});
});

View File

@ -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),

View File

@ -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" ? (
<div className="grid gap-4">
<SettingsInput
label="RxResume URL"
inputProps={{
name: "rxresumeUrl",
value: shared.baseUrl,
onChange: (event) =>
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}
/>
<SettingsInput
label="v5 API key"
inputProps={{
@ -167,6 +194,27 @@ export const ReactiveResumeConfigPanel: React.FC<
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
<div className="md:col-span-2">
<SettingsInput
label="RxResume URL"
inputProps={{
name: "rxresumeUrl",
value: shared.baseUrl,
onChange: (event) =>
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}
/>
</div>
<SettingsInput
label="v4 Email"
inputProps={{

View File

@ -5,6 +5,7 @@ export type RxResumeSettingsLike =
| {
rxresumeMode?: { value?: string | null } | null;
rxresumeEmail?: string | null;
rxresumeUrl?: string | null;
rxresumePasswordHint?: string | null;
rxresumeApiKeyHint?: string | null;
rxresumeBaseResumeId?: string | null;
@ -61,10 +62,12 @@ export const getRxResumeBaseResumeSelection = (
export const getRxResumeCredentialDrafts = (input: {
rxresumeEmail?: string | null;
rxresumeUrl?: string | null;
rxresumePassword?: string | null;
rxresumeApiKey?: string | null;
}) => ({
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<UpdateSettingsInput> => {
const update: Partial<UpdateSettingsInput> = {
rxresumeMode: mode,
rxresumeUrl: draft.baseUrl || null,
};
if (draft.email) update.rxresumeEmail = draft.email;
if (draft.password) update.rxresumePassword = draft.password;

View File

@ -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(

View File

@ -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);
}

View File

@ -57,6 +57,7 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
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<ReactiveResumeSectionProps> = ({
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) =>

View File

@ -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");
});

View File

@ -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",

View File

@ -172,36 +172,42 @@ async function readConfiguredMode(): Promise<RxResumeMode> {
}
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) };

View File

@ -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", () => {

View File

@ -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",

View File

@ -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();
});
});

View File

@ -201,6 +201,7 @@ export const createAppSettings = (
llmApiKeyHint: null,
rxresumeApiKeyHint: null,
rxresumeEmail: null,
rxresumeUrl: null,
rxresumePasswordHint: null,
basicAuthUser: null,
basicAuthPasswordHint: null,

View File

@ -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;