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:
co-authored by
Copilot Autofix powered by AI
parent
f92b80dfe2
commit
f5aef7af24
@@ -94,6 +94,7 @@ Important:
|
|||||||
Configure in **Settings → Reactive Resume**:
|
Configure in **Settings → Reactive Resume**:
|
||||||
|
|
||||||
- `rxresumeMode` (`v5` or `v4`)
|
- `rxresumeMode` (`v5` or `v4`)
|
||||||
|
- `rxresumeUrl` (optional shared URL for cloud or self-hosted deployments)
|
||||||
- `rxresumeApiKey` (for v5)
|
- `rxresumeApiKey` (for v5)
|
||||||
- `rxresumeEmail` + `rxresumePassword` (for v4)
|
- `rxresumeEmail` + `rxresumePassword` (for v4)
|
||||||
|
|
||||||
@@ -105,6 +106,8 @@ Or via environment variables:
|
|||||||
- `RXRESUME_PASSWORD`
|
- `RXRESUME_PASSWORD`
|
||||||
- optional `RXRESUME_URL` (works for both modes; v5 OpenAPI path is added automatically)
|
- 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
|
### 2) Select base resume
|
||||||
|
|
||||||
In **Settings → Reactive Resume**:
|
In **Settings → Reactive Resume**:
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ Defaults and constraints:
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
- 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
|
- Select a template/base resume
|
||||||
- Configure project selection behavior:
|
- Configure project selection behavior:
|
||||||
- Max projects
|
- Max projects
|
||||||
@@ -210,7 +212,9 @@ curl -X POST "http://localhost:3001/api/backups"
|
|||||||
|
|
||||||
### RxResume controls are disabled
|
### 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.
|
- Then refresh available resumes from the Reactive Resume section.
|
||||||
|
|
||||||
### RxResume projects look empty in the RxResume UI
|
### RxResume projects look empty in the RxResume UI
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as api from "@client/api";
|
import * as api from "@client/api";
|
||||||
import { useSettings } from "@client/hooks/useSettings";
|
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 type React from "react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||||
@@ -22,7 +22,18 @@ vi.mock("@client/hooks/useSettings", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@client/pages/settings/components/SettingsInput", () => ({
|
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", () => ({
|
vi.mock("@client/pages/settings/components/BaseResumeSelection", () => ({
|
||||||
@@ -95,6 +106,7 @@ const settingsResponse = {
|
|||||||
llmProvider: { value: "openrouter", default: "openrouter", override: null },
|
llmProvider: { value: "openrouter", default: "openrouter", override: null },
|
||||||
llmApiKeyHint: null,
|
llmApiKeyHint: null,
|
||||||
rxresumeEmail: "",
|
rxresumeEmail: "",
|
||||||
|
rxresumeUrl: "",
|
||||||
rxresumeApiKeyHint: null,
|
rxresumeApiKeyHint: null,
|
||||||
rxresumePasswordHint: null,
|
rxresumePasswordHint: null,
|
||||||
rxresumeBaseResumeId: null,
|
rxresumeBaseResumeId: null,
|
||||||
@@ -193,4 +205,48 @@ describe("OnboardingGate", () => {
|
|||||||
});
|
});
|
||||||
expect(screen.queryByText("LLM API key")).not.toBeInTheDocument();
|
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;
|
llmApiKey: string;
|
||||||
rxresumeMode: RxResumeMode;
|
rxresumeMode: RxResumeMode;
|
||||||
rxresumeEmail: string;
|
rxresumeEmail: string;
|
||||||
|
rxresumeUrl: string;
|
||||||
rxresumePassword: string;
|
rxresumePassword: string;
|
||||||
rxresumeApiKey: string;
|
rxresumeApiKey: string;
|
||||||
rxresumeBaseResumeId: string | null;
|
rxresumeBaseResumeId: string | null;
|
||||||
@@ -135,6 +136,7 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
llmApiKey: "",
|
llmApiKey: "",
|
||||||
rxresumeMode: "v5",
|
rxresumeMode: "v5",
|
||||||
rxresumeEmail: "",
|
rxresumeEmail: "",
|
||||||
|
rxresumeUrl: "",
|
||||||
rxresumePassword: "",
|
rxresumePassword: "",
|
||||||
rxresumeApiKey: "",
|
rxresumeApiKey: "",
|
||||||
rxresumeBaseResumeId: null,
|
rxresumeBaseResumeId: null,
|
||||||
@@ -287,6 +289,7 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
llmApiKey: "",
|
llmApiKey: "",
|
||||||
rxresumeMode: initialMode,
|
rxresumeMode: initialMode,
|
||||||
rxresumeEmail: "",
|
rxresumeEmail: "",
|
||||||
|
rxresumeUrl: settings.rxresumeUrl ?? "",
|
||||||
rxresumePassword: "",
|
rxresumePassword: "",
|
||||||
rxresumeApiKey: "",
|
rxresumeApiKey: "",
|
||||||
rxresumeBaseResumeId: selectedId,
|
rxresumeBaseResumeId: selectedId,
|
||||||
@@ -769,6 +772,10 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
apiKey: watch("rxresumeApiKey"),
|
apiKey: watch("rxresumeApiKey"),
|
||||||
onApiKeyChange: (value) => setValue("rxresumeApiKey", value),
|
onApiKeyChange: (value) => setValue("rxresumeApiKey", value),
|
||||||
}}
|
}}
|
||||||
|
shared={{
|
||||||
|
baseUrl: watch("rxresumeUrl"),
|
||||||
|
onBaseUrlChange: (value) => setValue("rxresumeUrl", value),
|
||||||
|
}}
|
||||||
v4={{
|
v4={{
|
||||||
email: watch("rxresumeEmail"),
|
email: watch("rxresumeEmail"),
|
||||||
onEmailChange: (value) => setValue("rxresumeEmail", value),
|
onEmailChange: (value) => setValue("rxresumeEmail", value),
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ type ReactiveResumeConfigPanelProps = {
|
|||||||
helper?: string;
|
helper?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
};
|
};
|
||||||
|
shared: {
|
||||||
|
baseUrl: string;
|
||||||
|
onBaseUrlChange: (value: string) => void;
|
||||||
|
baseUrlError?: string;
|
||||||
|
baseUrlHelper?: string;
|
||||||
|
baseUrlPlaceholder?: string;
|
||||||
|
};
|
||||||
v4: {
|
v4: {
|
||||||
email: string;
|
email: string;
|
||||||
onEmailChange: (value: string) => void;
|
onEmailChange: (value: string) => void;
|
||||||
@@ -110,6 +117,7 @@ export const ReactiveResumeConfigPanel: React.FC<
|
|||||||
showValidationStatus = false,
|
showValidationStatus = false,
|
||||||
validationStatuses,
|
validationStatuses,
|
||||||
intro,
|
intro,
|
||||||
|
shared,
|
||||||
v5,
|
v5,
|
||||||
v4,
|
v4,
|
||||||
projectSelection,
|
projectSelection,
|
||||||
@@ -151,6 +159,25 @@ export const ReactiveResumeConfigPanel: React.FC<
|
|||||||
|
|
||||||
{mode === "v5" ? (
|
{mode === "v5" ? (
|
||||||
<div className="grid gap-4">
|
<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
|
<SettingsInput
|
||||||
label="v5 API key"
|
label="v5 API key"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
@@ -167,6 +194,27 @@ export const ReactiveResumeConfigPanel: React.FC<
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<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
|
<SettingsInput
|
||||||
label="v4 Email"
|
label="v4 Email"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type RxResumeSettingsLike =
|
|||||||
| {
|
| {
|
||||||
rxresumeMode?: { value?: string | null } | null;
|
rxresumeMode?: { value?: string | null } | null;
|
||||||
rxresumeEmail?: string | null;
|
rxresumeEmail?: string | null;
|
||||||
|
rxresumeUrl?: string | null;
|
||||||
rxresumePasswordHint?: string | null;
|
rxresumePasswordHint?: string | null;
|
||||||
rxresumeApiKeyHint?: string | null;
|
rxresumeApiKeyHint?: string | null;
|
||||||
rxresumeBaseResumeId?: string | null;
|
rxresumeBaseResumeId?: string | null;
|
||||||
@@ -61,10 +62,12 @@ export const getRxResumeBaseResumeSelection = (
|
|||||||
|
|
||||||
export const getRxResumeCredentialDrafts = (input: {
|
export const getRxResumeCredentialDrafts = (input: {
|
||||||
rxresumeEmail?: string | null;
|
rxresumeEmail?: string | null;
|
||||||
|
rxresumeUrl?: string | null;
|
||||||
rxresumePassword?: string | null;
|
rxresumePassword?: string | null;
|
||||||
rxresumeApiKey?: string | null;
|
rxresumeApiKey?: string | null;
|
||||||
}) => ({
|
}) => ({
|
||||||
email: input.rxresumeEmail?.trim() ?? "",
|
email: input.rxresumeEmail?.trim() ?? "",
|
||||||
|
baseUrl: input.rxresumeUrl?.trim() ?? "",
|
||||||
password: input.rxresumePassword?.trim() ?? "",
|
password: input.rxresumePassword?.trim() ?? "",
|
||||||
apiKey: input.rxresumeApiKey?.trim() ?? "",
|
apiKey: input.rxresumeApiKey?.trim() ?? "",
|
||||||
});
|
});
|
||||||
@@ -116,6 +119,7 @@ export const toRxResumeValidationPayload = (
|
|||||||
draft: RxResumeCredentialDrafts,
|
draft: RxResumeCredentialDrafts,
|
||||||
) => ({
|
) => ({
|
||||||
email: draft.email || undefined,
|
email: draft.email || undefined,
|
||||||
|
baseUrl: draft.baseUrl || undefined,
|
||||||
password: draft.password || undefined,
|
password: draft.password || undefined,
|
||||||
apiKey: draft.apiKey || undefined,
|
apiKey: draft.apiKey || undefined,
|
||||||
});
|
});
|
||||||
@@ -126,6 +130,7 @@ export const buildRxResumeSettingsUpdate = (
|
|||||||
): Partial<UpdateSettingsInput> => {
|
): Partial<UpdateSettingsInput> => {
|
||||||
const update: Partial<UpdateSettingsInput> = {
|
const update: Partial<UpdateSettingsInput> = {
|
||||||
rxresumeMode: mode,
|
rxresumeMode: mode,
|
||||||
|
rxresumeUrl: draft.baseUrl || null,
|
||||||
};
|
};
|
||||||
if (draft.email) update.rxresumeEmail = draft.email;
|
if (draft.email) update.rxresumeEmail = draft.email;
|
||||||
if (draft.password) update.rxresumePassword = draft.password;
|
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 () => {
|
it("saves the writing language mode through the settings page", async () => {
|
||||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||||
vi.mocked(api.updateSettings).mockResolvedValue(
|
vi.mocked(api.updateSettings).mockResolvedValue(
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
|||||||
chatStyleLanguageMode: null,
|
chatStyleLanguageMode: null,
|
||||||
chatStyleManualLanguage: null,
|
chatStyleManualLanguage: null,
|
||||||
rxresumeEmail: "",
|
rxresumeEmail: "",
|
||||||
|
rxresumeUrl: "",
|
||||||
rxresumePassword: "",
|
rxresumePassword: "",
|
||||||
rxresumeApiKey: "",
|
rxresumeApiKey: "",
|
||||||
basicAuthUser: "",
|
basicAuthUser: "",
|
||||||
@@ -132,6 +133,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
|||||||
chatStyleLanguageMode: null,
|
chatStyleLanguageMode: null,
|
||||||
chatStyleManualLanguage: null,
|
chatStyleManualLanguage: null,
|
||||||
rxresumeEmail: null,
|
rxresumeEmail: null,
|
||||||
|
rxresumeUrl: null,
|
||||||
rxresumePassword: null,
|
rxresumePassword: null,
|
||||||
rxresumeApiKey: null,
|
rxresumeApiKey: null,
|
||||||
basicAuthUser: null,
|
basicAuthUser: null,
|
||||||
@@ -174,6 +176,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
|||||||
chatStyleLanguageMode: data.chatStyleLanguageMode.override ?? null,
|
chatStyleLanguageMode: data.chatStyleLanguageMode.override ?? null,
|
||||||
chatStyleManualLanguage: data.chatStyleManualLanguage.override ?? null,
|
chatStyleManualLanguage: data.chatStyleManualLanguage.override ?? null,
|
||||||
rxresumeEmail: data.rxresumeEmail ?? "",
|
rxresumeEmail: data.rxresumeEmail ?? "",
|
||||||
|
rxresumeUrl: data.rxresumeUrl ?? "",
|
||||||
rxresumePassword: "",
|
rxresumePassword: "",
|
||||||
rxresumeApiKey: "",
|
rxresumeApiKey: "",
|
||||||
basicAuthUser: data.basicAuthUser ?? "",
|
basicAuthUser: data.basicAuthUser ?? "",
|
||||||
@@ -715,6 +718,10 @@ export const SettingsPage: React.FC = () => {
|
|||||||
envPayload.rxresumeEmail = normalizeString(data.rxresumeEmail);
|
envPayload.rxresumeEmail = normalizeString(data.rxresumeEmail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dirtyFields.rxresumeUrl) {
|
||||||
|
envPayload.rxresumeUrl = normalizeString(data.rxresumeUrl);
|
||||||
|
}
|
||||||
|
|
||||||
if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) {
|
if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) {
|
||||||
envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail);
|
envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
|||||||
const rxresumeApiKeyValue =
|
const rxresumeApiKeyValue =
|
||||||
useWatch({ control, name: "rxresumeApiKey" }) ?? "";
|
useWatch({ control, name: "rxresumeApiKey" }) ?? "";
|
||||||
const rxresumeEmailValue = useWatch({ control, name: "rxresumeEmail" }) ?? "";
|
const rxresumeEmailValue = useWatch({ control, name: "rxresumeEmail" }) ?? "";
|
||||||
|
const rxresumeUrlValue = useWatch({ control, name: "rxresumeUrl" }) ?? "";
|
||||||
const rxresumePasswordValue =
|
const rxresumePasswordValue =
|
||||||
useWatch({ control, name: "rxresumePassword" }) ?? "";
|
useWatch({ control, name: "rxresumePassword" }) ?? "";
|
||||||
const resumeProjectsValue = useWatch({ control, name: "resumeProjects" });
|
const resumeProjectsValue = useWatch({ control, name: "resumeProjects" });
|
||||||
@@ -85,6 +86,12 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
|||||||
hasRxResumeAccess={hasRxResumeAccess}
|
hasRxResumeAccess={hasRxResumeAccess}
|
||||||
showValidationStatus={Boolean(validationStatuses)}
|
showValidationStatus={Boolean(validationStatuses)}
|
||||||
validationStatuses={validationStatuses}
|
validationStatuses={validationStatuses}
|
||||||
|
shared={{
|
||||||
|
baseUrl: rxresumeUrlValue,
|
||||||
|
onBaseUrlChange: (value) =>
|
||||||
|
setDirtyTouchedValue("rxresumeUrl", value),
|
||||||
|
baseUrlError: errors.rxresumeUrl?.message as string | undefined,
|
||||||
|
}}
|
||||||
v5={{
|
v5={{
|
||||||
apiKey: rxresumeApiKeyValue,
|
apiKey: rxresumeApiKeyValue,
|
||||||
onApiKeyChange: (value) =>
|
onApiKeyChange: (value) =>
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ describe.sequential("Settings API routes", () => {
|
|||||||
env: {
|
env: {
|
||||||
LLM_API_KEY: "secret-key",
|
LLM_API_KEY: "secret-key",
|
||||||
RXRESUME_EMAIL: "resume@example.com",
|
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(body.data.model.default).toBe("test-model");
|
||||||
expect(Array.isArray(body.data.searchTerms.value)).toBe(true);
|
expect(Array.isArray(body.data.searchTerms.value)).toBe(true);
|
||||||
expect(body.data.rxresumeEmail).toBe("resume@example.com");
|
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.llmApiKeyHint).toBe("secr");
|
||||||
expect(body.data.basicAuthActive).toBe(false);
|
expect(body.data.basicAuthActive).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -124,6 +126,7 @@ describe.sequential("Settings API routes", () => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
searchTerms: ["engineer"],
|
searchTerms: ["engineer"],
|
||||||
rxresumeEmail: "updated@example.com",
|
rxresumeEmail: "updated@example.com",
|
||||||
|
rxresumeUrl: "https://resume.example.com",
|
||||||
llmApiKey: "updated-secret",
|
llmApiKey: "updated-secret",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -132,6 +135,7 @@ describe.sequential("Settings API routes", () => {
|
|||||||
expect(patchBody.data.searchTerms.value).toEqual(["engineer"]);
|
expect(patchBody.data.searchTerms.value).toEqual(["engineer"]);
|
||||||
expect(patchBody.data.searchTerms.override).toEqual(["engineer"]);
|
expect(patchBody.data.searchTerms.override).toEqual(["engineer"]);
|
||||||
expect(patchBody.data.rxresumeEmail).toBe("updated@example.com");
|
expect(patchBody.data.rxresumeEmail).toBe("updated@example.com");
|
||||||
|
expect(patchBody.data.rxresumeUrl).toBe("https://resume.example.com");
|
||||||
expect(patchBody.data.llmApiKeyHint).toBe("upda");
|
expect(patchBody.data.llmApiKeyHint).toBe("upda");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ describe("rxresume adapter", () => {
|
|||||||
delete process.env.RXRESUME_API_KEY;
|
delete process.env.RXRESUME_API_KEY;
|
||||||
delete process.env.RXRESUME_EMAIL;
|
delete process.env.RXRESUME_EMAIL;
|
||||||
delete process.env.RXRESUME_PASSWORD;
|
delete process.env.RXRESUME_PASSWORD;
|
||||||
|
delete process.env.RXRESUME_URL;
|
||||||
delete process.env.RXRESUME_MODE;
|
delete process.env.RXRESUME_MODE;
|
||||||
mockSettings({});
|
mockSettings({});
|
||||||
});
|
});
|
||||||
@@ -157,6 +158,44 @@ describe("rxresume adapter", () => {
|
|||||||
expect(result).toEqual({ ok: true, mode: "v4" });
|
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 () => {
|
it("does not fall back to v4 validation when explicit v5 validation fails", async () => {
|
||||||
mockSettings({
|
mockSettings({
|
||||||
rxresumeMode: "v5",
|
rxresumeMode: "v5",
|
||||||
|
|||||||
@@ -172,36 +172,42 @@ async function readConfiguredMode(): Promise<RxResumeMode> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function readV4Credentials(overrides?: ResolveModeOptions["v4"]) {
|
async function readV4Credentials(overrides?: ResolveModeOptions["v4"]) {
|
||||||
const [storedEmail, storedPassword] = await Promise.all([
|
const [storedEmail, storedPassword, storedBaseUrl] = await Promise.all([
|
||||||
getSetting("rxresumeEmail"),
|
getSetting("rxresumeEmail"),
|
||||||
getSetting("rxresumePassword"),
|
getSetting("rxresumePassword"),
|
||||||
|
getSetting("rxresumeUrl"),
|
||||||
]);
|
]);
|
||||||
const email =
|
const email =
|
||||||
overrides?.email?.trim() ||
|
overrides?.email?.trim() ||
|
||||||
process.env.RXRESUME_EMAIL?.trim() ||
|
|
||||||
storedEmail?.trim() ||
|
storedEmail?.trim() ||
|
||||||
|
process.env.RXRESUME_EMAIL?.trim() ||
|
||||||
"";
|
"";
|
||||||
const password =
|
const password =
|
||||||
overrides?.password?.trim() ||
|
overrides?.password?.trim() ||
|
||||||
process.env.RXRESUME_PASSWORD?.trim() ||
|
|
||||||
storedPassword?.trim() ||
|
storedPassword?.trim() ||
|
||||||
|
process.env.RXRESUME_PASSWORD?.trim() ||
|
||||||
"";
|
"";
|
||||||
const baseUrl =
|
const baseUrl =
|
||||||
overrides?.baseUrl?.trim() ||
|
overrides?.baseUrl?.trim() ||
|
||||||
|
storedBaseUrl?.trim() ||
|
||||||
process.env.RXRESUME_URL?.trim() ||
|
process.env.RXRESUME_URL?.trim() ||
|
||||||
"https://v4.rxresu.me";
|
"https://v4.rxresu.me";
|
||||||
return { email, password, baseUrl, available: Boolean(email && password) };
|
return { email, password, baseUrl, available: Boolean(email && password) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readV5Credentials(overrides?: ResolveModeOptions["v5"]) {
|
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 =
|
const apiKey =
|
||||||
overrides?.apiKey?.trim() ||
|
overrides?.apiKey?.trim() ||
|
||||||
process.env.RXRESUME_API_KEY?.trim() ||
|
|
||||||
storedApiKey?.trim() ||
|
storedApiKey?.trim() ||
|
||||||
|
process.env.RXRESUME_API_KEY?.trim() ||
|
||||||
"";
|
"";
|
||||||
const baseUrl =
|
const baseUrl =
|
||||||
overrides?.baseUrl?.trim() ||
|
overrides?.baseUrl?.trim() ||
|
||||||
|
storedBaseUrl?.trim() ||
|
||||||
process.env.RXRESUME_URL?.trim() ||
|
process.env.RXRESUME_URL?.trim() ||
|
||||||
"https://rxresu.me";
|
"https://rxresu.me";
|
||||||
return { apiKey, baseUrl, available: Boolean(apiKey) };
|
return { apiKey, baseUrl, available: Boolean(apiKey) };
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ describe("settingsRegistry helpers", () => {
|
|||||||
it("has env-backed v5 api key secret setting", () => {
|
it("has env-backed v5 api key secret setting", () => {
|
||||||
expect(settingsRegistry.rxresumeApiKey.envKey).toBe("RXRESUME_API_KEY");
|
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", () => {
|
describe("writing-style language settings", () => {
|
||||||
|
|||||||
@@ -462,6 +462,14 @@ export const settingsRegistry = {
|
|||||||
envKey: "RXRESUME_EMAIL",
|
envKey: "RXRESUME_EMAIL",
|
||||||
schema: z.string().trim().max(200),
|
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: {
|
ukvisajobsEmail: {
|
||||||
kind: "string" as const,
|
kind: "string" as const,
|
||||||
envKey: "UKVISAJOBS_EMAIL",
|
envKey: "UKVISAJOBS_EMAIL",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { updateSettingsSchema } from "./settings-schema";
|
import { updateSettingsSchema } from "./settings-schema";
|
||||||
|
|
||||||
describe("updateSettingsSchema language settings", () => {
|
describe("updateSettingsSchema", () => {
|
||||||
it("accepts supported language mode and manual language values", () => {
|
it("accepts supported language mode and manual language values", () => {
|
||||||
expect(
|
expect(
|
||||||
updateSettingsSchema.parse({
|
updateSettingsSchema.parse({
|
||||||
@@ -43,4 +43,32 @@ describe("updateSettingsSchema language settings", () => {
|
|||||||
result.error.flatten().fieldErrors.chatStyleManualLanguage,
|
result.error.flatten().fieldErrors.chatStyleManualLanguage,
|
||||||
).toBeDefined();
|
).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ export const createAppSettings = (
|
|||||||
llmApiKeyHint: null,
|
llmApiKeyHint: null,
|
||||||
rxresumeApiKeyHint: null,
|
rxresumeApiKeyHint: null,
|
||||||
rxresumeEmail: null,
|
rxresumeEmail: null,
|
||||||
|
rxresumeUrl: null,
|
||||||
rxresumePasswordHint: null,
|
rxresumePasswordHint: null,
|
||||||
basicAuthUser: null,
|
basicAuthUser: null,
|
||||||
basicAuthPasswordHint: null,
|
basicAuthPasswordHint: null,
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ export interface AppSettings {
|
|||||||
rxresumeBaseResumeIdV4: string | null;
|
rxresumeBaseResumeIdV4: string | null;
|
||||||
rxresumeBaseResumeIdV5: string | null;
|
rxresumeBaseResumeIdV5: string | null;
|
||||||
rxresumeEmail: string | null;
|
rxresumeEmail: string | null;
|
||||||
|
rxresumeUrl: string | null;
|
||||||
ukvisajobsEmail: string | null;
|
ukvisajobsEmail: string | null;
|
||||||
adzunaAppId: string | null;
|
adzunaAppId: string | null;
|
||||||
basicAuthUser: string | null;
|
basicAuthUser: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user