Initial commit for env in UI
This commit is contained in:
parent
01c97ec59d
commit
7e1f9454d4
@ -198,6 +198,17 @@ export async function updateSettings(update: {
|
||||
jobspySites?: string[] | null
|
||||
jobspyLinkedinFetchDescription?: boolean | 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
|
||||
ukvisajobsHeadless?: boolean | null
|
||||
webhookSecret?: string | null
|
||||
notionApiKey?: string | null
|
||||
notionDatabaseId?: string | null
|
||||
}): Promise<AppSettings> {
|
||||
return fetchApi<AppSettings>('/settings', {
|
||||
method: 'PATCH',
|
||||
|
||||
@ -95,6 +95,17 @@ const baseSettings: AppSettings = {
|
||||
showSponsorInfo: true,
|
||||
defaultShowSponsorInfo: true,
|
||||
overrideShowSponsorInfo: null,
|
||||
openrouterApiKeyHint: null,
|
||||
rxresumeEmail: "",
|
||||
rxresumePasswordHint: null,
|
||||
basicAuthUser: "",
|
||||
basicAuthPasswordHint: null,
|
||||
ukvisajobsEmail: "",
|
||||
ukvisajobsPasswordHint: null,
|
||||
ukvisajobsHeadless: true,
|
||||
webhookSecretHint: null,
|
||||
notionApiKeyHint: null,
|
||||
notionDatabaseId: "",
|
||||
}
|
||||
|
||||
const renderPage = () => {
|
||||
|
||||
@ -14,6 +14,7 @@ import { arraysEqual } from "@/lib/utils"
|
||||
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 { JobCompleteWebhookSection } from "@client/pages/settings/components/JobCompleteWebhookSection"
|
||||
import { JobspySection } from "@client/pages/settings/components/JobspySection"
|
||||
@ -41,6 +42,17 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
||||
jobspySites: null,
|
||||
jobspyLinkedinFetchDescription: null,
|
||||
showSponsorInfo: null,
|
||||
openrouterApiKey: "",
|
||||
rxresumeEmail: "",
|
||||
rxresumePassword: "",
|
||||
basicAuthUser: "",
|
||||
basicAuthPassword: "",
|
||||
ukvisajobsEmail: "",
|
||||
ukvisajobsPassword: "",
|
||||
ukvisajobsHeadless: null,
|
||||
webhookSecret: "",
|
||||
notionApiKey: "",
|
||||
notionDatabaseId: "",
|
||||
}
|
||||
|
||||
const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
||||
@ -61,6 +73,17 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
||||
jobspySites: null,
|
||||
jobspyLinkedinFetchDescription: null,
|
||||
showSponsorInfo: null,
|
||||
openrouterApiKey: null,
|
||||
rxresumeEmail: null,
|
||||
rxresumePassword: null,
|
||||
basicAuthUser: null,
|
||||
basicAuthPassword: null,
|
||||
ukvisajobsEmail: null,
|
||||
ukvisajobsPassword: null,
|
||||
ukvisajobsHeadless: null,
|
||||
webhookSecret: null,
|
||||
notionApiKey: null,
|
||||
notionDatabaseId: null,
|
||||
}
|
||||
|
||||
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
||||
@ -81,6 +104,17 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
||||
jobspySites: data.overrideJobspySites,
|
||||
jobspyLinkedinFetchDescription: data.overrideJobspyLinkedinFetchDescription,
|
||||
showSponsorInfo: data.overrideShowSponsorInfo,
|
||||
openrouterApiKey: "",
|
||||
rxresumeEmail: data.rxresumeEmail ?? "",
|
||||
rxresumePassword: "",
|
||||
basicAuthUser: data.basicAuthUser ?? "",
|
||||
basicAuthPassword: "",
|
||||
ukvisajobsEmail: data.ukvisajobsEmail ?? "",
|
||||
ukvisajobsPassword: "",
|
||||
ukvisajobsHeadless: data.ukvisajobsHeadless,
|
||||
webhookSecret: "",
|
||||
notionApiKey: "",
|
||||
notionDatabaseId: data.notionDatabaseId ?? "",
|
||||
})
|
||||
|
||||
const normalizeString = (value: string | null | undefined) => {
|
||||
@ -88,6 +122,11 @@ const normalizeString = (value: string | null | undefined) => {
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
const normalizePrivateInput = (value: string | null | undefined) => {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed ? trimmed : undefined
|
||||
}
|
||||
|
||||
const isSameStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => {
|
||||
if (!left && !right) return true
|
||||
if (!left || !right) return false
|
||||
@ -170,6 +209,23 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
||||
effective: settings?.showSponsorInfo ?? true,
|
||||
default: settings?.defaultShowSponsorInfo ?? true,
|
||||
},
|
||||
envSettings: {
|
||||
readable: {
|
||||
rxresumeEmail: settings?.rxresumeEmail ?? "",
|
||||
ukvisajobsEmail: settings?.ukvisajobsEmail ?? "",
|
||||
basicAuthUser: settings?.basicAuthUser ?? "",
|
||||
notionDatabaseId: settings?.notionDatabaseId ?? "",
|
||||
ukvisajobsHeadless: settings?.ukvisajobsHeadless ?? true,
|
||||
},
|
||||
private: {
|
||||
openrouterApiKeyHint: settings?.openrouterApiKeyHint ?? null,
|
||||
rxresumePasswordHint: settings?.rxresumePasswordHint ?? null,
|
||||
ukvisajobsPasswordHint: settings?.ukvisajobsPasswordHint ?? null,
|
||||
basicAuthPasswordHint: settings?.basicAuthPasswordHint ?? null,
|
||||
webhookSecretHint: settings?.webhookSecretHint ?? null,
|
||||
notionApiKeyHint: settings?.notionApiKeyHint ?? null,
|
||||
},
|
||||
},
|
||||
defaultResumeProjects: settings?.defaultResumeProjects ?? null,
|
||||
profileProjects,
|
||||
maxProjectsTotal: profileProjects.length,
|
||||
@ -188,7 +244,7 @@ export const SettingsPage: React.FC = () => {
|
||||
defaultValues: DEFAULT_FORM_VALUES,
|
||||
})
|
||||
|
||||
const { handleSubmit, reset, watch, formState: { isDirty, errors, isValid } } = methods
|
||||
const { handleSubmit, reset, watch, formState: { isDirty, errors, isValid, dirtyFields } } = methods
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
@ -224,6 +280,7 @@ export const SettingsPage: React.FC = () => {
|
||||
searchTerms,
|
||||
jobspy,
|
||||
display,
|
||||
envSettings,
|
||||
defaultResumeProjects,
|
||||
profileProjects,
|
||||
maxProjectsTotal,
|
||||
@ -245,6 +302,58 @@ export const SettingsPage: React.FC = () => {
|
||||
? null
|
||||
: resumeProjectsData
|
||||
|
||||
const envPayload: Partial<UpdateSettingsInput> = {}
|
||||
|
||||
if (dirtyFields.rxresumeEmail) {
|
||||
envPayload.rxresumeEmail = normalizeString(data.rxresumeEmail)
|
||||
}
|
||||
|
||||
if (dirtyFields.ukvisajobsEmail) {
|
||||
envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail)
|
||||
}
|
||||
|
||||
if (dirtyFields.basicAuthUser) {
|
||||
envPayload.basicAuthUser = normalizeString(data.basicAuthUser)
|
||||
}
|
||||
|
||||
if (dirtyFields.notionDatabaseId) {
|
||||
envPayload.notionDatabaseId = normalizeString(data.notionDatabaseId)
|
||||
}
|
||||
|
||||
if (dirtyFields.ukvisajobsHeadless) {
|
||||
envPayload.ukvisajobsHeadless = data.ukvisajobsHeadless ?? null
|
||||
}
|
||||
|
||||
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.basicAuthPassword) {
|
||||
const value = normalizePrivateInput(data.basicAuthPassword)
|
||||
if (value !== undefined) envPayload.basicAuthPassword = value
|
||||
}
|
||||
|
||||
if (dirtyFields.webhookSecret) {
|
||||
const value = normalizePrivateInput(data.webhookSecret)
|
||||
if (value !== undefined) envPayload.webhookSecret = value
|
||||
}
|
||||
|
||||
if (dirtyFields.notionApiKey) {
|
||||
const value = normalizePrivateInput(data.notionApiKey)
|
||||
if (value !== undefined) envPayload.notionApiKey = value
|
||||
}
|
||||
|
||||
const payload: UpdateSettingsInput = {
|
||||
model: normalizeString(data.model),
|
||||
modelScorer: normalizeString(data.modelScorer),
|
||||
@ -266,6 +375,7 @@ export const SettingsPage: React.FC = () => {
|
||||
jobspy.linkedinFetchDescription.default
|
||||
),
|
||||
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
|
||||
...envPayload,
|
||||
}
|
||||
|
||||
const updated = await api.updateSettings(payload)
|
||||
@ -407,6 +517,11 @@ export const SettingsPage: React.FC = () => {
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<EnvironmentSettingsSection
|
||||
values={envSettings}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<DangerZoneSection
|
||||
statusesToClear={statusesToClear}
|
||||
toggleStatusToClear={toggleStatusToClear}
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
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",
|
||||
notionDatabaseId: "db-123",
|
||||
ukvisajobsHeadless: false,
|
||||
openrouterApiKey: "",
|
||||
rxresumePassword: "",
|
||||
ukvisajobsPassword: "",
|
||||
basicAuthPassword: "",
|
||||
webhookSecret: "",
|
||||
notionApiKey: "",
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Accordion type="multiple" defaultValue={["environment"]}>
|
||||
<EnvironmentSettingsSection
|
||||
values={{
|
||||
readable: {
|
||||
rxresumeEmail: "resume@example.com",
|
||||
ukvisajobsEmail: "visa@example.com",
|
||||
basicAuthUser: "admin",
|
||||
notionDatabaseId: "db-123",
|
||||
ukvisajobsHeadless: false,
|
||||
},
|
||||
private: {
|
||||
openrouterApiKeyHint: "sk-1",
|
||||
rxresumePasswordHint: null,
|
||||
ukvisajobsPasswordHint: "pass",
|
||||
basicAuthPasswordHint: "abcd",
|
||||
webhookSecretHint: "sec-",
|
||||
notionApiKeyHint: "not-",
|
||||
},
|
||||
}}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
/>
|
||||
</Accordion>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe("EnvironmentSettingsSection", () => {
|
||||
it("renders readable values and masks private secrets with hints", () => {
|
||||
render(<EnvironmentSettingsHarness />)
|
||||
|
||||
expect(screen.getByDisplayValue("resume@example.com")).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue("visa@example.com")).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue("admin")).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue("db-123")).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText("sk-1********")).toBeInTheDocument()
|
||||
expect(screen.getByText("pass********")).toBeInTheDocument()
|
||||
expect(screen.getByText("abcd********")).toBeInTheDocument()
|
||||
expect(screen.getByText("sec-********")).toBeInTheDocument()
|
||||
expect(screen.getByText("not-********")).toBeInTheDocument()
|
||||
expect(screen.getByText("Not set")).toBeInTheDocument()
|
||||
|
||||
const headlessToggle = screen.getByLabelText("UKVisaJobs headless mode")
|
||||
expect(headlessToggle).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,218 @@
|
||||
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 { EnvSettingsValues } from "@client/pages/settings/types"
|
||||
|
||||
type EnvironmentSettingsSectionProps = {
|
||||
values: EnvSettingsValues
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
const formatSecretHint = (hint: string | null) => (hint ? `${hint}********` : "Not set")
|
||||
|
||||
export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProps> = ({
|
||||
values,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { register, control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
||||
const { readable, private: privateValues } = values
|
||||
|
||||
return (
|
||||
<AccordionItem value="environment" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Environment</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">Readable values</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">RxResume email</div>
|
||||
<Input
|
||||
{...register("rxresumeEmail")}
|
||||
placeholder="you@example.com"
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
{errors.rxresumeEmail && <p className="text-xs text-destructive">{errors.rxresumeEmail.message}</p>}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Used for RxResume PDF automation.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">UKVisaJobs email</div>
|
||||
<Input
|
||||
{...register("ukvisajobsEmail")}
|
||||
placeholder="you@example.com"
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
{errors.ukvisajobsEmail && <p className="text-xs text-destructive">{errors.ukvisajobsEmail.message}</p>}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Used for refreshing UKVisaJobs sessions.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Basic auth user</div>
|
||||
<Input
|
||||
{...register("basicAuthUser")}
|
||||
placeholder="username"
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
{errors.basicAuthUser && <p className="text-xs text-destructive">{errors.basicAuthUser.message}</p>}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Pair with a password to require auth on writes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Notion database ID</div>
|
||||
<Input
|
||||
{...register("notionDatabaseId")}
|
||||
placeholder="xxxxxxxxxxxxxxxxxxxx"
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
{errors.notionDatabaseId && <p className="text-xs text-destructive">{errors.notionDatabaseId.message}</p>}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Destination database for applied job entries.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Controller
|
||||
name="ukvisajobsHeadless"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="ukvisajobsHeadless"
|
||||
checked={field.value ?? readable.ukvisajobsHeadless}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked === "indeterminate" ? null : checked === true)
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="ukvisajobsHeadless"
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
UKVisaJobs headless mode
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disable to show the browser while authenticating.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">Private values</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">OpenRouter API key</div>
|
||||
<Input
|
||||
{...register("openrouterApiKey")}
|
||||
type="password"
|
||||
placeholder="Enter new key"
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
{errors.openrouterApiKey && <p className="text-xs text-destructive">{errors.openrouterApiKey.message}</p>}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Current: <span className="font-mono">{formatSecretHint(privateValues.openrouterApiKeyHint)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">RxResume password</div>
|
||||
<Input
|
||||
{...register("rxresumePassword")}
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
{errors.rxresumePassword && <p className="text-xs text-destructive">{errors.rxresumePassword.message}</p>}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Current: <span className="font-mono">{formatSecretHint(privateValues.rxresumePasswordHint)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">UKVisaJobs password</div>
|
||||
<Input
|
||||
{...register("ukvisajobsPassword")}
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
{errors.ukvisajobsPassword && <p className="text-xs text-destructive">{errors.ukvisajobsPassword.message}</p>}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Current: <span className="font-mono">{formatSecretHint(privateValues.ukvisajobsPasswordHint)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Basic auth password</div>
|
||||
<Input
|
||||
{...register("basicAuthPassword")}
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
{errors.basicAuthPassword && <p className="text-xs text-destructive">{errors.basicAuthPassword.message}</p>}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Current: <span className="font-mono">{formatSecretHint(privateValues.basicAuthPasswordHint)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Webhook secret</div>
|
||||
<Input
|
||||
{...register("webhookSecret")}
|
||||
type="password"
|
||||
placeholder="Enter new secret"
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
{errors.webhookSecret && <p className="text-xs text-destructive">{errors.webhookSecret.message}</p>}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Current: <span className="font-mono">{formatSecretHint(privateValues.webhookSecretHint)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Notion API key</div>
|
||||
<Input
|
||||
{...register("notionApiKey")}
|
||||
type="password"
|
||||
placeholder="Enter new key"
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
{errors.notionApiKey && <p className="text-xs text-destructive">{errors.notionApiKey.message}</p>}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Current: <span className="font-mono">{formatSecretHint(privateValues.notionApiKeyHint)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Private values are write-only. Enter a new value to replace the stored secret.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -22,3 +22,21 @@ export type JobspyValues = {
|
||||
countryIndeed: EffectiveDefault<string>
|
||||
linkedinFetchDescription: EffectiveDefault<boolean>
|
||||
}
|
||||
|
||||
export type EnvSettingsValues = {
|
||||
readable: {
|
||||
rxresumeEmail: string
|
||||
ukvisajobsEmail: string
|
||||
basicAuthUser: string
|
||||
notionDatabaseId: string
|
||||
ukvisajobsHeadless: boolean
|
||||
}
|
||||
private: {
|
||||
openrouterApiKeyHint: string | null
|
||||
rxresumePasswordHint: string | null
|
||||
ukvisajobsPasswordHint: string | null
|
||||
basicAuthPasswordHint: string | null
|
||||
webhookSecretHint: string | null
|
||||
notionApiKeyHint: string | null
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,13 @@ describe.sequential('Settings API routes', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ server, baseUrl, closeDb, tempDir } = await startServer());
|
||||
({ server, baseUrl, closeDb, tempDir } = await startServer({
|
||||
env: {
|
||||
OPENROUTER_API_KEY: 'secret-key',
|
||||
RXRESUME_EMAIL: 'resume@example.com',
|
||||
UKVISAJOBS_HEADLESS: 'false',
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@ -22,6 +28,9 @@ describe.sequential('Settings API routes', () => {
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.defaultModel).toBe('test-model');
|
||||
expect(Array.isArray(body.data.searchTerms)).toBe(true);
|
||||
expect(body.data.rxresumeEmail).toBe('resume@example.com');
|
||||
expect(body.data.openrouterApiKeyHint).toBe('secr');
|
||||
expect(body.data.ukvisajobsHeadless).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid settings updates and persists overrides', async () => {
|
||||
@ -35,11 +44,19 @@ describe.sequential('Settings API routes', () => {
|
||||
const patchRes = await fetch(`${baseUrl}/api/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ searchTerms: ['engineer'] }),
|
||||
body: JSON.stringify({
|
||||
searchTerms: ['engineer'],
|
||||
rxresumeEmail: 'updated@example.com',
|
||||
openrouterApiKey: 'updated-secret',
|
||||
ukvisajobsHeadless: true,
|
||||
}),
|
||||
});
|
||||
const patchBody = await patchRes.json();
|
||||
expect(patchBody.success).toBe(true);
|
||||
expect(patchBody.data.searchTerms).toEqual(['engineer']);
|
||||
expect(patchBody.data.overrideSearchTerms).toEqual(['engineer']);
|
||||
expect(patchBody.data.rxresumeEmail).toBe('updated@example.com');
|
||||
expect(patchBody.data.openrouterApiKeyHint).toBe('upda');
|
||||
expect(patchBody.data.ukvisajobsHeadless).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { updateSettingsSchema } from '@shared/settings-schema.js';
|
||||
import * as settingsRepo from '@server/repositories/settings.js';
|
||||
import {
|
||||
applyEnvValue,
|
||||
getEnvSettingsData,
|
||||
normalizeEnvInput,
|
||||
serializeEnvBoolean,
|
||||
} from '@server/services/envSettings.js';
|
||||
import {
|
||||
extractProjectsFromProfile,
|
||||
normalizeResumeProjectsSettings,
|
||||
@ -97,6 +103,8 @@ settingsRouter.get('/', async (_req: Request, res: Response) => {
|
||||
: null;
|
||||
const showSponsorInfo = overrideShowSponsorInfo ?? defaultShowSponsorInfo;
|
||||
|
||||
const envSettings = await getEnvSettingsData();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
@ -146,6 +154,7 @@ settingsRouter.get('/', async (_req: Request, res: Response) => {
|
||||
showSponsorInfo,
|
||||
defaultShowSponsorInfo,
|
||||
overrideShowSponsorInfo,
|
||||
...envSettings,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@ -256,6 +265,73 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
|
||||
await settingsRepo.setSetting('showSponsorInfo', value !== null ? (value ? '1' : '0') : null);
|
||||
}
|
||||
|
||||
if ('openrouterApiKey' in input) {
|
||||
const value = normalizeEnvInput(input.openrouterApiKey);
|
||||
await settingsRepo.setSetting('openrouterApiKey', value);
|
||||
applyEnvValue('OPENROUTER_API_KEY', value);
|
||||
}
|
||||
|
||||
if ('rxresumeEmail' in input) {
|
||||
const value = normalizeEnvInput(input.rxresumeEmail);
|
||||
await settingsRepo.setSetting('rxresumeEmail', value);
|
||||
applyEnvValue('RXRESUME_EMAIL', value);
|
||||
}
|
||||
|
||||
if ('rxresumePassword' in input) {
|
||||
const value = normalizeEnvInput(input.rxresumePassword);
|
||||
await settingsRepo.setSetting('rxresumePassword', value);
|
||||
applyEnvValue('RXRESUME_PASSWORD', value);
|
||||
}
|
||||
|
||||
if ('basicAuthUser' in input) {
|
||||
const value = normalizeEnvInput(input.basicAuthUser);
|
||||
await settingsRepo.setSetting('basicAuthUser', value);
|
||||
applyEnvValue('BASIC_AUTH_USER', value);
|
||||
}
|
||||
|
||||
if ('basicAuthPassword' in input) {
|
||||
const value = normalizeEnvInput(input.basicAuthPassword);
|
||||
await settingsRepo.setSetting('basicAuthPassword', value);
|
||||
applyEnvValue('BASIC_AUTH_PASSWORD', value);
|
||||
}
|
||||
|
||||
if ('ukvisajobsEmail' in input) {
|
||||
const value = normalizeEnvInput(input.ukvisajobsEmail);
|
||||
await settingsRepo.setSetting('ukvisajobsEmail', value);
|
||||
applyEnvValue('UKVISAJOBS_EMAIL', value);
|
||||
}
|
||||
|
||||
if ('ukvisajobsPassword' in input) {
|
||||
const value = normalizeEnvInput(input.ukvisajobsPassword);
|
||||
await settingsRepo.setSetting('ukvisajobsPassword', value);
|
||||
applyEnvValue('UKVISAJOBS_PASSWORD', value);
|
||||
}
|
||||
|
||||
if ('ukvisajobsHeadless' in input) {
|
||||
const value = input.ukvisajobsHeadless ?? null;
|
||||
const serialized = serializeEnvBoolean(value);
|
||||
await settingsRepo.setSetting('ukvisajobsHeadless', serialized);
|
||||
applyEnvValue('UKVISAJOBS_HEADLESS', serialized);
|
||||
}
|
||||
|
||||
if ('webhookSecret' in input) {
|
||||
const value = normalizeEnvInput(input.webhookSecret);
|
||||
await settingsRepo.setSetting('webhookSecret', value);
|
||||
applyEnvValue('WEBHOOK_SECRET', value);
|
||||
}
|
||||
|
||||
if ('notionApiKey' in input) {
|
||||
const value = normalizeEnvInput(input.notionApiKey);
|
||||
await settingsRepo.setSetting('notionApiKey', value);
|
||||
applyEnvValue('NOTION_API_KEY', value);
|
||||
}
|
||||
|
||||
if ('notionDatabaseId' in input) {
|
||||
const value = normalizeEnvInput(input.notionDatabaseId);
|
||||
await settingsRepo.setSetting('notionDatabaseId', value);
|
||||
applyEnvValue('NOTION_DATABASE_ID', value);
|
||||
}
|
||||
|
||||
const overrideModel = await settingsRepo.getSetting('model');
|
||||
const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini';
|
||||
const model = overrideModel || defaultModel;
|
||||
@ -338,6 +414,8 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
|
||||
: null;
|
||||
const showSponsorInfo = overrideShowSponsorInfo ?? defaultShowSponsorInfo;
|
||||
|
||||
const envSettings = await getEnvSettingsData();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
@ -387,6 +465,7 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
|
||||
showSponsorInfo,
|
||||
defaultShowSponsorInfo,
|
||||
overrideShowSponsorInfo,
|
||||
...envSettings,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@ -86,11 +86,14 @@ export async function startServer(options?: {
|
||||
};
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const { applyStoredEnvOverrides } = await import('../../services/envSettings.js');
|
||||
const { createApp } = await import('../../app.js');
|
||||
const { closeDb } = await import('../../db/index.js');
|
||||
const { getPipelineStatus } = await import('../../pipeline/index.js');
|
||||
vi.mocked(getPipelineStatus).mockReturnValue({ isRunning: false });
|
||||
|
||||
await applyStoredEnvOverrides();
|
||||
|
||||
const app = createApp();
|
||||
const server = app.listen(0);
|
||||
await new Promise<void>((resolve) => server.once('listening', () => resolve()));
|
||||
|
||||
@ -13,12 +13,19 @@ import { getDataDir } from './config/dataDir.js';
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function createBasicAuthGuard() {
|
||||
const BASIC_AUTH_USER = process.env.BASIC_AUTH_USER || '';
|
||||
const BASIC_AUTH_PASSWORD = process.env.BASIC_AUTH_PASSWORD || '';
|
||||
const basicAuthEnabled = BASIC_AUTH_USER.length > 0 && BASIC_AUTH_PASSWORD.length > 0;
|
||||
function getAuthConfig() {
|
||||
const user = process.env.BASIC_AUTH_USER || '';
|
||||
const pass = process.env.BASIC_AUTH_PASSWORD || '';
|
||||
return {
|
||||
user,
|
||||
pass,
|
||||
enabled: user.length > 0 && pass.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function isAuthorized(req: express.Request): boolean {
|
||||
if (!basicAuthEnabled) return false;
|
||||
const { user: authUser, pass: authPass, enabled } = getAuthConfig();
|
||||
if (!enabled) return false;
|
||||
const authHeader = req.headers.authorization || '';
|
||||
if (!authHeader.startsWith('Basic ')) return false;
|
||||
const encoded = authHeader.slice('Basic '.length).trim();
|
||||
@ -32,7 +39,7 @@ function createBasicAuthGuard() {
|
||||
if (separatorIndex === -1) return false;
|
||||
const user = decoded.slice(0, separatorIndex);
|
||||
const pass = decoded.slice(separatorIndex + 1);
|
||||
return user === BASIC_AUTH_USER && pass === BASIC_AUTH_PASSWORD;
|
||||
return user === authUser && pass === authPass;
|
||||
}
|
||||
|
||||
function isPublicReadOnlyRoute(method: string, path: string): boolean {
|
||||
@ -48,7 +55,8 @@ function createBasicAuthGuard() {
|
||||
}
|
||||
|
||||
const middleware = (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
if (!basicAuthEnabled || !requiresAuth(req.method, req.path)) return next();
|
||||
const { enabled } = getAuthConfig();
|
||||
if (!enabled || !requiresAuth(req.method, req.path)) return next();
|
||||
if (isAuthorized(req)) return next();
|
||||
res.setHeader('WWW-Authenticate', 'Basic realm="Job Ops"');
|
||||
res.status(401).send('Authentication required');
|
||||
@ -57,7 +65,7 @@ function createBasicAuthGuard() {
|
||||
return {
|
||||
middleware,
|
||||
isAuthorized,
|
||||
basicAuthEnabled,
|
||||
basicAuthEnabled: getAuthConfig().enabled,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -4,14 +4,18 @@
|
||||
|
||||
import './config/env.js';
|
||||
import { createApp } from './app.js';
|
||||
import { applyStoredEnvOverrides } from './services/envSettings.js';
|
||||
import { initialize as initializeVisaSponsors } from './services/visa-sponsors/index.js';
|
||||
|
||||
const app = createApp();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
async function startServer() {
|
||||
await applyStoredEnvOverrides();
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, async () => {
|
||||
console.log(`
|
||||
const app = createApp();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, async () => {
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ 🚀 Job Ops Orchestrator ║
|
||||
@ -25,10 +29,13 @@ app.listen(PORT, async () => {
|
||||
╚═══════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
|
||||
// Initialize visa sponsors service (downloads data if needed, starts scheduler)
|
||||
try {
|
||||
await initializeVisaSponsors();
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to initialize visa sponsors service:', error);
|
||||
}
|
||||
});
|
||||
// Initialize visa sponsors service (downloads data if needed, starts scheduler)
|
||||
try {
|
||||
await initializeVisaSponsors();
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to initialize visa sponsors service:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void startServer();
|
||||
|
||||
@ -24,6 +24,17 @@ export type SettingKey = 'model'
|
||||
| 'jobspySites'
|
||||
| 'jobspyLinkedinFetchDescription'
|
||||
| 'showSponsorInfo'
|
||||
| 'openrouterApiKey'
|
||||
| 'rxresumeEmail'
|
||||
| 'rxresumePassword'
|
||||
| 'basicAuthUser'
|
||||
| 'basicAuthPassword'
|
||||
| 'ukvisajobsEmail'
|
||||
| 'ukvisajobsPassword'
|
||||
| 'ukvisajobsHeadless'
|
||||
| 'webhookSecret'
|
||||
| 'notionApiKey'
|
||||
| 'notionDatabaseId'
|
||||
|
||||
export async function getSetting(key: SettingKey): Promise<string | null> {
|
||||
const [row] = await db.select().from(settings).where(eq(settings.key, key))
|
||||
|
||||
109
orchestrator/src/server/services/envSettings.ts
Normal file
109
orchestrator/src/server/services/envSettings.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import * as settingsRepo from '@server/repositories/settings.js';
|
||||
import { SettingKey } from '@server/repositories/settings.js';
|
||||
|
||||
const envDefaults: Record<string, string | undefined> = { ...process.env };
|
||||
|
||||
const readableStringConfig: { settingKey: SettingKey, envKey: string }[] = [
|
||||
{ settingKey: 'rxresumeEmail', envKey: 'RXRESUME_EMAIL' },
|
||||
{ settingKey: 'ukvisajobsEmail', envKey: 'UKVISAJOBS_EMAIL' },
|
||||
{ settingKey: 'basicAuthUser', envKey: 'BASIC_AUTH_USER' },
|
||||
{ settingKey: 'notionDatabaseId', envKey: 'NOTION_DATABASE_ID' },
|
||||
];
|
||||
|
||||
const readableBooleanConfig: { settingKey: SettingKey, envKey: string, defaultValue: boolean }[] = [
|
||||
{ settingKey: 'ukvisajobsHeadless', envKey: 'UKVISAJOBS_HEADLESS', defaultValue: true },
|
||||
];
|
||||
|
||||
const privateStringConfig: { settingKey: SettingKey, envKey: string, hintKey: string }[] = [
|
||||
{ settingKey: 'openrouterApiKey', envKey: 'OPENROUTER_API_KEY', hintKey: 'openrouterApiKeyHint' },
|
||||
{ settingKey: 'rxresumePassword', envKey: 'RXRESUME_PASSWORD', hintKey: 'rxresumePasswordHint' },
|
||||
{ settingKey: 'ukvisajobsPassword', envKey: 'UKVISAJOBS_PASSWORD', hintKey: 'ukvisajobsPasswordHint' },
|
||||
{ settingKey: 'basicAuthPassword', envKey: 'BASIC_AUTH_PASSWORD', hintKey: 'basicAuthPasswordHint' },
|
||||
{ settingKey: 'webhookSecret', envKey: 'WEBHOOK_SECRET', hintKey: 'webhookSecretHint' },
|
||||
{ settingKey: 'notionApiKey', envKey: 'NOTION_API_KEY', hintKey: 'notionApiKeyHint' },
|
||||
];
|
||||
|
||||
export function normalizeEnvInput(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function parseEnvBoolean(raw: string | null | undefined, defaultValue: boolean): boolean {
|
||||
if (raw === undefined || raw === null || raw === '') return defaultValue;
|
||||
if (raw === 'false' || raw === '0') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function applyEnvValue(envKey: string, value: string | null): void {
|
||||
if (value === null) {
|
||||
const fallback = envDefaults[envKey];
|
||||
if (fallback === undefined) {
|
||||
delete process.env[envKey];
|
||||
} else {
|
||||
process.env[envKey] = fallback;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
process.env[envKey] = value;
|
||||
}
|
||||
|
||||
export function serializeEnvBoolean(value: boolean | null): string | null {
|
||||
if (value === null) return null;
|
||||
return value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
export async function applyStoredEnvOverrides(): Promise<void> {
|
||||
await Promise.all([
|
||||
...readableStringConfig.map(async ({ settingKey, envKey }) => {
|
||||
const override = await settingsRepo.getSetting(settingKey);
|
||||
if (override === null) return;
|
||||
applyEnvValue(envKey, normalizeEnvInput(override));
|
||||
}),
|
||||
...readableBooleanConfig.map(async ({ settingKey, envKey, defaultValue }) => {
|
||||
const override = await settingsRepo.getSetting(settingKey);
|
||||
if (override === null) return;
|
||||
const parsed = parseEnvBoolean(override, defaultValue);
|
||||
applyEnvValue(envKey, serializeEnvBoolean(parsed));
|
||||
}),
|
||||
...privateStringConfig.map(async ({ settingKey, envKey }) => {
|
||||
const override = await settingsRepo.getSetting(settingKey);
|
||||
if (override === null) return;
|
||||
applyEnvValue(envKey, normalizeEnvInput(override));
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function getEnvSettingsData(): Promise<Record<string, string | boolean | number | null>> {
|
||||
const readableValues: Record<string, string | boolean | null> = {};
|
||||
const privateValues: Record<string, string | null> = {};
|
||||
|
||||
for (const { settingKey, envKey } of readableStringConfig) {
|
||||
const override = await settingsRepo.getSetting(settingKey);
|
||||
const rawValue = override ?? process.env[envKey];
|
||||
readableValues[settingKey] = normalizeEnvInput(rawValue);
|
||||
}
|
||||
|
||||
for (const { settingKey, envKey, defaultValue } of readableBooleanConfig) {
|
||||
const override = await settingsRepo.getSetting(settingKey);
|
||||
const rawValue = override ?? process.env[envKey];
|
||||
readableValues[settingKey] = parseEnvBoolean(rawValue, defaultValue);
|
||||
}
|
||||
|
||||
for (const { settingKey, envKey, hintKey } of privateStringConfig) {
|
||||
const override = await settingsRepo.getSetting(settingKey);
|
||||
const rawValue = override ?? process.env[envKey];
|
||||
privateValues[hintKey] = rawValue ? rawValue.slice(0, 4) : null;
|
||||
}
|
||||
|
||||
return {
|
||||
...readableValues,
|
||||
...privateValues,
|
||||
};
|
||||
}
|
||||
|
||||
export const envSettingConfig = {
|
||||
readableStringConfig,
|
||||
readableBooleanConfig,
|
||||
privateStringConfig,
|
||||
};
|
||||
@ -24,6 +24,17 @@ export const updateSettingsSchema = z.object({
|
||||
jobspySites: z.array(z.string().trim().min(1).max(50)).max(20).nullable().optional(),
|
||||
jobspyLinkedinFetchDescription: z.boolean().nullable().optional(),
|
||||
showSponsorInfo: z.boolean().nullable().optional(),
|
||||
openrouterApiKey: z.string().trim().max(2000).nullable().optional(),
|
||||
rxresumeEmail: z.string().trim().max(200).nullable().optional(),
|
||||
rxresumePassword: z.string().trim().max(2000).nullable().optional(),
|
||||
basicAuthUser: z.string().trim().max(200).nullable().optional(),
|
||||
basicAuthPassword: z.string().trim().max(2000).nullable().optional(),
|
||||
ukvisajobsEmail: z.string().trim().max(200).nullable().optional(),
|
||||
ukvisajobsPassword: z.string().trim().max(2000).nullable().optional(),
|
||||
ukvisajobsHeadless: z.boolean().nullable().optional(),
|
||||
webhookSecret: z.string().trim().max(2000).nullable().optional(),
|
||||
notionApiKey: z.string().trim().max(2000).nullable().optional(),
|
||||
notionDatabaseId: z.string().trim().max(200).nullable().optional(),
|
||||
});
|
||||
|
||||
export type UpdateSettingsInput = z.infer<typeof updateSettingsSchema>;
|
||||
|
||||
@ -383,4 +383,15 @@ export interface AppSettings {
|
||||
showSponsorInfo: boolean;
|
||||
defaultShowSponsorInfo: boolean;
|
||||
overrideShowSponsorInfo: boolean | null;
|
||||
openrouterApiKeyHint: string | null;
|
||||
rxresumeEmail: string | null;
|
||||
rxresumePasswordHint: string | null;
|
||||
basicAuthUser: string | null;
|
||||
basicAuthPasswordHint: string | null;
|
||||
ukvisajobsEmail: string | null;
|
||||
ukvisajobsPasswordHint: string | null;
|
||||
ukvisajobsHeadless: boolean;
|
||||
webhookSecretHint: string | null;
|
||||
notionApiKeyHint: string | null;
|
||||
notionDatabaseId: string | null;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user