From 7e1f9454d4f327085804ec6f83acdaf883c7e7c0 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Thu, 22 Jan 2026 11:45:17 +0000 Subject: [PATCH 01/13] Initial commit for env in UI --- orchestrator/src/client/api/client.ts | 11 + .../src/client/pages/SettingsPage.test.tsx | 11 + .../src/client/pages/SettingsPage.tsx | 117 +++++++++- .../EnvironmentSettingsSection.test.tsx | 73 ++++++ .../components/EnvironmentSettingsSection.tsx | 218 ++++++++++++++++++ .../src/client/pages/settings/types.ts | 18 ++ .../src/server/api/routes/settings.test.ts | 21 +- .../src/server/api/routes/settings.ts | 79 +++++++ .../src/server/api/routes/test-utils.ts | 3 + orchestrator/src/server/app.ts | 22 +- orchestrator/src/server/index.ts | 31 ++- .../src/server/repositories/settings.ts | 11 + .../src/server/services/envSettings.ts | 109 +++++++++ orchestrator/src/shared/settings-schema.ts | 11 + orchestrator/src/shared/types.ts | 11 + 15 files changed, 724 insertions(+), 22 deletions(-) create mode 100644 orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx create mode 100644 orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx create mode 100644 orchestrator/src/server/services/envSettings.ts diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index f60d8c6..f3f1af3 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -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 { return fetchApi('/settings', { method: 'PATCH', diff --git a/orchestrator/src/client/pages/SettingsPage.test.tsx b/orchestrator/src/client/pages/SettingsPage.test.tsx index d488b22..6b5de51 100644 --- a/orchestrator/src/client/pages/SettingsPage.test.tsx +++ b/orchestrator/src/client/pages/SettingsPage.test.tsx @@ -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 = () => { diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 37b4c90..5cee80d 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -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 = {} + + 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} /> + { + const methods = useForm({ + defaultValues: { + rxresumeEmail: "resume@example.com", + ukvisajobsEmail: "visa@example.com", + basicAuthUser: "admin", + notionDatabaseId: "db-123", + ukvisajobsHeadless: false, + openrouterApiKey: "", + rxresumePassword: "", + ukvisajobsPassword: "", + basicAuthPassword: "", + webhookSecret: "", + notionApiKey: "", + } + }) + + return ( + + + + + + ) +} + +describe("EnvironmentSettingsSection", () => { + it("renders readable values and masks private secrets with hints", () => { + render() + + 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() + }) +}) diff --git a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx new file mode 100644 index 0000000..fefdd72 --- /dev/null +++ b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx @@ -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 = ({ + values, + isLoading, + isSaving, +}) => { + const { register, control, formState: { errors } } = useFormContext() + const { readable, private: privateValues } = values + + return ( + + + Environment + + +
+
+
Readable values
+
+
+
RxResume email
+ + {errors.rxresumeEmail &&

{errors.rxresumeEmail.message}

} +
+ Used for RxResume PDF automation. +
+
+ +
+
UKVisaJobs email
+ + {errors.ukvisajobsEmail &&

{errors.ukvisajobsEmail.message}

} +
+ Used for refreshing UKVisaJobs sessions. +
+
+ +
+
Basic auth user
+ + {errors.basicAuthUser &&

{errors.basicAuthUser.message}

} +
+ Pair with a password to require auth on writes. +
+
+ +
+
Notion database ID
+ + {errors.notionDatabaseId &&

{errors.notionDatabaseId.message}

} +
+ Destination database for applied job entries. +
+
+ +
+
+ ( + { + field.onChange(checked === "indeterminate" ? null : checked === true) + }} + disabled={isLoading || isSaving} + /> + )} + /> +
+ +

+ Disable to show the browser while authenticating. +

+
+
+
+
+
+ + + +
+
Private values
+
+
+
OpenRouter API key
+ + {errors.openrouterApiKey &&

{errors.openrouterApiKey.message}

} +
+ Current: {formatSecretHint(privateValues.openrouterApiKeyHint)} +
+
+ +
+
RxResume password
+ + {errors.rxresumePassword &&

{errors.rxresumePassword.message}

} +
+ Current: {formatSecretHint(privateValues.rxresumePasswordHint)} +
+
+ +
+
UKVisaJobs password
+ + {errors.ukvisajobsPassword &&

{errors.ukvisajobsPassword.message}

} +
+ Current: {formatSecretHint(privateValues.ukvisajobsPasswordHint)} +
+
+ +
+
Basic auth password
+ + {errors.basicAuthPassword &&

{errors.basicAuthPassword.message}

} +
+ Current: {formatSecretHint(privateValues.basicAuthPasswordHint)} +
+
+ +
+
Webhook secret
+ + {errors.webhookSecret &&

{errors.webhookSecret.message}

} +
+ Current: {formatSecretHint(privateValues.webhookSecretHint)} +
+
+ +
+
Notion API key
+ + {errors.notionApiKey &&

{errors.notionApiKey.message}

} +
+ Current: {formatSecretHint(privateValues.notionApiKeyHint)} +
+
+
+
+ Private values are write-only. Enter a new value to replace the stored secret. +
+
+
+
+
+ ) +} diff --git a/orchestrator/src/client/pages/settings/types.ts b/orchestrator/src/client/pages/settings/types.ts index 227be46..e424a7d 100644 --- a/orchestrator/src/client/pages/settings/types.ts +++ b/orchestrator/src/client/pages/settings/types.ts @@ -22,3 +22,21 @@ export type JobspyValues = { countryIndeed: EffectiveDefault linkedinFetchDescription: EffectiveDefault } + +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 + } +} diff --git a/orchestrator/src/server/api/routes/settings.test.ts b/orchestrator/src/server/api/routes/settings.test.ts index 1e13474..6d2006b 100644 --- a/orchestrator/src/server/api/routes/settings.test.ts +++ b/orchestrator/src/server/api/routes/settings.test.ts @@ -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); }); }); diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index 1813fca..082c6dd 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -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) { diff --git a/orchestrator/src/server/api/routes/test-utils.ts b/orchestrator/src/server/api/routes/test-utils.ts index 246f0aa..8787a9d 100644 --- a/orchestrator/src/server/api/routes/test-utils.ts +++ b/orchestrator/src/server/api/routes/test-utils.ts @@ -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((resolve) => server.once('listening', () => resolve())); diff --git a/orchestrator/src/server/app.ts b/orchestrator/src/server/app.ts index b592371..4f39077 100644 --- a/orchestrator/src/server/app.ts +++ b/orchestrator/src/server/app.ts @@ -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, }; } diff --git a/orchestrator/src/server/index.ts b/orchestrator/src/server/index.ts index df29e1b..fc7e212 100644 --- a/orchestrator/src/server/index.ts +++ b/orchestrator/src/server/index.ts @@ -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(); diff --git a/orchestrator/src/server/repositories/settings.ts b/orchestrator/src/server/repositories/settings.ts index 36b6f19..399581b 100644 --- a/orchestrator/src/server/repositories/settings.ts +++ b/orchestrator/src/server/repositories/settings.ts @@ -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 { const [row] = await db.select().from(settings).where(eq(settings.key, key)) diff --git a/orchestrator/src/server/services/envSettings.ts b/orchestrator/src/server/services/envSettings.ts new file mode 100644 index 0000000..201f209 --- /dev/null +++ b/orchestrator/src/server/services/envSettings.ts @@ -0,0 +1,109 @@ +import * as settingsRepo from '@server/repositories/settings.js'; +import { SettingKey } from '@server/repositories/settings.js'; + +const envDefaults: Record = { ...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 { + 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> { + const readableValues: Record = {}; + const privateValues: Record = {}; + + 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, +}; diff --git a/orchestrator/src/shared/settings-schema.ts b/orchestrator/src/shared/settings-schema.ts index a4db7f2..237df50 100644 --- a/orchestrator/src/shared/settings-schema.ts +++ b/orchestrator/src/shared/settings-schema.ts @@ -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; diff --git a/orchestrator/src/shared/types.ts b/orchestrator/src/shared/types.ts index d59c27f..16d7bb0 100644 --- a/orchestrator/src/shared/types.ts +++ b/orchestrator/src/shared/types.ts @@ -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; } From 0424a290085daba251318ba1da877112219f3b2c Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Thu, 22 Jan 2026 12:04:20 +0000 Subject: [PATCH 02/13] correct keys, and keys separated correctly --- .../src/client/pages/SettingsPage.tsx | 27 +- .../EnvironmentSettingsSection.test.tsx | 22 +- .../components/EnvironmentSettingsSection.tsx | 279 ++++++++---------- .../src/client/pages/settings/types.ts | 2 +- .../src/server/api/routes/settings.test.ts | 5 +- .../src/server/api/routes/settings.ts | 19 -- .../src/server/repositories/settings.ts | 3 - .../src/server/services/envSettings.ts | 10 +- orchestrator/src/shared/settings-schema.ts | 3 - orchestrator/src/shared/types.ts | 4 +- 10 files changed, 148 insertions(+), 226 deletions(-) diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 5cee80d..cae31d2 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -49,10 +49,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = { basicAuthPassword: "", ukvisajobsEmail: "", ukvisajobsPassword: "", - ukvisajobsHeadless: null, webhookSecret: "", - notionApiKey: "", - notionDatabaseId: "", } const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { @@ -80,10 +77,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { basicAuthPassword: null, ukvisajobsEmail: null, ukvisajobsPassword: null, - ukvisajobsHeadless: null, webhookSecret: null, - notionApiKey: null, - notionDatabaseId: null, } const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ @@ -111,10 +105,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ basicAuthPassword: "", ukvisajobsEmail: data.ukvisajobsEmail ?? "", ukvisajobsPassword: "", - ukvisajobsHeadless: data.ukvisajobsHeadless, webhookSecret: "", - notionApiKey: "", - notionDatabaseId: data.notionDatabaseId ?? "", }) const normalizeString = (value: string | null | undefined) => { @@ -214,8 +205,6 @@ const getDerivedSettings = (settings: AppSettings | null) => { rxresumeEmail: settings?.rxresumeEmail ?? "", ukvisajobsEmail: settings?.ukvisajobsEmail ?? "", basicAuthUser: settings?.basicAuthUser ?? "", - notionDatabaseId: settings?.notionDatabaseId ?? "", - ukvisajobsHeadless: settings?.ukvisajobsHeadless ?? true, }, private: { openrouterApiKeyHint: settings?.openrouterApiKeyHint ?? null, @@ -223,10 +212,11 @@ const getDerivedSettings = (settings: AppSettings | null) => { ukvisajobsPasswordHint: settings?.ukvisajobsPasswordHint ?? null, basicAuthPasswordHint: settings?.basicAuthPasswordHint ?? null, webhookSecretHint: settings?.webhookSecretHint ?? null, - notionApiKeyHint: settings?.notionApiKeyHint ?? null, }, + basicAuthActive: settings?.basicAuthActive ?? false, }, defaultResumeProjects: settings?.defaultResumeProjects ?? null, + profileProjects, maxProjectsTotal: profileProjects.length, } @@ -316,14 +306,6 @@ export const SettingsPage: React.FC = () => { 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 @@ -349,11 +331,6 @@ export const SettingsPage: React.FC = () => { 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), diff --git a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx index db7e703..ef117d6 100644 --- a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx +++ b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx @@ -11,14 +11,11 @@ const EnvironmentSettingsHarness = () => { rxresumeEmail: "resume@example.com", ukvisajobsEmail: "visa@example.com", basicAuthUser: "admin", - notionDatabaseId: "db-123", - ukvisajobsHeadless: false, openrouterApiKey: "", rxresumePassword: "", ukvisajobsPassword: "", basicAuthPassword: "", webhookSecret: "", - notionApiKey: "", } }) @@ -31,8 +28,6 @@ const EnvironmentSettingsHarness = () => { rxresumeEmail: "resume@example.com", ukvisajobsEmail: "visa@example.com", basicAuthUser: "admin", - notionDatabaseId: "db-123", - ukvisajobsHeadless: false, }, private: { openrouterApiKeyHint: "sk-1", @@ -40,8 +35,8 @@ const EnvironmentSettingsHarness = () => { ukvisajobsPasswordHint: "pass", basicAuthPasswordHint: "abcd", webhookSecretHint: "sec-", - notionApiKeyHint: "not-", }, + basicAuthActive: true, }} isLoading={false} isSaving={false} @@ -52,22 +47,25 @@ const EnvironmentSettingsHarness = () => { } describe("EnvironmentSettingsSection", () => { - it("renders readable values and masks private secrets with hints", () => { + it("renders values grouped logically and masks private secrets with hints", () => { render() 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() + // Basic Auth + expect(screen.getByLabelText("Enable basic authentication")).toBeChecked() + expect(screen.getByDisplayValue("admin")).toBeInTheDocument() + + // Sections + expect(screen.getByText("External Services")).toBeInTheDocument() + expect(screen.getByText("Service Accounts")).toBeInTheDocument() + expect(screen.getByText("Security")).toBeInTheDocument() }) }) diff --git a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx index fefdd72..683191d 100644 --- a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx @@ -1,4 +1,4 @@ -import React from "react" +import React, { useState, useEffect } from "react" import { useFormContext, Controller } from "react-hook-form" import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" @@ -22,109 +22,27 @@ export const EnvironmentSettingsSection: React.FC { const { register, control, formState: { errors } } = useFormContext() - const { readable, private: privateValues } = values + const { readable, private: privateValues, basicAuthActive } = values + + const [isBasicAuthEnabled, setIsBasicAuthEnabled] = useState(basicAuthActive) + + useEffect(() => { + setIsBasicAuthEnabled(basicAuthActive) + }, [basicAuthActive]) return ( - Environment + Environment & Accounts -
+
+ {/* External Services */}
-
Readable values
+
External Services
-
RxResume email
- - {errors.rxresumeEmail &&

{errors.rxresumeEmail.message}

} -
- Used for RxResume PDF automation. -
-
- -
-
UKVisaJobs email
- - {errors.ukvisajobsEmail &&

{errors.ukvisajobsEmail.message}

} -
- Used for refreshing UKVisaJobs sessions. -
-
- -
-
Basic auth user
- - {errors.basicAuthUser &&

{errors.basicAuthUser.message}

} -
- Pair with a password to require auth on writes. -
-
- -
-
Notion database ID
- - {errors.notionDatabaseId &&

{errors.notionDatabaseId.message}

} -
- Destination database for applied job entries. -
-
- -
-
- ( - { - field.onChange(checked === "indeterminate" ? null : checked === true) - }} - disabled={isLoading || isSaving} - /> - )} - /> -
- -

- Disable to show the browser while authenticating. -

-
-
-
-
-
- - - -
-
Private values
-
-
-
OpenRouter API key
+
OpenRouter API key
-
RxResume password
- - {errors.rxresumePassword &&

{errors.rxresumePassword.message}

} -
- Current: {formatSecretHint(privateValues.rxresumePasswordHint)} -
-
- -
-
UKVisaJobs password
- - {errors.ukvisajobsPassword &&

{errors.ukvisajobsPassword.message}

} -
- Current: {formatSecretHint(privateValues.ukvisajobsPasswordHint)} -
-
- -
-
Basic auth password
- - {errors.basicAuthPassword &&

{errors.basicAuthPassword.message}

} -
- Current: {formatSecretHint(privateValues.basicAuthPasswordHint)} -
-
- -
-
Webhook secret
+
Webhook secret
{formatSecretHint(privateValues.webhookSecretHint)}
+
+
-
-
Notion API key
- - {errors.notionApiKey &&

{errors.notionApiKey.message}

} -
- Current: {formatSecretHint(privateValues.notionApiKeyHint)} + + + {/* Service Accounts */} +
+
Service Accounts
+ +
+
RxResume
+
+
+
Email
+ + {errors.rxresumeEmail &&

{errors.rxresumeEmail.message}

} +
+
+
Password
+ + {errors.rxresumePassword &&

{errors.rxresumePassword.message}

} +
+ Current: {formatSecretHint(privateValues.rxresumePasswordHint)} +
-
- Private values are write-only. Enter a new value to replace the stored secret. + +
+
UKVisaJobs
+
+
+
Email
+ + {errors.ukvisajobsEmail &&

{errors.ukvisajobsEmail.message}

} +
+
+
Password
+ + {errors.ukvisajobsPassword &&

{errors.ukvisajobsPassword.message}

} +
+ Current: {formatSecretHint(privateValues.ukvisajobsPasswordHint)} +
+
+
+ + + + {/* Security */} +
+
Security
+
+ setIsBasicAuthEnabled(checked === true)} + disabled={isLoading || isSaving} + /> +
+ +

+ Require a username and password for write operations. +

+
+
+ + {isBasicAuthEnabled && ( +
+
+
Username
+ + {errors.basicAuthUser &&

{errors.basicAuthUser.message}

} +
+ +
+
Password
+ + {errors.basicAuthPassword &&

{errors.basicAuthPassword.message}

} +
+ Current: {formatSecretHint(privateValues.basicAuthPasswordHint)} +
+
+
+ )} +
) } + diff --git a/orchestrator/src/client/pages/settings/types.ts b/orchestrator/src/client/pages/settings/types.ts index e424a7d..a0b2555 100644 --- a/orchestrator/src/client/pages/settings/types.ts +++ b/orchestrator/src/client/pages/settings/types.ts @@ -37,6 +37,6 @@ export type EnvSettingsValues = { ukvisajobsPasswordHint: string | null basicAuthPasswordHint: string | null webhookSecretHint: string | null - notionApiKeyHint: string | null } + basicAuthActive: boolean } diff --git a/orchestrator/src/server/api/routes/settings.test.ts b/orchestrator/src/server/api/routes/settings.test.ts index 6d2006b..312216a 100644 --- a/orchestrator/src/server/api/routes/settings.test.ts +++ b/orchestrator/src/server/api/routes/settings.test.ts @@ -13,7 +13,6 @@ describe.sequential('Settings API routes', () => { env: { OPENROUTER_API_KEY: 'secret-key', RXRESUME_EMAIL: 'resume@example.com', - UKVISAJOBS_HEADLESS: 'false', }, })); }); @@ -30,7 +29,7 @@ describe.sequential('Settings API routes', () => { 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); + expect(body.data.basicAuthActive).toBe(false); }); it('rejects invalid settings updates and persists overrides', async () => { @@ -48,7 +47,6 @@ describe.sequential('Settings API routes', () => { searchTerms: ['engineer'], rxresumeEmail: 'updated@example.com', openrouterApiKey: 'updated-secret', - ukvisajobsHeadless: true, }), }); const patchBody = await patchRes.json(); @@ -57,6 +55,5 @@ describe.sequential('Settings API routes', () => { 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); }); }); diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index 082c6dd..0f5d687 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -307,31 +307,12 @@ settingsRouter.patch('/', async (req: Request, res: Response) => { 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; diff --git a/orchestrator/src/server/repositories/settings.ts b/orchestrator/src/server/repositories/settings.ts index 399581b..310c144 100644 --- a/orchestrator/src/server/repositories/settings.ts +++ b/orchestrator/src/server/repositories/settings.ts @@ -31,10 +31,7 @@ export type SettingKey = 'model' | 'basicAuthPassword' | 'ukvisajobsEmail' | 'ukvisajobsPassword' - | 'ukvisajobsHeadless' | 'webhookSecret' - | 'notionApiKey' - | 'notionDatabaseId' export async function getSetting(key: SettingKey): Promise { const [row] = await db.select().from(settings).where(eq(settings.key, key)) diff --git a/orchestrator/src/server/services/envSettings.ts b/orchestrator/src/server/services/envSettings.ts index 201f209..45eb77d 100644 --- a/orchestrator/src/server/services/envSettings.ts +++ b/orchestrator/src/server/services/envSettings.ts @@ -7,12 +7,9 @@ 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 readableBooleanConfig: { settingKey: SettingKey, envKey: string, defaultValue: boolean }[] = []; const privateStringConfig: { settingKey: SettingKey, envKey: string, hintKey: string }[] = [ { settingKey: 'openrouterApiKey', envKey: 'OPENROUTER_API_KEY', hintKey: 'openrouterApiKeyHint' }, @@ -20,7 +17,6 @@ const privateStringConfig: { settingKey: SettingKey, envKey: string, hintKey: st { 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 { @@ -96,9 +92,13 @@ export async function getEnvSettingsData(): Promise; diff --git a/orchestrator/src/shared/types.ts b/orchestrator/src/shared/types.ts index 16d7bb0..fbafbf6 100644 --- a/orchestrator/src/shared/types.ts +++ b/orchestrator/src/shared/types.ts @@ -390,8 +390,6 @@ export interface AppSettings { basicAuthPasswordHint: string | null; ukvisajobsEmail: string | null; ukvisajobsPasswordHint: string | null; - ukvisajobsHeadless: boolean; webhookSecretHint: string | null; - notionApiKeyHint: string | null; - notionDatabaseId: string | null; + basicAuthActive: boolean; } From a81b1f0e580da86867ffce6bb8989edae5af4e2d Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Thu, 22 Jan 2026 12:24:14 +0000 Subject: [PATCH 03/13] use settings input component --- orchestrator/src/client/api/client.ts | 3 - .../src/client/pages/SettingsPage.test.tsx | 4 +- .../components/EnvironmentSettingsSection.tsx | 167 ++++++++---------- .../components/GradcrackerSection.tsx | 62 +++---- .../components/JobCompleteWebhookSection.tsx | 37 ++-- .../settings/components/JobspySection.tsx | 152 +++++++--------- .../components/ModelSettingsSection.tsx | 83 ++++----- .../components/PipelineWebhookSection.tsx | 37 ++-- .../settings/components/SettingsInput.tsx | 39 ++++ .../settings/components/UkvisajobsSection.tsx | 62 +++---- .../src/client/pages/settings/types.ts | 2 - .../src/server/api/routes/settings.ts | 1 - 12 files changed, 277 insertions(+), 372 deletions(-) create mode 100644 orchestrator/src/client/pages/settings/components/SettingsInput.tsx diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index f3f1af3..5c8c427 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -205,10 +205,7 @@ export async function updateSettings(update: { basicAuthPassword?: string | null ukvisajobsEmail?: string | null ukvisajobsPassword?: string | null - ukvisajobsHeadless?: boolean | null webhookSecret?: string | null - notionApiKey?: string | null - notionDatabaseId?: string | null }): Promise { return fetchApi('/settings', { method: 'PATCH', diff --git a/orchestrator/src/client/pages/SettingsPage.test.tsx b/orchestrator/src/client/pages/SettingsPage.test.tsx index 6b5de51..3189857 100644 --- a/orchestrator/src/client/pages/SettingsPage.test.tsx +++ b/orchestrator/src/client/pages/SettingsPage.test.tsx @@ -102,10 +102,8 @@ const baseSettings: AppSettings = { basicAuthPasswordHint: null, ukvisajobsEmail: "", ukvisajobsPasswordHint: null, - ukvisajobsHeadless: true, webhookSecretHint: null, - notionApiKeyHint: null, - notionDatabaseId: "", + basicAuthActive: false, } const renderPage = () => { diff --git a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx index 683191d..583c3a8 100644 --- a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx @@ -1,12 +1,12 @@ import React, { useState, useEffect } from "react" -import { useFormContext, Controller } from "react-hook-form" +import { useFormContext } 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" +import { SettingsInput } from "@client/pages/settings/components/SettingsInput" type EnvironmentSettingsSectionProps = { values: EnvSettingsValues @@ -21,8 +21,8 @@ export const EnvironmentSettingsSection: React.FC { - const { register, control, formState: { errors } } = useFormContext() - const { readable, private: privateValues, basicAuthActive } = values + const { register, formState: { errors } } = useFormContext() + const { private: privateValues, basicAuthActive } = values const [isBasicAuthEnabled, setIsBasicAuthEnabled] = useState(basicAuthActive) @@ -41,33 +41,25 @@ export const EnvironmentSettingsSection: React.FC
External Services
-
-
OpenRouter API key
- - {errors.openrouterApiKey &&

{errors.openrouterApiKey.message}

} -
- Current: {formatSecretHint(privateValues.openrouterApiKeyHint)} -
-
+ -
-
Webhook secret
- - {errors.webhookSecret &&

{errors.webhookSecret.message}

} -
- Current: {formatSecretHint(privateValues.webhookSecretHint)} -
-
+
@@ -80,56 +72,44 @@ export const EnvironmentSettingsSection: React.FC
RxResume
-
-
Email
- - {errors.rxresumeEmail &&

{errors.rxresumeEmail.message}

} -
-
-
Password
- - {errors.rxresumePassword &&

{errors.rxresumePassword.message}

} -
- Current: {formatSecretHint(privateValues.rxresumePasswordHint)} -
-
+ +
UKVisaJobs
-
-
Email
- - {errors.ukvisajobsEmail &&

{errors.ukvisajobsEmail.message}

} -
-
-
Password
- - {errors.ukvisajobsPassword &&

{errors.ukvisajobsPassword.message}

} -
- Current: {formatSecretHint(privateValues.ukvisajobsPasswordHint)} -
-
+ +
@@ -161,29 +141,23 @@ export const EnvironmentSettingsSection: React.FC -
-
Username
- - {errors.basicAuthUser &&

{errors.basicAuthUser.message}

} -
+ -
-
Password
- - {errors.basicAuthPassword &&

{errors.basicAuthPassword.message}

} -
- Current: {formatSecretHint(privateValues.basicAuthPasswordHint)} -
-
+
)} @@ -192,4 +166,3 @@ export const EnvironmentSettingsSection: React.FC ) } - diff --git a/orchestrator/src/client/pages/settings/components/GradcrackerSection.tsx b/orchestrator/src/client/pages/settings/components/GradcrackerSection.tsx index a8cdda3..aa540fc 100644 --- a/orchestrator/src/client/pages/settings/components/GradcrackerSection.tsx +++ b/orchestrator/src/client/pages/settings/components/GradcrackerSection.tsx @@ -2,10 +2,9 @@ import React from "react" import { useFormContext, Controller } from "react-hook-form" import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" -import { Input } from "@/components/ui/input" -import { Separator } from "@/components/ui/separator" import { UpdateSettingsInput } from "@shared/settings-schema" import type { NumericSettingValues } from "@client/pages/settings/types" +import { SettingsInput } from "@client/pages/settings/components/SettingsInput" type GradcrackerSectionProps = { values: NumericSettingValues @@ -28,48 +27,35 @@ export const GradcrackerSection: React.FC = ({
-
-
Max jobs per search term
- ( - { + ( + { const value = parseInt(event.target.value, 10) if (Number.isNaN(value)) { field.onChange(null) } else { field.onChange(Math.min(1000, Math.max(1, value))) } - }} - disabled={isLoading || isSaving} - /> - )} - /> - {errors.gradcrackerMaxJobsPerTerm &&

{errors.gradcrackerMaxJobsPerTerm.message}

} -
- Maximum number of jobs to fetch for EACH search term from Gradcracker. Range: 1-1000. -
-
- - - -
-
-
Effective
-
{effectiveGradcrackerMaxJobsPerTerm}
-
-
-
Default
-
{defaultGradcrackerMaxJobsPerTerm}
-
-
+ }, + }} + disabled={isLoading || isSaving} + error={errors.gradcrackerMaxJobsPerTerm?.message as string | undefined} + helper={`Maximum number of jobs to fetch for EACH search term from Gradcracker. Default: ${defaultGradcrackerMaxJobsPerTerm}. Range: 1-1000.`} + current={String(effectiveGradcrackerMaxJobsPerTerm)} + /> + )} + />
diff --git a/orchestrator/src/client/pages/settings/components/JobCompleteWebhookSection.tsx b/orchestrator/src/client/pages/settings/components/JobCompleteWebhookSection.tsx index 1d4b775..fa0c2c3 100644 --- a/orchestrator/src/client/pages/settings/components/JobCompleteWebhookSection.tsx +++ b/orchestrator/src/client/pages/settings/components/JobCompleteWebhookSection.tsx @@ -2,10 +2,9 @@ import React from "react" import { useFormContext } from "react-hook-form" import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" -import { Input } from "@/components/ui/input" -import { Separator } from "@/components/ui/separator" import { UpdateSettingsInput } from "@shared/settings-schema" import type { WebhookValues } from "@client/pages/settings/types" +import { SettingsInput } from "@client/pages/settings/components/SettingsInput" type JobCompleteWebhookSectionProps = { values: WebhookValues @@ -28,31 +27,15 @@ export const JobCompleteWebhookSection: React.FC
-
-
Job completion webhook URL
- - {errors.jobCompleteWebhookUrl &&

{errors.jobCompleteWebhookUrl.message}

} -
- When set, the server sends a POST when you mark a job as applied (includes the job description). -
-
- - - -
-
-
Effective
-
{effectiveJobCompleteWebhookUrl || "—"}
-
-
-
Default (env)
-
{defaultJobCompleteWebhookUrl || "—"}
-
-
+
diff --git a/orchestrator/src/client/pages/settings/components/JobspySection.tsx b/orchestrator/src/client/pages/settings/components/JobspySection.tsx index f004786..fa25071 100644 --- a/orchestrator/src/client/pages/settings/components/JobspySection.tsx +++ b/orchestrator/src/client/pages/settings/components/JobspySection.tsx @@ -3,10 +3,10 @@ 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 { JobspyValues } from "@client/pages/settings/types" +import { SettingsInput } from "@client/pages/settings/components/SettingsInput" type JobspySectionProps = { values: JobspyValues @@ -99,107 +99,85 @@ export const JobspySection: React.FC = ({
-
-
Location
- - {errors.jobspyLocation &&

{errors.jobspyLocation.message}

} -
- Location to search for jobs (e.g. "UK", "London", "Remote"). -
-
- Effective: {location.effective || "—"} - Default: {location.default || "—"} -
-
+ -
-
Results Wanted
- ( - { + ( + { const value = parseInt(event.target.value, 10) if (Number.isNaN(value)) { field.onChange(null) } else { field.onChange(Math.min(1000, Math.max(1, value))) } - }} - disabled={isLoading || isSaving} - /> - )} - /> - {errors.jobspyResultsWanted &&

{errors.jobspyResultsWanted.message}

} -
- Number of results to fetch per term per site. Max 1000. -
-
- Effective: {resultsWanted.effective} - Default: {resultsWanted.default} -
-
+ }, + }} + disabled={isLoading || isSaving} + error={errors.jobspyResultsWanted?.message as string | undefined} + helper={`Number of results to fetch per term per site. Default: ${resultsWanted.default}. Max 1000.`} + current={`Effective: ${resultsWanted.effective} | Default: ${resultsWanted.default}`} + /> + )} + /> -
-
Hours Old
- ( - { + ( + { const value = parseInt(event.target.value, 10) if (Number.isNaN(value)) { field.onChange(null) } else { field.onChange(Math.min(720, Math.max(1, value))) } - }} - disabled={isLoading || isSaving} - /> - )} - /> - {errors.jobspyHoursOld &&

{errors.jobspyHoursOld.message}

} -
- Max age of jobs in hours (e.g. 72 for 3 days). Max 720 (30 days). -
-
- Effective: {hoursOld.effective}h - Default: {hoursOld.default}h -
-
+ }, + }} + disabled={isLoading || isSaving} + error={errors.jobspyHoursOld?.message as string | undefined} + helper={`Max age of jobs in hours (e.g. 72 for 3 days). Default: ${hoursOld.default}. Max 720.`} + current={`Effective: ${hoursOld.effective}h | Default: ${hoursOld.default}h`} + /> + )} + /> -
-
Indeed Country
- - {errors.jobspyCountryIndeed &&

{errors.jobspyCountryIndeed.message}

} -
- Country domain for Indeed (e.g. "UK" for indeed.co.uk). -
-
- Effective: {countryIndeed.effective || "—"} - Default: {countryIndeed.default || "—"} -
-
+
diff --git a/orchestrator/src/client/pages/settings/components/ModelSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/ModelSettingsSection.tsx index b08e414..7632ee2 100644 --- a/orchestrator/src/client/pages/settings/components/ModelSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/ModelSettingsSection.tsx @@ -2,10 +2,10 @@ import React from "react" import { useFormContext } from "react-hook-form" import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" -import { Input } from "@/components/ui/input" import { Separator } from "@/components/ui/separator" import { UpdateSettingsInput } from "@shared/settings-schema" import type { ModelValues } from "@client/pages/settings/types" +import { SettingsInput } from "@client/pages/settings/components/SettingsInput" type ModelSettingsSectionProps = { values: ModelValues @@ -28,18 +28,15 @@ export const ModelSettingsSection: React.FC = ({
-
-
Override model
- - {errors.model &&

{errors.model.message}

} -
- Leave blank to use the default from server env (`MODEL`). -
-
+ @@ -47,44 +44,32 @@ export const ModelSettingsSection: React.FC = ({
Task-Specific Overrides
-
-
Scoring Model
- - {errors.modelScorer &&

{errors.modelScorer.message}

} -
- Effective: {scorer || effective} -
-
+ -
-
Tailoring Model
- - {errors.modelTailoring &&

{errors.modelTailoring.message}

} -
- Effective: {tailoring || effective} -
-
+ -
-
Project Selection Model
- - {errors.modelProjectSelection &&

{errors.modelProjectSelection.message}

} -
- Effective: {projectSelection || effective} -
-
+
diff --git a/orchestrator/src/client/pages/settings/components/PipelineWebhookSection.tsx b/orchestrator/src/client/pages/settings/components/PipelineWebhookSection.tsx index 8d92275..7339866 100644 --- a/orchestrator/src/client/pages/settings/components/PipelineWebhookSection.tsx +++ b/orchestrator/src/client/pages/settings/components/PipelineWebhookSection.tsx @@ -2,10 +2,9 @@ import React from "react" import { useFormContext } from "react-hook-form" import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" -import { Input } from "@/components/ui/input" -import { Separator } from "@/components/ui/separator" import { UpdateSettingsInput } from "@shared/settings-schema" import type { WebhookValues } from "@client/pages/settings/types" +import { SettingsInput } from "@client/pages/settings/components/SettingsInput" type PipelineWebhookSectionProps = { values: WebhookValues @@ -28,31 +27,15 @@ export const PipelineWebhookSection: React.FC = ({
-
-
Pipeline status webhook URL
- - {errors.pipelineWebhookUrl &&

{errors.pipelineWebhookUrl.message}

} -
- When set, the server sends a POST on pipeline completion/failure. Leave blank to disable. -
-
- - - -
-
-
Effective
-
{effectivePipelineWebhookUrl || "—"}
-
-
-
Default (env)
-
{defaultPipelineWebhookUrl || "—"}
-
-
+
diff --git a/orchestrator/src/client/pages/settings/components/SettingsInput.tsx b/orchestrator/src/client/pages/settings/components/SettingsInput.tsx new file mode 100644 index 0000000..bc1071b --- /dev/null +++ b/orchestrator/src/client/pages/settings/components/SettingsInput.tsx @@ -0,0 +1,39 @@ +import React from "react" + +import { Input } from "@/components/ui/input" + +type SettingsInputProps = { + label: string + inputProps: React.InputHTMLAttributes + placeholder?: string + type?: React.HTMLInputTypeAttribute + disabled?: boolean + error?: string + helper?: string + current?: string | null +} + +export const SettingsInput: React.FC = ({ + label, + inputProps, + placeholder, + type = "text", + disabled, + error, + helper, + current, +}) => { + return ( +
+
{label}
+ + {error &&

{error}

} + {helper &&
{helper}
} + {current !== undefined && ( +
+ Current: {current} +
+ )} +
+ ) +} diff --git a/orchestrator/src/client/pages/settings/components/UkvisajobsSection.tsx b/orchestrator/src/client/pages/settings/components/UkvisajobsSection.tsx index 05fce6c..fd85efe 100644 --- a/orchestrator/src/client/pages/settings/components/UkvisajobsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/UkvisajobsSection.tsx @@ -2,10 +2,9 @@ import React from "react" import { useFormContext, Controller } from "react-hook-form" import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" -import { Input } from "@/components/ui/input" -import { Separator } from "@/components/ui/separator" import { UpdateSettingsInput } from "@shared/settings-schema" import type { NumericSettingValues } from "@client/pages/settings/types" +import { SettingsInput } from "@client/pages/settings/components/SettingsInput" type UkvisajobsSectionProps = { values: NumericSettingValues @@ -28,48 +27,35 @@ export const UkvisajobsSection: React.FC = ({
-
-
Max jobs to fetch
- ( - { + ( + { const value = parseInt(event.target.value, 10) if (Number.isNaN(value)) { field.onChange(null) } else { field.onChange(Math.min(1000, Math.max(1, value))) } - }} - disabled={isLoading || isSaving} - /> - )} - /> - {errors.ukvisajobsMaxJobs &&

{errors.ukvisajobsMaxJobs.message}

} -
- Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Range: 1-1000. -
-
- - - -
-
-
Effective
-
{effectiveUkvisajobsMaxJobs}
-
-
-
Default
-
{defaultUkvisajobsMaxJobs}
-
-
+ }, + }} + disabled={isLoading || isSaving} + error={errors.ukvisajobsMaxJobs?.message as string | undefined} + helper={`Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Default: ${defaultUkvisajobsMaxJobs}. Range: 1-1000.`} + current={String(effectiveUkvisajobsMaxJobs)} + /> + )} + />
diff --git a/orchestrator/src/client/pages/settings/types.ts b/orchestrator/src/client/pages/settings/types.ts index a0b2555..c75cf80 100644 --- a/orchestrator/src/client/pages/settings/types.ts +++ b/orchestrator/src/client/pages/settings/types.ts @@ -28,8 +28,6 @@ export type EnvSettingsValues = { rxresumeEmail: string ukvisajobsEmail: string basicAuthUser: string - notionDatabaseId: string - ukvisajobsHeadless: boolean } private: { openrouterApiKeyHint: string | null diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index 0f5d687..039cca9 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -5,7 +5,6 @@ import { applyEnvValue, getEnvSettingsData, normalizeEnvInput, - serializeEnvBoolean, } from '@server/services/envSettings.js'; import { extractProjectsFromProfile, From 31dfc7afd64af49d852437129bd581783018a75d Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Thu, 22 Jan 2026 12:36:53 +0000 Subject: [PATCH 04/13] webhook sections combined --- .../src/client/pages/SettingsPage.tsx | 14 ++-- .../EnvironmentSettingsSection.test.tsx | 1 - .../components/EnvironmentSettingsSection.tsx | 10 --- .../components/JobCompleteWebhookSection.tsx | 43 ---------- .../components/PipelineWebhookSection.tsx | 43 ---------- .../components/WebhooksSection.test.tsx | 50 ++++++++++++ .../settings/components/WebhooksSection.tsx | 80 +++++++++++++++++++ 7 files changed, 135 insertions(+), 106 deletions(-) delete mode 100644 orchestrator/src/client/pages/settings/components/JobCompleteWebhookSection.tsx delete mode 100644 orchestrator/src/client/pages/settings/components/PipelineWebhookSection.tsx create mode 100644 orchestrator/src/client/pages/settings/components/WebhooksSection.test.tsx create mode 100644 orchestrator/src/client/pages/settings/components/WebhooksSection.tsx diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index cae31d2..075286a 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -16,10 +16,9 @@ import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneS 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" import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection" -import { PipelineWebhookSection } from "@client/pages/settings/components/PipelineWebhookSection" +import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection" import { ResumeProjectsSection } from "@client/pages/settings/components/ResumeProjectsSection" import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection" import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection" @@ -452,13 +451,10 @@ export const SettingsPage: React.FC = () => { isLoading={isLoading} isSaving={isSaving} /> - - diff --git a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx index ef117d6..8801d2e 100644 --- a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx +++ b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx @@ -56,7 +56,6 @@ describe("EnvironmentSettingsSection", () => { 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 set")).toBeInTheDocument() // Basic Auth diff --git a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx index 583c3a8..c7511d5 100644 --- a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx @@ -50,16 +50,6 @@ export const EnvironmentSettingsSection: React.FC - - diff --git a/orchestrator/src/client/pages/settings/components/JobCompleteWebhookSection.tsx b/orchestrator/src/client/pages/settings/components/JobCompleteWebhookSection.tsx deleted file mode 100644 index fa0c2c3..0000000 --- a/orchestrator/src/client/pages/settings/components/JobCompleteWebhookSection.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from "react" -import { useFormContext } from "react-hook-form" - -import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" -import { UpdateSettingsInput } from "@shared/settings-schema" -import type { WebhookValues } from "@client/pages/settings/types" -import { SettingsInput } from "@client/pages/settings/components/SettingsInput" - -type JobCompleteWebhookSectionProps = { - values: WebhookValues - isLoading: boolean - isSaving: boolean -} - -export const JobCompleteWebhookSection: React.FC = ({ - values, - isLoading, - isSaving, -}) => { - const { default: defaultJobCompleteWebhookUrl, effective: effectiveJobCompleteWebhookUrl } = values - const { register, formState: { errors } } = useFormContext() - - return ( - - - Job Complete Webhook - - -
- -
-
-
- ) -} diff --git a/orchestrator/src/client/pages/settings/components/PipelineWebhookSection.tsx b/orchestrator/src/client/pages/settings/components/PipelineWebhookSection.tsx deleted file mode 100644 index 7339866..0000000 --- a/orchestrator/src/client/pages/settings/components/PipelineWebhookSection.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from "react" -import { useFormContext } from "react-hook-form" - -import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" -import { UpdateSettingsInput } from "@shared/settings-schema" -import type { WebhookValues } from "@client/pages/settings/types" -import { SettingsInput } from "@client/pages/settings/components/SettingsInput" - -type PipelineWebhookSectionProps = { - values: WebhookValues - isLoading: boolean - isSaving: boolean -} - -export const PipelineWebhookSection: React.FC = ({ - values, - isLoading, - isSaving, -}) => { - const { default: defaultPipelineWebhookUrl, effective: effectivePipelineWebhookUrl } = values - const { register, formState: { errors } } = useFormContext() - - return ( - - - Pipeline Webhook - - -
- -
-
-
- ) -} diff --git a/orchestrator/src/client/pages/settings/components/WebhooksSection.test.tsx b/orchestrator/src/client/pages/settings/components/WebhooksSection.test.tsx new file mode 100644 index 0000000..1547fa2 --- /dev/null +++ b/orchestrator/src/client/pages/settings/components/WebhooksSection.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react" +import { useForm, FormProvider } from "react-hook-form" + +import { Accordion } from "@/components/ui/accordion" +import { WebhooksSection } from "./WebhooksSection" +import { UpdateSettingsInput } from "@shared/settings-schema" + +const WebhooksHarness = () => { + const methods = useForm({ + defaultValues: { + pipelineWebhookUrl: "https://pipeline.com", + jobCompleteWebhookUrl: "https://job.com", + webhookSecret: "", + } + }) + + return ( + + + + + + ) +} + +describe("WebhooksSection", () => { + it("renders both webhook sections and the secret", () => { + render() + + expect(screen.getByText("Pipeline Status")).toBeInTheDocument() + expect(screen.getByText("Job Completion")).toBeInTheDocument() + + expect(screen.getByDisplayValue("https://pipeline.com")).toBeInTheDocument() + expect(screen.getByDisplayValue("https://job.com")).toBeInTheDocument() + + expect(screen.getByText("sec-********")).toBeInTheDocument() + }) +}) diff --git a/orchestrator/src/client/pages/settings/components/WebhooksSection.tsx b/orchestrator/src/client/pages/settings/components/WebhooksSection.tsx new file mode 100644 index 0000000..5c21850 --- /dev/null +++ b/orchestrator/src/client/pages/settings/components/WebhooksSection.tsx @@ -0,0 +1,80 @@ +import React from "react" +import { useFormContext } from "react-hook-form" + +import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { Separator } from "@/components/ui/separator" +import { UpdateSettingsInput } from "@shared/settings-schema" +import type { WebhookValues } from "@client/pages/settings/types" +import { SettingsInput } from "@client/pages/settings/components/SettingsInput" + +type WebhooksSectionProps = { + pipelineWebhook: WebhookValues + jobCompleteWebhook: WebhookValues + webhookSecretHint: string | null + isLoading: boolean + isSaving: boolean +} + +export const WebhooksSection: React.FC = ({ + pipelineWebhook, + jobCompleteWebhook, + webhookSecretHint, + isLoading, + isSaving, +}) => { + const { register, formState: { errors } } = useFormContext() + + const formatSecretHint = (hint: string | null) => (hint ? `${hint}********` : "Not set") + + return ( + + + Webhooks + + +
+
+
Pipeline Status
+ +
+ + + +
+
Job Completion
+
+ + + +
+
+
+
+
+ ) +} From c416e5c7ffe50604c350852068bf5692f6566e3e Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Thu, 22 Jan 2026 12:37:16 +0000 Subject: [PATCH 05/13] better copy --- .../src/client/pages/settings/components/WebhooksSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orchestrator/src/client/pages/settings/components/WebhooksSection.tsx b/orchestrator/src/client/pages/settings/components/WebhooksSection.tsx index 5c21850..cf23bcd 100644 --- a/orchestrator/src/client/pages/settings/components/WebhooksSection.tsx +++ b/orchestrator/src/client/pages/settings/components/WebhooksSection.tsx @@ -68,7 +68,7 @@ export const WebhooksSection: React.FC = ({ placeholder="Enter new secret" disabled={isLoading || isSaving} error={errors.webhookSecret?.message as string | undefined} - helper="Secret sent to webhook" + helper="Secret sent to webhook (Bearer token)" current={formatSecretHint(webhookSecretHint)} /> From fc527b6cc8435ed74505dcb2d0cc9c6526281534 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Thu, 22 Jan 2026 12:46:43 +0000 Subject: [PATCH 06/13] checkbox didn't enable the save button, and disabling the toggle wipes the credentials in the backend upon saving --- .../src/client/pages/SettingsPage.test.tsx | 85 +++++++++++++++++++ .../src/client/pages/SettingsPage.tsx | 28 ++++-- .../EnvironmentSettingsSection.test.tsx | 7 +- .../components/EnvironmentSettingsSection.tsx | 30 ++++--- .../settings/components/SettingsInput.tsx | 10 ++- orchestrator/src/shared/settings-schema.ts | 1 + 6 files changed, 134 insertions(+), 27 deletions(-) diff --git a/orchestrator/src/client/pages/SettingsPage.test.tsx b/orchestrator/src/client/pages/SettingsPage.test.tsx index 3189857..ba334f3 100644 --- a/orchestrator/src/client/pages/SettingsPage.test.tsx +++ b/orchestrator/src/client/pages/SettingsPage.test.tsx @@ -195,4 +195,89 @@ describe("SettingsPage", () => { }) ) }) + + it("enables save button when model is changed", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings) + renderPage() + const saveButton = screen.getByRole("button", { name: /^save$/i }) + expect(saveButton).toBeDisabled() + + const modelTrigger = await screen.findByRole("button", { name: /model/i }) + fireEvent.click(modelTrigger) + const modelInput = screen.getByLabelText(/override model/i) + fireEvent.change(modelInput, { target: { value: "new-model" } }) + expect(saveButton).toBeEnabled() + }) + + it("enables save button when numeric setting is changed", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings) + renderPage() + const saveButton = screen.getByRole("button", { name: /^save$/i }) + + const visaTrigger = await screen.findByRole("button", { name: /ukvisajobs extractor/i }) + fireEvent.click(visaTrigger) + const maxJobsInput = screen.getByLabelText(/max jobs to fetch/i) + fireEvent.change(maxJobsInput, { target: { value: "100" } }) + expect(saveButton).toBeEnabled() + }) + + it("enables save button when display setting is changed", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings) + renderPage() + const saveButton = screen.getByRole("button", { name: /^save$/i }) + + const displayTrigger = await screen.findByRole("button", { name: /display settings/i }) + fireEvent.click(displayTrigger) + const sponsorCheckbox = screen.getByLabelText(/show visa sponsor information/i) + fireEvent.click(sponsorCheckbox) + expect(saveButton).toBeEnabled() + }) + + it("enables save button when basic auth toggle is changed", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings) + renderPage() + const saveButton = screen.getByRole("button", { name: /^save$/i }) + + const envTrigger = await screen.findByRole("button", { name: /environment & accounts/i }) + fireEvent.click(envTrigger) + const authCheckbox = screen.getByLabelText(/enable basic authentication/i) + fireEvent.click(authCheckbox) + expect(saveButton).toBeEnabled() + }) + + it("wipes basic auth credentials when toggle is disabled and saved", async () => { + // Initial state: Basic Auth is active + const activeSettings = { + ...baseSettings, + basicAuthActive: true, + basicAuthUser: "admin", + basicAuthPasswordHint: "pass", + } + vi.mocked(api.getSettings).mockResolvedValue(activeSettings) + vi.mocked(api.updateSettings).mockResolvedValue(baseSettings) + + renderPage() + + const envTrigger = await screen.findByRole("button", { name: /environment & accounts/i }) + fireEvent.click(envTrigger) + + const authCheckbox = screen.getByLabelText(/enable basic authentication/i) + expect(authCheckbox).toBeChecked() + + // Disable it + fireEvent.click(authCheckbox) + expect(authCheckbox).not.toBeChecked() + + const saveButton = screen.getByRole("button", { name: /^save$/i }) + expect(saveButton).toBeEnabled() + fireEvent.click(saveButton) + + await waitFor(() => expect(api.updateSettings).toHaveBeenCalled()) + expect(api.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + basicAuthUser: null, + basicAuthPassword: null, + }) + ) + }) }) diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 075286a..e995f49 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -49,6 +49,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = { ukvisajobsEmail: "", ukvisajobsPassword: "", webhookSecret: "", + enableBasicAuth: false, } const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { @@ -105,6 +106,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ ukvisajobsEmail: data.ukvisajobsEmail ?? "", ukvisajobsPassword: "", webhookSecret: "", + enableBasicAuth: data.basicAuthActive, }) const normalizeString = (value: string | null | undefined) => { @@ -284,7 +286,7 @@ export const SettingsPage: React.FC = () => { if (!settings) return try { setIsSaving(true) - + // Prepare payload: nullify if equal to default const resumeProjectsData = data.resumeProjects const resumeProjectsOverride = (resumeProjectsData && defaultResumeProjects && resumeProjectsEqual(resumeProjectsData, defaultResumeProjects)) @@ -301,8 +303,18 @@ export const SettingsPage: React.FC = () => { envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail) } - if (dirtyFields.basicAuthUser) { - envPayload.basicAuthUser = normalizeString(data.basicAuthUser) + if (data.enableBasicAuth === false) { + envPayload.basicAuthUser = null + envPayload.basicAuthPassword = null + } else { + if (dirtyFields.basicAuthUser) { + envPayload.basicAuthUser = normalizeString(data.basicAuthUser) + } + + if (dirtyFields.basicAuthPassword) { + const value = normalizePrivateInput(data.basicAuthPassword) + if (value !== undefined) envPayload.basicAuthPassword = value + } } if (dirtyFields.openrouterApiKey) { @@ -320,11 +332,6 @@ export const SettingsPage: React.FC = () => { 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 @@ -354,6 +361,11 @@ export const SettingsPage: React.FC = () => { ...envPayload, } + // Remove virtual field because the backend doesn't expect it + // this exists only to toggle the UI + // need o track it so that the save button is enabled when it changes + delete payload.enableBasicAuth + const updated = await api.updateSettings(payload) setSettings(updated) reset(mapSettingsToForm(updated)) diff --git a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx index 8801d2e..3a3b863 100644 --- a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx +++ b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx @@ -16,6 +16,7 @@ const EnvironmentSettingsHarness = () => { ukvisajobsPassword: "", basicAuthPassword: "", webhookSecret: "", + enableBasicAuth: true, } }) @@ -53,9 +54,9 @@ describe("EnvironmentSettingsSection", () => { expect(screen.getByDisplayValue("resume@example.com")).toBeInTheDocument() expect(screen.getByDisplayValue("visa@example.com")).toBeInTheDocument() - expect(screen.getByText("sk-1********")).toBeInTheDocument() - expect(screen.getByText("pass********")).toBeInTheDocument() - expect(screen.getByText("abcd********")).toBeInTheDocument() + expect(screen.getByText(/sk-1\*{8}/)).toBeInTheDocument() + expect(screen.getByText(/pass\*{8}/)).toBeInTheDocument() + expect(screen.getByText(/abcd\*{8}/)).toBeInTheDocument() expect(screen.getByText("Not set")).toBeInTheDocument() // Basic Auth diff --git a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx index c7511d5..f4bee27 100644 --- a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect } from "react" -import { useFormContext } from "react-hook-form" +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" @@ -21,14 +21,10 @@ export const EnvironmentSettingsSection: React.FC { - const { register, formState: { errors } } = useFormContext() - const { private: privateValues, basicAuthActive } = values + const { register, control, watch, formState: { errors } } = useFormContext() + const { private: privateValues } = values - const [isBasicAuthEnabled, setIsBasicAuthEnabled] = useState(basicAuthActive) - - useEffect(() => { - setIsBasicAuthEnabled(basicAuthActive) - }, [basicAuthActive]) + const isBasicAuthEnabled = watch("enableBasicAuth") return ( @@ -110,11 +106,17 @@ export const EnvironmentSettingsSection: React.FC
Security
- setIsBasicAuthEnabled(checked === true)} - disabled={isLoading || isSaving} + ( + + )} />