Jobber/orchestrator/src/client/components/OnboardingGate.tsx
Shaheer Sarfaraz b18c2eccbb
Code cleanup (#218)
* chore: move @types/canvas-confetti to devDependencies, remove unused get-tsconfig direct dep

* chore: configure knip with workspace entry points for all packages

* refactor(shared): split 1119-line types.ts into domain modules under types/

* refactor: remove llm-service.ts shim, migrate all import sites to llm/service directly

* refactor(settings): migrate 4 manually-resolved settings into conversion registry

* refactor: split gmail-sync.ts into gmail-api, email-router, and thin orchestrator

* refactor(orchestrator): extract useKeyboardShortcuts and usePipelineControls from OrchestratorPage

Splits the 840-line OrchestratorPage into a thin orchestration shell (~480 lines) by
extracting keyboard shortcut handling into useKeyboardShortcuts.ts and pipeline
control logic into usePipelineControls.ts. Net negative line count across all files.

* feat: create settings registry (Step 1)

Introduces a single source of truth for all settings, combining schema definitions, default logic, parsing, and serialization into a single configuration object.

* feat: derive schema, keys, and types from settings registry (Step 2)

Derives AppSettings nested shape, SettingKey DB union, and updateSettingsSchema Zod shape automatically from the settings registry.

* refactor: gut envSettings and remove settings-conversion (Step 3)

Replaces manual env arrays with registry-driven maps in envSettings.ts.
Deletes settings-conversion.ts since all parsing/defaults now live in the registry.

* refactor: simplify getEffectiveSettings with generic loop (Step 4)

Replaces ~334 lines of manual key-by-key unpacking with a generic registry-driven iteration loop (~40 lines). Models, typed, string, and virtual kinds are automatically derived.

* refactor: simplify settingsUpdateRegistry (Step 5)

Replaces ~350 lines of explicit per-key update handlers with a dynamic generic loop over the settings registry, properly routing persistence and side effects.

* refactor(settings): implement nested settings registry and clean up tests

- Migrate settings system to use a centralized nested registry (`settings-schema.ts`, `registry.ts`)
- Remove obsolete flat-to-nested conversion logic (`settings-conversion.ts`)
- Address Biome warnings by explicitly ignoring intentional `any` usage in generic runtime schema builder and registry logic
- Clean up unused variables in test files (`SettingsPage.test.tsx`) to achieve a 100% green CI pipeline

* refactor(settings): address PR comments on env data and registry parsing

- Narrow `getEnvSettingsData` return type to `Partial<AppSettings>` to satisfy strict typing and omit 'typed' registry entries
- Introduce `parseNonEmptyStringOrNull` for typed string settings so empty-string overrides cleanly fall back to defaults (matching original `||` logic)
- Add missing unit tests for registry parse/serialize helpers (JSON, bools, numeric clamping)
2026-02-21 03:07:51 +00:00

793 lines
26 KiB
TypeScript

import * as api from "@client/api";
import { useDemoInfo } from "@client/hooks/useDemoInfo";
import { useSettings } from "@client/hooks/useSettings";
import { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection";
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import {
formatSecretHint,
getLlmProviderConfig,
LLM_PROVIDER_LABELS,
LLM_PROVIDERS,
normalizeLlmProvider,
} from "@client/pages/settings/utils";
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import type { ValidationResult } from "@shared/types.js";
import { Check } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldTitle,
} from "@/components/ui/field";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
type ValidationState = ValidationResult & { checked: boolean };
type OnboardingFormData = {
llmProvider: string;
llmBaseUrl: string;
llmApiKey: string;
rxresumeEmail: string;
rxresumePassword: string;
rxresumeBaseResumeId: string | null;
};
function getStepPrimaryLabel(input: {
currentStep: string | null;
llmValidated: boolean;
rxresumeValidated: boolean;
baseResumeValidated: boolean;
}): string {
const toLabel = (isValidated: boolean): string =>
isValidated ? "Revalidate" : "Validate";
if (input.currentStep === "llm") return toLabel(input.llmValidated);
if (input.currentStep === "rxresume") return toLabel(input.rxresumeValidated);
if (input.currentStep === "baseresume")
return toLabel(input.baseResumeValidated);
return "Validate";
}
export const OnboardingGate: React.FC = () => {
const {
settings,
isLoading: settingsLoading,
refreshSettings,
} = useSettings();
const [isSavingEnv, setIsSavingEnv] = useState(false);
const [isValidatingLlm, setIsValidatingLlm] = useState(false);
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false);
const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false);
const [llmValidation, setLlmValidation] = useState<ValidationState>({
valid: false,
message: null,
checked: false,
});
const [rxresumeValidation, setRxresumeValidation] = useState<ValidationState>(
{
valid: false,
message: null,
checked: false,
},
);
const [baseResumeValidation, setBaseResumeValidation] =
useState<ValidationState>({
valid: false,
message: null,
checked: false,
});
const [currentStep, setCurrentStep] = useState<string | null>(null);
const demoInfo = useDemoInfo();
const demoMode = demoInfo?.demoMode ?? false;
const { control, watch, getValues, reset, setValue } =
useForm<OnboardingFormData>({
defaultValues: {
llmProvider: "",
llmBaseUrl: "",
llmApiKey: "",
rxresumeEmail: "",
rxresumePassword: "",
rxresumeBaseResumeId: null,
},
});
const llmProvider = watch("llmProvider");
const validateLlm = useCallback(async () => {
const values = getValues();
const selectedProvider = normalizeLlmProvider(
values.llmProvider || settings?.llmProvider?.value || "openrouter",
);
const providerConfig = getLlmProviderConfig(selectedProvider);
const { requiresApiKey, showBaseUrl } = providerConfig;
setIsValidatingLlm(true);
try {
const result = await api.validateLlm({
provider: selectedProvider,
baseUrl: showBaseUrl
? values.llmBaseUrl.trim() || undefined
: undefined,
apiKey: requiresApiKey
? values.llmApiKey.trim() || undefined
: undefined,
});
setLlmValidation({ ...result, checked: true });
return result;
} catch (error) {
const message =
error instanceof Error ? error.message : "LLM validation failed";
const result = { valid: false, message };
setLlmValidation({ ...result, checked: true });
return result;
} finally {
setIsValidatingLlm(false);
}
}, [getValues, settings?.llmProvider]);
const validateRxresume = useCallback(async () => {
const values = getValues();
setIsValidatingRxresume(true);
try {
const result = await api.validateRxresume(
values.rxresumeEmail.trim() || undefined,
values.rxresumePassword.trim() || undefined,
);
setRxresumeValidation({ ...result, checked: true });
return result;
} catch (error) {
const message =
error instanceof Error ? error.message : "RxResume validation failed";
const result = { valid: false, message };
setRxresumeValidation({ ...result, checked: true });
return result;
} finally {
setIsValidatingRxresume(false);
}
}, [getValues]);
const validateBaseResume = useCallback(async () => {
setIsValidatingBaseResume(true);
try {
const result = await api.validateResumeConfig();
setBaseResumeValidation({ ...result, checked: true });
return result;
} catch (error) {
const message =
error instanceof Error
? error.message
: "Base resume validation failed";
const result = { valid: false, message };
setBaseResumeValidation({ ...result, checked: true });
return result;
} finally {
setIsValidatingBaseResume(false);
}
}, []);
const selectedProvider = normalizeLlmProvider(
llmProvider || settings?.llmProvider?.value || "openrouter",
);
const providerConfig = getLlmProviderConfig(selectedProvider);
const {
normalizedProvider,
showApiKey,
showBaseUrl,
requiresApiKey: requiresLlmKey,
} = providerConfig;
const llmKeyHint = settings?.llmApiKeyHint ?? null;
const hasLlmKey = Boolean(llmKeyHint);
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim());
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint);
const hasCheckedValidations =
(requiresLlmKey ? llmValidation.checked : true) &&
rxresumeValidation.checked &&
baseResumeValidation.checked;
const llmValidated = requiresLlmKey ? llmValidation.valid : true;
const shouldOpen =
!demoMode &&
Boolean(settings && !settingsLoading) &&
hasCheckedValidations &&
!(llmValidated && rxresumeValidation.valid && baseResumeValidation.valid);
const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim()
? settings.rxresumeEmail
: undefined;
const rxresumePasswordCurrent = settings?.rxresumePasswordHint
? formatSecretHint(settings?.rxresumePasswordHint)
: undefined;
// Initialize form values from settings
useEffect(() => {
if (settings) {
reset({
llmProvider: settings.llmProvider?.value || "",
llmBaseUrl: settings.llmBaseUrl?.value || "",
llmApiKey: "",
rxresumeEmail: "",
rxresumePassword: "",
rxresumeBaseResumeId: settings.rxresumeBaseResumeId || null,
});
}
}, [settings, reset]);
// Clear base URL when provider doesn't require it
useEffect(() => {
if (!showBaseUrl) {
setValue("llmBaseUrl", "");
}
}, [showBaseUrl, setValue]);
// Reset LLM validation when provider changes
useEffect(() => {
if (!selectedProvider) return;
setLlmValidation({ valid: false, message: null, checked: false });
}, [selectedProvider]);
const steps = useMemo(
() => [
{
id: "llm",
label: "LLM Provider",
subtitle: "Provider + credentials",
complete: llmValidated,
disabled: false,
},
{
id: "rxresume",
label: "Connect Reactive Resume",
subtitle: "Reactive Resume login",
complete: rxresumeValidation.valid,
disabled: false,
},
{
id: "baseresume",
label: "Select Template Resume",
subtitle: "Template selection",
complete: baseResumeValidation.valid,
disabled: !rxresumeValidation.valid,
},
],
[llmValidated, rxresumeValidation.valid, baseResumeValidation.valid],
);
const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id;
useEffect(() => {
if (!shouldOpen) return;
if (!currentStep && defaultStep) {
setCurrentStep(defaultStep);
}
}, [currentStep, defaultStep, shouldOpen]);
const runAllValidations = useCallback(async () => {
if (!settings) return;
const validations: Promise<ValidationResult>[] = [];
if (requiresLlmKey) {
validations.push(validateLlm());
} else {
setLlmValidation({ valid: true, message: null, checked: true });
}
validations.push(validateRxresume(), validateBaseResume());
const results = await Promise.allSettled(validations);
const failed = results.find((result) => result.status === "rejected");
if (failed) {
const reason = failed.status === "rejected" ? failed.reason : null;
const message =
reason instanceof Error ? reason.message : "Validation checks failed";
toast.error(message);
}
}, [
settings,
requiresLlmKey,
validateLlm,
validateRxresume,
validateBaseResume,
]);
// Run validations on mount when needed
useEffect(() => {
if (demoMode) return;
if (!settings || settingsLoading) return;
const needsValidation =
(requiresLlmKey ? !llmValidation.checked : false) ||
!rxresumeValidation.checked ||
!baseResumeValidation.checked;
if (!needsValidation) return;
void runAllValidations();
}, [
settings,
settingsLoading,
requiresLlmKey,
llmValidation.checked,
rxresumeValidation.checked,
baseResumeValidation.checked,
runAllValidations,
demoMode,
]);
const handleRefresh = async () => {
const results = await Promise.allSettled([
refreshSettings(),
runAllValidations(),
]);
const failed = results.find((result) => result.status === "rejected");
if (failed) {
const reason = failed.status === "rejected" ? failed.reason : null;
const message =
reason instanceof Error ? reason.message : "Failed to refresh setup";
toast.error(message);
}
};
const handleSaveLlm = async (): Promise<boolean> => {
const values = getValues();
const apiKeyValue = values.llmApiKey.trim();
const baseUrlValue = values.llmBaseUrl.trim();
if (requiresLlmKey && !apiKeyValue && !hasLlmKey) {
toast.info("Add your LLM API key to continue");
return false;
}
try {
const validation = requiresLlmKey
? await validateLlm()
: { valid: true, message: null };
if (!validation.valid) {
toast.error(validation.message || "LLM validation failed");
return false;
}
const update: Partial<UpdateSettingsInput> = {
llmProvider: normalizedProvider,
llmBaseUrl: showBaseUrl ? baseUrlValue || null : null,
};
if (showApiKey && apiKeyValue) {
update.llmApiKey = apiKeyValue;
}
setIsSavingEnv(true);
await api.updateSettings(update);
await refreshSettings();
setValue("llmApiKey", "");
toast.success("LLM provider connected");
return true;
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to save LLM settings";
toast.error(message);
return false;
} finally {
setIsSavingEnv(false);
}
};
const handleSaveRxresume = async (): Promise<boolean> => {
const values = getValues();
const emailValue = values.rxresumeEmail.trim();
const passwordValue = values.rxresumePassword.trim();
const missing: string[] = [];
if (!hasRxresumeEmail && !emailValue) missing.push("RxResume email");
if (!hasRxresumePassword && !passwordValue)
missing.push("RxResume password");
if (missing.length > 0) {
toast.info("Almost there", {
description: `Missing: ${missing.join(", ")}`,
});
return false;
}
try {
const validation = await validateRxresume();
if (!validation.valid) {
toast.error(validation.message || "RxResume validation failed");
return false;
}
const update: { rxresumeEmail?: string; rxresumePassword?: string } = {};
if (emailValue) update.rxresumeEmail = emailValue;
if (passwordValue) update.rxresumePassword = passwordValue;
if (Object.keys(update).length > 0) {
setIsSavingEnv(true);
await api.updateSettings(update);
await refreshSettings();
setValue("rxresumePassword", "");
}
toast.success("RxResume connected");
return true;
} catch (error) {
const message =
error instanceof Error
? error.message
: "Failed to save RxResume credentials";
toast.error(message);
return false;
} finally {
setIsSavingEnv(false);
}
};
const handleSaveBaseResume = async (): Promise<boolean> => {
const values = getValues();
if (!values.rxresumeBaseResumeId) {
toast.info("Select a base resume to continue");
return false;
}
try {
setIsSavingEnv(true);
await api.updateSettings({
rxresumeBaseResumeId: values.rxresumeBaseResumeId,
});
const validation = await validateBaseResume();
if (!validation.valid) {
toast.error(validation.message || "Base resume validation failed");
return false;
}
await refreshSettings();
toast.success("Base resume set");
return true;
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to save base resume";
toast.error(message);
return false;
} finally {
setIsSavingEnv(false);
}
};
const resolvedStepIndex = currentStep
? steps.findIndex((step) => step.id === currentStep)
: 0;
const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0;
const completedSteps = steps.filter((step) => step.complete).length;
const progressValue =
steps.length > 0 ? Math.round((completedSteps / steps.length) * 100) : 0;
const isBusy =
isSavingEnv ||
settingsLoading ||
isValidatingLlm ||
isValidatingRxresume ||
isValidatingBaseResume;
const canGoBack = stepIndex > 0;
const primaryLabel = getStepPrimaryLabel({
currentStep,
llmValidated,
rxresumeValidated: rxresumeValidation.valid,
baseResumeValidated: baseResumeValidation.valid,
});
const handlePrimaryAction = async () => {
if (!currentStep) return;
if (currentStep === "llm") {
await handleSaveLlm();
return;
}
if (currentStep === "rxresume") {
await handleSaveRxresume();
return;
}
if (currentStep === "baseresume") {
await handleSaveBaseResume();
return;
}
};
const handleBack = () => {
if (!canGoBack) return;
setCurrentStep(steps[stepIndex - 1]?.id ?? currentStep);
};
if (!shouldOpen || !currentStep) return null;
return (
<AlertDialog open>
<AlertDialogContent
className="max-w-3xl max-h-[90vh] overflow-hidden p-0"
onEscapeKeyDown={(event) => event.preventDefault()}
>
<div className="space-y-6 px-6 py-6 max-h-[calc(90vh-3.5rem)] overflow-y-auto">
<AlertDialogHeader>
<AlertDialogTitle>Welcome to Job Ops</AlertDialogTitle>
<AlertDialogDescription>
Let's get your workspace ready. Add your keys and resume once,
then the pipeline can run end-to-end.
</AlertDialogDescription>
</AlertDialogHeader>
<Tabs value={currentStep} onValueChange={setCurrentStep}>
<TabsList className="grid h-auto w-full grid-cols-1 gap-2 border-b border-border/60 bg-transparent p-0 text-left sm:grid-cols-3">
{steps.map((step, index) => {
const isActive = step.id === currentStep;
const isComplete = step.complete;
return (
<FieldLabel
key={step.id}
className={cn(
"w-full [&>[data-slot=field]]:border-0 [&>[data-slot=field]]:p-0 [&>[data-slot=field]]:rounded-none",
step.disabled && "opacity-50 cursor-not-allowed",
)}
>
<TabsTrigger
value={step.id}
disabled={step.disabled}
className={cn(
"w-full rounded-md hover:bg-muted/60 border-b-2 border-transparent px-3 py-4 text-left shadow-none",
isActive
? "border-primary !bg-muted/60 text-foreground"
: "text-muted-foreground",
)}
>
<Field orientation="horizontal" className="items-start">
<FieldContent>
<FieldTitle>{step.label}</FieldTitle>
<FieldDescription>{step.subtitle}</FieldDescription>
</FieldContent>
<span
className={cn(
"mt-0.5 flex h-6 w-6 items-center justify-center rounded-md text-xs font-semibold",
isComplete
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
>
{isComplete ? (
<Check className="h-3.5 w-3.5" />
) : (
index + 1
)}
</span>
</Field>
</TabsTrigger>
</FieldLabel>
);
})}
</TabsList>
<TabsContent value="llm" className="space-y-4 pt-6">
<div>
<p className="text-sm font-semibold">Connect LLM provider</p>
<p className="text-xs text-muted-foreground">
Used for job scoring, summaries, and tailoring.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label htmlFor="llmProvider" className="text-sm font-medium">
Provider
</label>
<Controller
name="llmProvider"
control={control}
render={({ field }) => (
<Select
value={selectedProvider}
onValueChange={(value) => {
field.onChange(value);
}}
disabled={isSavingEnv}
>
<SelectTrigger id="llmProvider">
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{LLM_PROVIDERS.map((provider) => (
<SelectItem key={provider} value={provider}>
{LLM_PROVIDER_LABELS[provider]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">
{providerConfig.providerHint}
</p>
</div>
{showBaseUrl && (
<Controller
name="llmBaseUrl"
control={control}
render={({ field }) => (
<SettingsInput
label="LLM base URL"
inputProps={{
name: "llmBaseUrl",
value: field.value,
onChange: field.onChange,
}}
placeholder={providerConfig.baseUrlPlaceholder}
helper={providerConfig.baseUrlHelper}
current={settings?.llmBaseUrl?.value || "—"}
disabled={isSavingEnv}
/>
)}
/>
)}
{showApiKey && (
<Controller
name="llmApiKey"
control={control}
render={({ field }) => (
<SettingsInput
label="LLM API key"
inputProps={{
name: "llmApiKey",
value: field.value,
onChange: field.onChange,
}}
type="password"
placeholder="Enter key"
helper={
llmKeyHint
? `${providerConfig.keyHelper}. Leave blank to use the saved key.`
: providerConfig.keyHelper
}
disabled={isSavingEnv}
/>
)}
/>
)}
</div>
</TabsContent>
<TabsContent value="rxresume" className="space-y-4 pt-6">
<div>
<p className="text-sm font-semibold">
Link your RxResume account
</p>
<p className="text-xs text-muted-foreground">
Used to export tailored PDFs. Create an account{" "}
<a
className="underline underline-offset-2"
href="https://v4.rxresu.me/auth/register"
target="_blank"
rel="noreferrer"
>
here
</a>{" "}
on RxResume v4 using email/password.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Controller
name="rxresumeEmail"
control={control}
render={({ field }) => (
<SettingsInput
label="Email"
inputProps={{
name: "rxresumeEmail",
value: field.value,
onChange: field.onChange,
}}
placeholder="you@example.com"
current={rxresumeEmailCurrent}
disabled={isSavingEnv}
/>
)}
/>
<Controller
name="rxresumePassword"
control={control}
render={({ field }) => (
<SettingsInput
label="Password"
inputProps={{
name: "rxresumePassword",
value: field.value,
onChange: field.onChange,
}}
type="password"
placeholder="Enter password"
current={rxresumePasswordCurrent}
disabled={isSavingEnv}
/>
)}
/>
</div>
</TabsContent>
<TabsContent value="baseresume" className="space-y-4 pt-6">
<div>
<p className="text-sm font-semibold">
Select your template resume
</p>
<p className="text-xs text-muted-foreground">
Choose the resume you want to use as a template. The selected
resume will be used as a template for tailoring.
</p>
</div>
<Controller
name="rxresumeBaseResumeId"
control={control}
render={({ field }) => (
<BaseResumeSelection
value={field.value}
onValueChange={field.onChange}
hasRxResumeAccess={rxresumeValidation.valid}
disabled={isSavingEnv}
/>
)}
/>
</TabsContent>
</Tabs>
<div className="flex items-center justify-between">
<Button
variant="outline"
onClick={handleBack}
disabled={!canGoBack || isBusy}
>
Back
</Button>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={handleRefresh} disabled={isBusy}>
Refresh status
</Button>
<Button onClick={handlePrimaryAction} disabled={isBusy}>
{isBusy ? "Working..." : primaryLabel}
</Button>
</div>
</div>
<Progress value={progressValue} className="h-2" />
<div className="rounded-lg border border-muted bg-muted/30 p-3 text-xs text-muted-foreground">
Friendly heads-up: pipelines can be slow or a little flaky in alpha.
If anything feels off, open a GitHub issue and we will take a look.{" "}
<a
className="font-semibold text-foreground underline underline-offset-2"
href="https://github.com/DaKheera47/job-ops/issues"
target="_blank"
rel="noreferrer"
>
Open an issue
</a>
.
</div>
</div>
</AlertDialogContent>
</AlertDialog>
);
};