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
co-authored by Copilot Autofix powered by AI
parent f92b80dfe2
commit f5aef7af24
17 changed files with 269 additions and 9 deletions
@@ -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();
});
});
@@ -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),
@@ -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={{
@@ -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;
@@ -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(
@@ -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);
}
@@ -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) =>
@@ -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");
});
@@ -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",
@@ -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) };