feat(FN-812): add first-run model onboarding modal and API key provider support
- Add ModelOnboardingModal component with provider selection, API key input, and model verification flow - Add API key authentication support to SettingsModal with clear-key functionality - Trigger onboarding from App.tsx when modelOnboardingComplete setting is false - Add comprehensive tests for ModelOnboardingModal and SettingsModal authentication - Document model onboarding flow and API key auth in AGENTS.md and README - Add CSS styles for onboarding modal, provider cards, and auth section
This commit is contained in:
@@ -713,6 +713,7 @@ When reading settings, project values override global values. The merged view is
|
|||||||
- `defaultThinkingLevel` — Default thinking effort level
|
- `defaultThinkingLevel` — Default thinking effort level
|
||||||
- `ntfyEnabled` — Enable push notifications
|
- `ntfyEnabled` — Enable push notifications
|
||||||
- `ntfyTopic` — ntfy.sh topic for notifications
|
- `ntfyTopic` — ntfy.sh topic for notifications
|
||||||
|
- `modelOnboardingComplete` — Whether first-run model/provider onboarding has been completed
|
||||||
|
|
||||||
**Project settings** (`~/.fusion/config.json`):
|
**Project settings** (`~/.fusion/config.json`):
|
||||||
- All other settings listed below (concurrency, merge, worktrees, commands, etc.)
|
- All other settings listed below (concurrency, merge, worktrees, commands, etc.)
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ fn dashboard --interactive
|
|||||||
|
|
||||||
Open [http://localhost:4040](http://localhost:4040) - create tasks from the board or the CLI.
|
Open [http://localhost:4040](http://localhost:4040) - create tasks from the board or the CLI.
|
||||||
|
|
||||||
|
**First-run setup:** On first launch, Fusion opens a guided onboarding modal that walks you through configuring provider credentials (OAuth login or API key entry) and selecting a default AI model. Completion is tracked via the `modelOnboardingComplete` global setting. You can re-trigger onboarding at any time by clearing this flag in Settings, or configure providers and models manually from the Settings modal.
|
||||||
|
|
||||||
### CLI commands
|
### CLI commands
|
||||||
|
|
||||||
**Dashboard:**
|
**Dashboard:**
|
||||||
|
|||||||
@@ -276,7 +276,8 @@ View a centralized timeline of all task lifecycle events. Click the history icon
|
|||||||
- **Error Recovery**: If settings fail to load, the modal displays an inline error message with a retry button instead of getting stuck on "Loading…"
|
- **Error Recovery**: If settings fail to load, the modal displays an inline error message with a retry button instead of getting stuck on "Loading…"
|
||||||
- **Settings API Contract**: Server-owned fields like `githubTokenConfigured` are injected on GET /settings but stripped on PUT /settings to prevent persistence to config.json
|
- **Settings API Contract**: Server-owned fields like `githubTokenConfigured` are injected on GET /settings but stripped on PUT /settings to prevent persistence to config.json
|
||||||
- **Notifications**: ntfy.sh integration for push notifications when tasks complete or fail
|
- **Notifications**: ntfy.sh integration for push notifications when tasks complete or fail
|
||||||
- **Authentication**: OAuth provider management for AI model access
|
- **Authentication**: Provider management for AI model access. OAuth providers (e.g. Anthropic) use a Login/Logout flow; API-key providers (e.g. OpenRouter) show a masked key entry with Save/Clear actions. After saving or clearing a key, the auth status refreshes immediately so the authenticated badge stays in sync. Stored key values are never prefilled or displayed.
|
||||||
|
- **Model Onboarding**: On first dashboard launch, a guided onboarding modal walks users through (1) authenticating with at least one AI provider (OAuth login or API key entry) and (2) selecting a default AI model. Completion is tracked via the `modelOnboardingComplete` global setting. Users can skip onboarding (which still marks it complete to prevent repeated popups) or re-trigger it by clearing the flag in Settings.
|
||||||
- **Pause Controls**: Soft pause (stop new work) and hard stop (kill all agents)
|
- **Pause Controls**: Soft pause (stop new work) and hard stop (kill all agents)
|
||||||
- **Theming**: Light/dark/system mode toggle and 12 color themes (see Theming section below)
|
- **Theming**: Light/dark/system mode toggle and 12 color themes (see Theming section below)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core";
|
import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core";
|
||||||
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, updateGlobalSettings, fetchModels, fetchTaskDetail, updateProject, unregisterProject } from "./api";
|
import { fetchConfig, fetchSettings, fetchAuthStatus, fetchGlobalSettings, updateSettings, updateGlobalSettings, fetchModels, fetchTaskDetail, updateProject, unregisterProject } from "./api";
|
||||||
import type { ModelInfo, ProjectInfo } from "./api";
|
import type { ModelInfo, ProjectInfo } from "./api";
|
||||||
import { Header } from "./components/Header";
|
import { Header } from "./components/Header";
|
||||||
import { Board } from "./components/Board";
|
import { Board } from "./components/Board";
|
||||||
@@ -12,6 +12,7 @@ import { TerminalModal } from "./components/TerminalModal";
|
|||||||
import { FileBrowserModal } from "./components/FileBrowserModal";
|
import { FileBrowserModal } from "./components/FileBrowserModal";
|
||||||
import { ChangedFilesModal } from "./components/ChangedFilesModal";
|
import { ChangedFilesModal } from "./components/ChangedFilesModal";
|
||||||
import { SettingsModal } from "./components/SettingsModal";
|
import { SettingsModal } from "./components/SettingsModal";
|
||||||
|
import { ModelOnboardingModal } from "./components/ModelOnboardingModal";
|
||||||
import { PlanningModeModal } from "./components/PlanningModeModal";
|
import { PlanningModeModal } from "./components/PlanningModeModal";
|
||||||
import { SubtaskBreakdownModal } from "./components/SubtaskBreakdownModal";
|
import { SubtaskBreakdownModal } from "./components/SubtaskBreakdownModal";
|
||||||
import type { SectionId } from "./components/SettingsModal";
|
import type { SectionId } from "./components/SettingsModal";
|
||||||
@@ -100,6 +101,7 @@ function AppInner() {
|
|||||||
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
|
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
|
||||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
|
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
|
||||||
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
|
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
|
||||||
|
const [modelOnboardingOpen, setModelOnboardingOpen] = useState(false);
|
||||||
|
|
||||||
// Settings state
|
// Settings state
|
||||||
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
||||||
@@ -180,9 +182,30 @@ function AppInner() {
|
|||||||
.catch(() => {/* keep default */});
|
.catch(() => {/* keep default */});
|
||||||
fetchAuthStatus()
|
fetchAuthStatus()
|
||||||
.then(({ providers }) => {
|
.then(({ providers }) => {
|
||||||
if (providers.length > 0 && providers.every((p) => !p.authenticated)) {
|
const hasAuthenticatedProvider = providers.some((p) => p.authenticated);
|
||||||
setSettingsOpen(true);
|
// Check if onboarding is needed: either no authenticated providers,
|
||||||
setSettingsInitialSection("authentication");
|
// or providers are authenticated but no default model is configured
|
||||||
|
const needsSetup = providers.length > 0 && !hasAuthenticatedProvider;
|
||||||
|
if (needsSetup || (providers.length > 0 && hasAuthenticatedProvider)) {
|
||||||
|
fetchGlobalSettings()
|
||||||
|
.then((globalSettings) => {
|
||||||
|
const hasDefaultModel = !!(globalSettings.defaultProvider && globalSettings.defaultModelId);
|
||||||
|
const setupIncomplete = !hasAuthenticatedProvider || !hasDefaultModel;
|
||||||
|
if (!globalSettings.modelOnboardingComplete && setupIncomplete) {
|
||||||
|
// First-run: show onboarding modal
|
||||||
|
setModelOnboardingOpen(true);
|
||||||
|
} else if (!hasAuthenticatedProvider) {
|
||||||
|
// Already onboarded but no auth: open settings to authentication
|
||||||
|
setSettingsOpen(true);
|
||||||
|
setSettingsInitialSection("authentication");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// If we can't fetch global settings, fall back to onboarding
|
||||||
|
if (!hasAuthenticatedProvider) {
|
||||||
|
setModelOnboardingOpen(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {/* fail silently */});
|
.catch(() => {/* fail silently */});
|
||||||
@@ -314,6 +337,10 @@ function AppInner() {
|
|||||||
refreshProjects();
|
refreshProjects();
|
||||||
}, [setCurrentProject, addToast, refreshProjects]);
|
}, [setCurrentProject, addToast, refreshProjects]);
|
||||||
|
|
||||||
|
const handleModelOnboardingComplete = useCallback(() => {
|
||||||
|
setModelOnboardingOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handlePauseProject = useCallback(async (project: ProjectInfo) => {
|
const handlePauseProject = useCallback(async (project: ProjectInfo) => {
|
||||||
try {
|
try {
|
||||||
await updateProject(project.id, { status: "paused" });
|
await updateProject(project.id, { status: "paused" });
|
||||||
@@ -822,6 +849,12 @@ function AppInner() {
|
|||||||
onClose={() => setSetupWizardOpen(false)}
|
onClose={() => setSetupWizardOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{modelOnboardingOpen && (
|
||||||
|
<ModelOnboardingModal
|
||||||
|
onComplete={handleModelOnboardingComplete}
|
||||||
|
addToast={addToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
592
packages/dashboard/app/components/ModelOnboardingModal.tsx
Normal file
592
packages/dashboard/app/components/ModelOnboardingModal.tsx
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { X, Loader2, CheckCircle, Key, Zap } from "lucide-react";
|
||||||
|
import type { AuthProvider, ModelInfo } from "../api";
|
||||||
|
import {
|
||||||
|
fetchAuthStatus,
|
||||||
|
loginProvider,
|
||||||
|
logoutProvider,
|
||||||
|
saveApiKey,
|
||||||
|
clearApiKey,
|
||||||
|
fetchModels,
|
||||||
|
updateGlobalSettings,
|
||||||
|
} from "../api";
|
||||||
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
|
|
||||||
|
export interface ModelOnboardingModalProps {
|
||||||
|
/** Called when onboarding is complete or dismissed */
|
||||||
|
onComplete: () => void;
|
||||||
|
/** Toast helper */
|
||||||
|
addToast: (message: string, type?: ToastType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OnboardingStep = "providers" | "model" | "complete";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-run onboarding modal that guides users through:
|
||||||
|
* 1. Provider credential setup (OAuth login or API key entry)
|
||||||
|
* 2. Default model selection
|
||||||
|
*
|
||||||
|
* Dismissing the modal marks onboarding as complete to prevent repeated popups.
|
||||||
|
*/
|
||||||
|
export function ModelOnboardingModal({
|
||||||
|
onComplete,
|
||||||
|
addToast,
|
||||||
|
}: ModelOnboardingModalProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
|
const [step, setStep] = useState<OnboardingStep>("providers");
|
||||||
|
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
|
||||||
|
const [authLoading, setAuthLoading] = useState(true);
|
||||||
|
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
|
||||||
|
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||||
|
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
|
||||||
|
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
|
||||||
|
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
// Load auth providers
|
||||||
|
const loadAuthStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { providers } = await fetchAuthStatus();
|
||||||
|
setAuthProviders(providers);
|
||||||
|
} catch {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load models
|
||||||
|
const loadModels = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetchModels();
|
||||||
|
setAvailableModels(response.models);
|
||||||
|
} catch {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initial data load
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([loadAuthStatus(), loadModels()]).finally(() =>
|
||||||
|
setAuthLoading(false),
|
||||||
|
);
|
||||||
|
}, [loadAuthStatus, loadModels]);
|
||||||
|
|
||||||
|
// Check if we can skip the providers step (already authenticated)
|
||||||
|
const hasAuthenticatedProvider = authProviders.some((p) => p.authenticated);
|
||||||
|
|
||||||
|
// Auto-advance to model step when provider is authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && hasAuthenticatedProvider && step === "providers") {
|
||||||
|
// Small delay to let the user see the success state
|
||||||
|
const timer = setTimeout(() => setStep("model"), 600);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [authLoading, hasAuthenticatedProvider, step]);
|
||||||
|
|
||||||
|
// Cleanup polling on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (pollIntervalRef.current) {
|
||||||
|
clearInterval(pollIntervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// OAuth login handler
|
||||||
|
const handleLogin = useCallback(
|
||||||
|
async (providerId: string) => {
|
||||||
|
setAuthActionInProgress(providerId);
|
||||||
|
try {
|
||||||
|
const { url } = await loginProvider(providerId);
|
||||||
|
window.open(url, "_blank");
|
||||||
|
|
||||||
|
// Poll for auth completion
|
||||||
|
pollIntervalRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const { providers } = await fetchAuthStatus();
|
||||||
|
setAuthProviders(providers);
|
||||||
|
const provider = providers.find((p) => p.id === providerId);
|
||||||
|
if (provider?.authenticated) {
|
||||||
|
if (pollIntervalRef.current) {
|
||||||
|
clearInterval(pollIntervalRef.current);
|
||||||
|
pollIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
setAuthActionInProgress(null);
|
||||||
|
addToast("Login successful", "success");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Continue polling
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
addToast(
|
||||||
|
err instanceof Error ? err.message : "Login failed",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
setAuthActionInProgress(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
// API key save handler
|
||||||
|
const handleSaveApiKey = useCallback(
|
||||||
|
async (providerId: string) => {
|
||||||
|
const key = apiKeyInputs[providerId]?.trim();
|
||||||
|
if (!key) {
|
||||||
|
setApiKeyErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[providerId]: "API key is required",
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAuthActionInProgress(providerId);
|
||||||
|
setApiKeyErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[providerId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await saveApiKey(providerId, key);
|
||||||
|
await loadAuthStatus();
|
||||||
|
setApiKeyInputs((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[providerId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
addToast("API key saved", "success");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
addToast(
|
||||||
|
err instanceof Error ? err.message : "Failed to save API key",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setAuthActionInProgress(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[apiKeyInputs, addToast, loadAuthStatus],
|
||||||
|
);
|
||||||
|
|
||||||
|
// API key clear handler
|
||||||
|
const handleClearApiKey = useCallback(
|
||||||
|
async (providerId: string) => {
|
||||||
|
setAuthActionInProgress(providerId);
|
||||||
|
try {
|
||||||
|
await clearApiKey(providerId);
|
||||||
|
await loadAuthStatus();
|
||||||
|
addToast("API key removed", "success");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
addToast(
|
||||||
|
err instanceof Error ? err.message : "Failed to clear API key",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setAuthActionInProgress(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addToast, loadAuthStatus],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Logout handler (for OAuth providers that are authenticated)
|
||||||
|
const handleLogout = useCallback(
|
||||||
|
async (providerId: string) => {
|
||||||
|
setAuthActionInProgress(providerId);
|
||||||
|
try {
|
||||||
|
await logoutProvider(providerId);
|
||||||
|
await loadAuthStatus();
|
||||||
|
addToast("Logged out", "success");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
addToast(
|
||||||
|
err instanceof Error ? err.message : "Logout failed",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setAuthActionInProgress(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addToast, loadAuthStatus],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle model selection from CustomModelDropdown
|
||||||
|
const handleModelSelect = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setSelectedModel(value);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Complete onboarding
|
||||||
|
const handleComplete = useCallback(async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const updates: Record<string, unknown> = {
|
||||||
|
modelOnboardingComplete: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// If a model was selected, persist it as the default
|
||||||
|
if (selectedModel) {
|
||||||
|
// Parse the provider/modelId format from CustomModelDropdown
|
||||||
|
const slashIdx = selectedModel.indexOf("/");
|
||||||
|
const provider =
|
||||||
|
slashIdx !== -1 ? selectedModel.slice(0, slashIdx) : undefined;
|
||||||
|
const modelId =
|
||||||
|
slashIdx !== -1 ? selectedModel.slice(slashIdx + 1) : selectedModel;
|
||||||
|
|
||||||
|
const model = availableModels.find((m) => m.id === modelId);
|
||||||
|
if (model) {
|
||||||
|
updates.defaultProvider = model.provider;
|
||||||
|
updates.defaultModelId = model.id;
|
||||||
|
} else if (provider && modelId) {
|
||||||
|
// Fallback: use parsed values even if not in the model list
|
||||||
|
updates.defaultProvider = provider;
|
||||||
|
updates.defaultModelId = modelId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateGlobalSettings(updates);
|
||||||
|
setStep("complete");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
addToast(
|
||||||
|
err instanceof Error ? err.message : "Failed to save settings",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [selectedModel, availableModels, addToast]);
|
||||||
|
|
||||||
|
// Dismiss without completing (still marks onboarding complete)
|
||||||
|
const handleDismiss = useCallback(async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updateGlobalSettings({ modelOnboardingComplete: true });
|
||||||
|
} catch {
|
||||||
|
// Best-effort: still close even if save fails
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
onComplete();
|
||||||
|
}, [onComplete]);
|
||||||
|
|
||||||
|
// Close from the completion step
|
||||||
|
const handleFinish = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
onComplete();
|
||||||
|
}, [onComplete]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const oauthProviders = authProviders.filter(
|
||||||
|
(p) => !p.type || p.type === "oauth",
|
||||||
|
);
|
||||||
|
const apiKeyProviders = authProviders.filter((p) => p.type === "api_key");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="modal-overlay open"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="onboarding-title"
|
||||||
|
>
|
||||||
|
<div className="modal model-onboarding-modal">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="model-onboarding-header">
|
||||||
|
<h2 id="onboarding-title" className="model-onboarding-title">
|
||||||
|
{step === "providers" && (
|
||||||
|
<>
|
||||||
|
<Zap size={24} /> Set Up AI Provider
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === "model" && (
|
||||||
|
<>
|
||||||
|
<Zap size={24} /> Choose Default Model
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === "complete" && (
|
||||||
|
<>
|
||||||
|
<CheckCircle size={24} /> All Set!
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
{step !== "complete" && (
|
||||||
|
<button
|
||||||
|
className="modal-close"
|
||||||
|
onClick={handleDismiss}
|
||||||
|
aria-label="Skip onboarding"
|
||||||
|
title="Skip for now"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step indicator */}
|
||||||
|
<div className="model-onboarding-steps">
|
||||||
|
<div
|
||||||
|
className={`model-onboarding-step-indicator${step === "providers" ? " active" : ""}${step === "model" || step === "complete" ? " done" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="step-number">1</span>
|
||||||
|
<span className="step-label">Connect Provider</span>
|
||||||
|
</div>
|
||||||
|
<div className="model-onboarding-step-connector" />
|
||||||
|
<div
|
||||||
|
className={`model-onboarding-step-indicator${step === "model" ? " active" : ""}${step === "complete" ? " done" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="step-number">2</span>
|
||||||
|
<span className="step-label">Select Model</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="model-onboarding-content">
|
||||||
|
{step === "providers" && (
|
||||||
|
<div className="model-onboarding-providers">
|
||||||
|
<p className="model-onboarding-description">
|
||||||
|
Connect at least one AI provider to start running tasks.
|
||||||
|
{oauthProviders.length > 0 &&
|
||||||
|
apiKeyProviders.length > 0 &&
|
||||||
|
" OAuth providers open a browser for login; API key providers need a key from the provider's dashboard."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{authLoading ? (
|
||||||
|
<div className="model-onboarding-loading">
|
||||||
|
<Loader2 size={24} className="animate-spin" />
|
||||||
|
<span>Loading providers…</span>
|
||||||
|
</div>
|
||||||
|
) : authProviders.length === 0 ? (
|
||||||
|
<div className="model-onboarding-empty">
|
||||||
|
No AI providers are configured. Please check your Fusion
|
||||||
|
configuration.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* OAuth Providers */}
|
||||||
|
{oauthProviders.map((provider) => (
|
||||||
|
<div key={provider.id} className="onboarding-provider-row">
|
||||||
|
<div className="onboarding-provider-info">
|
||||||
|
<strong>{provider.name}</strong>
|
||||||
|
<span
|
||||||
|
data-testid={`onboarding-auth-status-${provider.id}`}
|
||||||
|
className={`auth-status-badge ${provider.authenticated ? "authenticated" : "not-authenticated"}`}
|
||||||
|
>
|
||||||
|
{provider.authenticated
|
||||||
|
? "✓ Authenticated"
|
||||||
|
: "✗ Not authenticated"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{authActionInProgress === provider.id ? (
|
||||||
|
<button className="btn btn-sm" disabled>
|
||||||
|
{provider.authenticated
|
||||||
|
? "Logging out…"
|
||||||
|
: "Waiting for login…"}
|
||||||
|
</button>
|
||||||
|
) : provider.authenticated ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => handleLogout(provider.id)}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => handleLogin(provider.id)}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* API Key Providers */}
|
||||||
|
{apiKeyProviders.map((provider) => (
|
||||||
|
<div key={provider.id} className="onboarding-provider-row">
|
||||||
|
<div className="onboarding-provider-info">
|
||||||
|
<strong>
|
||||||
|
<Key size={14} style={{ marginRight: 4 }} />
|
||||||
|
{provider.name}
|
||||||
|
</strong>
|
||||||
|
<span
|
||||||
|
data-testid={`onboarding-auth-status-${provider.id}`}
|
||||||
|
className={`auth-status-badge ${provider.authenticated ? "authenticated" : "not-authenticated"}`}
|
||||||
|
>
|
||||||
|
{provider.authenticated
|
||||||
|
? "✓ Key saved"
|
||||||
|
: "✗ No API key"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="onboarding-apikey-actions">
|
||||||
|
{provider.authenticated ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => handleClearApiKey(provider.id)}
|
||||||
|
disabled={authActionInProgress === provider.id}
|
||||||
|
>
|
||||||
|
{authActionInProgress === provider.id
|
||||||
|
? "Removing…"
|
||||||
|
: "Remove Key"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="onboarding-apikey-input-row">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="onboarding-apikey-input"
|
||||||
|
placeholder={`Enter ${provider.name} API key`}
|
||||||
|
value={apiKeyInputs[provider.id] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setApiKeyInputs((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[provider.id]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleSaveApiKey(provider.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-testid={`onboarding-apikey-input-${provider.id}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => handleSaveApiKey(provider.id)}
|
||||||
|
disabled={
|
||||||
|
authActionInProgress === provider.id ||
|
||||||
|
!apiKeyInputs[provider.id]?.trim()
|
||||||
|
}
|
||||||
|
data-testid={`onboarding-apikey-save-${provider.id}`}
|
||||||
|
>
|
||||||
|
{authActionInProgress === provider.id
|
||||||
|
? "Saving…"
|
||||||
|
: "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{apiKeyErrors[provider.id] && (
|
||||||
|
<small className="field-error">
|
||||||
|
{apiKeyErrors[provider.id]}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "model" && (
|
||||||
|
<div className="model-onboarding-model">
|
||||||
|
<p className="model-onboarding-description">
|
||||||
|
Select the default model Fusion will use for AI tasks. You can
|
||||||
|
change this later in Settings.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{availableModels.length === 0 ? (
|
||||||
|
<div className="model-onboarding-empty">
|
||||||
|
No models available. Please check your provider configuration.
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
onClick={() => setStep("providers")}
|
||||||
|
>
|
||||||
|
← Back to Providers
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="onboarding-model-selector">
|
||||||
|
<CustomModelDropdown
|
||||||
|
models={availableModels}
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={handleModelSelect}
|
||||||
|
placeholder="Select a default model…"
|
||||||
|
label="Default model"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedModel && (
|
||||||
|
<div className="onboarding-model-preview">
|
||||||
|
<small className="settings-muted">
|
||||||
|
Selected:{" "}
|
||||||
|
{availableModels.find((m) => m.id === selectedModel)?.name ??
|
||||||
|
selectedModel}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "complete" && (
|
||||||
|
<div className="model-onboarding-complete">
|
||||||
|
<CheckCircle size={48} className="success-icon" />
|
||||||
|
<p>
|
||||||
|
You're ready to start using Fusion! You can always change your
|
||||||
|
model and provider settings from the Settings panel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="model-onboarding-footer">
|
||||||
|
{step === "providers" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={handleDismiss}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
// Fetch models now that provider is authenticated
|
||||||
|
loadModels();
|
||||||
|
setStep("model");
|
||||||
|
}}
|
||||||
|
disabled={!hasAuthenticatedProvider}
|
||||||
|
>
|
||||||
|
Continue →
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "model" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => setStep("providers")}
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleComplete}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
<span>Saving…</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Complete Setup"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "complete" && (
|
||||||
|
<button className="btn btn-primary" onClick={handleFinish}>
|
||||||
|
<CheckCircle size={16} />
|
||||||
|
<span>Get Started</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { THINKING_LEVELS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "@fusion/core";
|
import { THINKING_LEVELS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "@fusion/core";
|
||||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent } from "@fusion/core";
|
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent } from "@fusion/core";
|
||||||
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings } from "../api";
|
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings } from "../api";
|
||||||
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData } from "../api";
|
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData } from "../api";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { ThemeSelector } from "./ThemeSelector";
|
import { ThemeSelector } from "./ThemeSelector";
|
||||||
@@ -86,6 +86,8 @@ export function SettingsModal({
|
|||||||
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
|
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
|
||||||
const [authLoading, setAuthLoading] = useState(false);
|
const [authLoading, setAuthLoading] = useState(false);
|
||||||
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
|
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
|
||||||
|
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
|
||||||
|
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
|
||||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
// Model state
|
// Model state
|
||||||
@@ -200,7 +202,7 @@ export function SettingsModal({
|
|||||||
addToast(err.message || "Login failed", "error");
|
addToast(err.message || "Login failed", "error");
|
||||||
setAuthActionInProgress(null);
|
setAuthActionInProgress(null);
|
||||||
}
|
}
|
||||||
}, [addToast, loadAuthStatus]);
|
}, [addToast]);
|
||||||
|
|
||||||
const handleLogout = useCallback(async (providerId: string) => {
|
const handleLogout = useCallback(async (providerId: string) => {
|
||||||
setAuthActionInProgress(providerId);
|
setAuthActionInProgress(providerId);
|
||||||
@@ -215,6 +217,57 @@ export function SettingsModal({
|
|||||||
}
|
}
|
||||||
}, [addToast, loadAuthStatus]);
|
}, [addToast, loadAuthStatus]);
|
||||||
|
|
||||||
|
const handleSaveApiKey = useCallback(async (providerId: string) => {
|
||||||
|
const key = apiKeyInputs[providerId]?.trim();
|
||||||
|
if (!key) {
|
||||||
|
setApiKeyErrors((prev) => ({ ...prev, [providerId]: "API key is required" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAuthActionInProgress(providerId);
|
||||||
|
setApiKeyErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[providerId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await saveApiKey(providerId, key);
|
||||||
|
setApiKeyInputs((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[providerId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await loadAuthStatus();
|
||||||
|
addToast("API key saved", "success");
|
||||||
|
} catch (err: any) {
|
||||||
|
setApiKeyErrors((prev) => ({ ...prev, [providerId]: err.message || "Failed to save API key" }));
|
||||||
|
} finally {
|
||||||
|
setAuthActionInProgress(null);
|
||||||
|
}
|
||||||
|
}, [apiKeyInputs, addToast, loadAuthStatus]);
|
||||||
|
|
||||||
|
const handleClearApiKey = useCallback(async (providerId: string) => {
|
||||||
|
setAuthActionInProgress(providerId);
|
||||||
|
try {
|
||||||
|
await clearApiKey(providerId);
|
||||||
|
setApiKeyInputs((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[providerId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setApiKeyErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[providerId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await loadAuthStatus();
|
||||||
|
addToast("API key cleared", "success");
|
||||||
|
} catch (err: any) {
|
||||||
|
addToast(err.message || "Failed to clear API key", "error");
|
||||||
|
} finally {
|
||||||
|
setAuthActionInProgress(null);
|
||||||
|
}
|
||||||
|
}, [addToast, loadAuthStatus]);
|
||||||
|
|
||||||
const handleTestNotification = useCallback(async () => {
|
const handleTestNotification = useCallback(async () => {
|
||||||
// Validate ntfy is enabled and topic is valid
|
// Validate ntfy is enabled and topic is valid
|
||||||
if (!form.ntfyEnabled || !form.ntfyTopic || !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)) {
|
if (!form.ntfyEnabled || !form.ntfyTopic || !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)) {
|
||||||
@@ -1614,7 +1667,7 @@ export function SettingsModal({
|
|||||||
<div className="settings-empty-state">Loading authentication status…</div>
|
<div className="settings-empty-state">Loading authentication status…</div>
|
||||||
) : authProviders.length === 0 ? (
|
) : authProviders.length === 0 ? (
|
||||||
<div className="settings-empty-state settings-muted">
|
<div className="settings-empty-state settings-muted">
|
||||||
No OAuth providers available
|
No providers available
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1634,33 +1687,71 @@ export function SettingsModal({
|
|||||||
{provider.authenticated ? "✓ Authenticated" : "✗ Not authenticated"}
|
{provider.authenticated ? "✓ Authenticated" : "✗ Not authenticated"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{provider.type === "api_key" ? (
|
||||||
{authActionInProgress === provider.id ? (
|
<div className="auth-apikey-section">
|
||||||
<button className="btn btn-sm" disabled>
|
<div className="auth-apikey-input-row">
|
||||||
{provider.authenticated ? "Logging out…" : "Waiting for login…"}
|
<input
|
||||||
</button>
|
type="password"
|
||||||
) : provider.authenticated ? (
|
className="auth-apikey-input"
|
||||||
<button
|
placeholder="Enter API key"
|
||||||
className="btn btn-sm"
|
value={apiKeyInputs[provider.id] ?? ""}
|
||||||
onClick={() => handleLogout(provider.id)}
|
onChange={(e) => setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))}
|
||||||
>
|
disabled={authActionInProgress === provider.id}
|
||||||
Logout
|
/>
|
||||||
</button>
|
{provider.authenticated ? (
|
||||||
) : (
|
<button
|
||||||
<button
|
className="btn btn-sm"
|
||||||
className="btn btn-primary btn-sm"
|
onClick={() => handleClearApiKey(provider.id)}
|
||||||
onClick={() => handleLogin(provider.id)}
|
disabled={authActionInProgress === provider.id}
|
||||||
>
|
>
|
||||||
Login
|
Clear
|
||||||
</button>
|
</button>
|
||||||
)}
|
) : (
|
||||||
</div>
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => handleSaveApiKey(provider.id)}
|
||||||
|
disabled={authActionInProgress === provider.id}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{authActionInProgress === provider.id && (
|
||||||
|
<small className="auth-apikey-progress">Saving…</small>
|
||||||
|
)}
|
||||||
|
{apiKeyErrors[provider.id] && (
|
||||||
|
<small className="auth-apikey-error">{apiKeyErrors[provider.id]}</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{authActionInProgress === provider.id ? (
|
||||||
|
<button className="btn btn-sm" disabled>
|
||||||
|
{provider.authenticated ? "Logging out…" : "Waiting for login…"}
|
||||||
|
</button>
|
||||||
|
) : provider.authenticated ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => handleLogout(provider.id)}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => handleLogin(provider.id)}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<small className="auth-hint">
|
<small className="auth-hint">
|
||||||
Login and logout take effect immediately — no need to save.
|
Authentication changes take effect immediately — no need to save.
|
||||||
</small>
|
</small>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ vi.mock("../../api", async (importOriginal) => {
|
|||||||
fetchConfig: vi.fn(() => Promise.resolve({ maxConcurrent: 2 })),
|
fetchConfig: vi.fn(() => Promise.resolve({ maxConcurrent: 2 })),
|
||||||
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||||
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||||
|
fetchGlobalSettings: vi.fn(() => Promise.resolve({})),
|
||||||
fetchAuthStatus: vi.fn(() =>
|
fetchAuthStatus: vi.fn(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -105,7 +106,7 @@ vi.mock("../../hooks/useTerminal", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { App } from "../../App";
|
import { App } from "../../App";
|
||||||
import { fetchAuthStatus, fetchSettings, fetchTaskDetail, updateSettings, runScript, fetchScripts } from "../../api";
|
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, updateSettings, runScript, fetchScripts } from "../../api";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -513,10 +514,30 @@ describe("App mission wiring", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("App auto-open Settings on unauthenticated", () => {
|
describe("App auto-open Settings on unauthenticated", () => {
|
||||||
it("auto-opens Settings to Authentication tab when all providers are unauthenticated", async () => {
|
it("auto-opens onboarding modal when all providers are unauthenticated and onboarding not complete", async () => {
|
||||||
|
// fetchGlobalSettings returns {} by default (modelOnboardingComplete is undefined)
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
// Wait for the auth status check and global settings check
|
||||||
|
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||||||
|
await waitFor(() => expect(fetchGlobalSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
// The onboarding modal should be open showing the provider step
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Set Up AI Provider")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Settings modal should NOT be open
|
||||||
|
expect(screen.queryByText("Settings")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-opens Settings to Authentication tab when all providers are unauthenticated but onboarding IS complete", async () => {
|
||||||
|
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
modelOnboardingComplete: true,
|
||||||
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
// Wait for the auth status check and settings modal to appear
|
|
||||||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||||||
|
|
||||||
// The Settings modal should be open showing Authentication content
|
// The Settings modal should be open showing Authentication content
|
||||||
@@ -524,34 +545,59 @@ describe("App auto-open Settings on unauthenticated", () => {
|
|||||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(2));
|
||||||
|
|
||||||
// Authentication section should be active — auth status is fetched when section is active
|
// Authentication section should be active — auth status is fetched when section is active
|
||||||
// Wait for the auth providers to appear
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Anthropic")).toBeTruthy();
|
expect(screen.getByText("Anthropic")).toBeTruthy();
|
||||||
});
|
});
|
||||||
expect(screen.getByText("GitHub")).toBeTruthy();
|
expect(screen.getByText("GitHub")).toBeTruthy();
|
||||||
|
|
||||||
// General section should NOT be showing
|
// Onboarding modal should NOT be open
|
||||||
expect(screen.queryByLabelText("Task Prefix")).toBeNull();
|
expect(screen.queryByText("Set Up AI Provider")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT auto-open Settings when at least one provider is authenticated", async () => {
|
it("does NOT auto-open anything when at least one provider is authenticated and default model is set", async () => {
|
||||||
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
providers: [
|
providers: [
|
||||||
{ id: "anthropic", name: "Anthropic", authenticated: true },
|
{ id: "anthropic", name: "Anthropic", authenticated: true },
|
||||||
{ id: "github", name: "GitHub", authenticated: false },
|
{ id: "github", name: "GitHub", authenticated: false },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
defaultProvider: "anthropic",
|
||||||
|
defaultModelId: "claude-sonnet-4-5",
|
||||||
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||||||
|
|
||||||
// Settings modal should NOT be open — no modal overlay
|
// Settings modal should NOT be open
|
||||||
// fetchSettings called once by App useEffect only (not by SettingsModal)
|
|
||||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
// No settings modal content
|
|
||||||
expect(screen.queryByText("Settings")).toBeNull();
|
expect(screen.queryByText("Settings")).toBeNull();
|
||||||
|
|
||||||
|
// Onboarding modal should NOT be open
|
||||||
|
expect(screen.queryByText("Set Up AI Provider")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-opens onboarding when providers are authenticated but default model is missing", async () => {
|
||||||
|
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "anthropic", name: "Anthropic", authenticated: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// No defaultProvider or defaultModelId → setup incomplete
|
||||||
|
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
modelOnboardingComplete: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||||||
|
await waitFor(() => expect(fetchGlobalSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
// Onboarding modal should be open
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Set Up AI Provider")).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT auto-open Settings when fetchAuthStatus fails", async () => {
|
it("does NOT auto-open Settings when fetchAuthStatus fails", async () => {
|
||||||
@@ -564,37 +610,35 @@ describe("App auto-open Settings on unauthenticated", () => {
|
|||||||
|
|
||||||
// Settings modal should NOT be open
|
// Settings modal should NOT be open
|
||||||
expect(screen.queryByText("Settings")).toBeNull();
|
expect(screen.queryByText("Settings")).toBeNull();
|
||||||
|
// Onboarding modal should NOT be open
|
||||||
|
expect(screen.queryByText("Set Up AI Provider")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("re-opening Settings via gear icon defaults to General tab after auto-opened close", async () => {
|
it("re-opening Settings via gear icon defaults to General tab after closing onboarding", async () => {
|
||||||
|
// fetchGlobalSettings returns {} by default → onboarding opens
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
// Wait for auto-open
|
// Wait for onboarding to auto-open
|
||||||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(2));
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Anthropic")).toBeTruthy();
|
expect(screen.getByText("Set Up AI Provider")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Authentication auto-open should not render General fields yet
|
// Dismiss the onboarding modal via Skip for now button
|
||||||
expect(screen.queryByLabelText("Task Prefix")).toBeNull();
|
fireEvent.click(screen.getByText("Skip for now"));
|
||||||
|
|
||||||
// Close the auto-opened settings modal via Cancel button
|
// Onboarding modal should be closed
|
||||||
fireEvent.click(screen.getByText("Cancel"));
|
|
||||||
|
|
||||||
// Settings modal should be closed
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByText("Anthropic")).toBeNull();
|
expect(screen.queryByText("Set Up AI Provider")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Open settings again via the gear icon button
|
// Open settings via the gear icon button
|
||||||
const settingsButton = screen.getByTitle("Settings");
|
const settingsButton = screen.getByTitle("Settings");
|
||||||
fireEvent.click(settingsButton);
|
fireEvent.click(settingsButton);
|
||||||
|
|
||||||
// Now it should open to General section (default)
|
// Now it should open to General section (default)
|
||||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(3));
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(2));
|
||||||
expect(screen.getByLabelText("Task Prefix")).toBeTruthy();
|
expect(screen.getByLabelText("Task Prefix")).toBeTruthy();
|
||||||
expect(screen.queryByText("Anthropic")).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,450 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||||
|
import { ModelOnboardingModal } from "../ModelOnboardingModal";
|
||||||
|
import type { AuthProvider } from "../../api";
|
||||||
|
|
||||||
|
// Mock the API module
|
||||||
|
const mockFetchAuthStatus = vi.fn();
|
||||||
|
const mockLoginProvider = vi.fn();
|
||||||
|
const mockLogoutProvider = vi.fn();
|
||||||
|
const mockSaveApiKey = vi.fn();
|
||||||
|
const mockClearApiKey = vi.fn();
|
||||||
|
const mockFetchModels = vi.fn();
|
||||||
|
const mockUpdateGlobalSettings = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({
|
||||||
|
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
|
||||||
|
loginProvider: (...args: unknown[]) => mockLoginProvider(...args),
|
||||||
|
logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args),
|
||||||
|
saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args),
|
||||||
|
clearApiKey: (...args: unknown[]) => mockClearApiKey(...args),
|
||||||
|
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
|
||||||
|
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock CustomModelDropdown since it has complex portal behavior
|
||||||
|
vi.mock("../CustomModelDropdown", () => ({
|
||||||
|
CustomModelDropdown: ({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) => (
|
||||||
|
<select
|
||||||
|
data-testid="mock-model-dropdown"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{placeholder ?? "Select…"}</option>
|
||||||
|
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
|
||||||
|
<option value="openai/gpt-4o">GPT-4o</option>
|
||||||
|
</select>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultAuthProviders: AuthProvider[] = [
|
||||||
|
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
|
||||||
|
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultModels = [
|
||||||
|
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: false, contextWindow: 200000 },
|
||||||
|
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockFetchAuthStatus.mockResolvedValue({ providers: defaultAuthProviders });
|
||||||
|
mockFetchModels.mockResolvedValue({ models: defaultModels, favoriteProviders: [], favoriteModels: [] });
|
||||||
|
mockUpdateGlobalSettings.mockResolvedValue({});
|
||||||
|
mockLoginProvider.mockResolvedValue({ url: "https://auth.example.com/login" });
|
||||||
|
mockLogoutProvider.mockResolvedValue({ success: true });
|
||||||
|
mockSaveApiKey.mockResolvedValue({ success: true });
|
||||||
|
mockClearApiKey.mockResolvedValue({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ModelOnboardingModal", () => {
|
||||||
|
describe("provider step", () => {
|
||||||
|
it("renders the provider step by default", async () => {
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Set Up AI Provider")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Connect Provider")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Select Model")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows OAuth providers with Login button", async () => {
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Anthropic")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("✗ Not authenticated")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Login")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows API key providers with key input", async () => {
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("OpenAI")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("onboarding-apikey-input-openai")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("onboarding-apikey-save-openai")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables Continue button when no providers are authenticated", async () => {
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Continue →")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Continue →").closest("button")?.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initiates OAuth login when Login is clicked", async () => {
|
||||||
|
const mockWindowOpen = vi.fn();
|
||||||
|
vi.spyOn(window, "open").mockImplementation(mockWindowOpen);
|
||||||
|
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Login")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Login"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockLoginProvider).toHaveBeenCalledWith("anthropic");
|
||||||
|
expect(mockWindowOpen).toHaveBeenCalledWith("https://auth.example.com/login", "_blank");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves API key when Save is clicked", async () => {
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("onboarding-apikey-input-openai")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByTestId("onboarding-apikey-input-openai");
|
||||||
|
fireEvent.change(input, { target: { value: "sk-test-key-123" } });
|
||||||
|
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSaveApiKey).toHaveBeenCalledWith("openai", "sk-test-key-123");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Save button as disabled when API key input is empty", async () => {
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("onboarding-apikey-save-openai")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveBtn = screen.getByTestId("onboarding-apikey-save-openai") as HTMLButtonElement;
|
||||||
|
expect(saveBtn.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears API key when Remove Key is clicked for authenticated provider", async () => {
|
||||||
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
|
||||||
|
{ id: "openai", name: "OpenAI", authenticated: true, type: "api_key" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("✓ Key saved")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Remove Key"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockClearApiKey).toHaveBeenCalledWith("openai");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT render API key values in the DOM", async () => {
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("onboarding-apikey-input-openai")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByTestId("onboarding-apikey-input-openai") as HTMLInputElement;
|
||||||
|
// Input should be empty initially (never prefilled)
|
||||||
|
expect(input.value).toBe("");
|
||||||
|
expect(input.type).toBe("password");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("model selection step", () => {
|
||||||
|
it("advances to model step when Continue is clicked with authenticated provider", async () => {
|
||||||
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Continue →")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for auto-advance or click Continue
|
||||||
|
// The auto-advance happens after 600ms, but we can also click
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Choose Default Model")).toBeTruthy();
|
||||||
|
}, { timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows model dropdown on the model step", async () => {
|
||||||
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Choose Default Model")).toBeTruthy();
|
||||||
|
}, { timeout: 3000 });
|
||||||
|
|
||||||
|
expect(screen.getByTestId("mock-model-dropdown")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows model selection", async () => {
|
||||||
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Choose Default Model")).toBeTruthy();
|
||||||
|
}, { timeout: 3000 });
|
||||||
|
|
||||||
|
const dropdown = screen.getByTestId("mock-model-dropdown");
|
||||||
|
fireEvent.change(dropdown, { target: { value: "anthropic/claude-sonnet-4-5" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Claude Sonnet 4\.5/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows going back to provider step", async () => {
|
||||||
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Choose Default Model")).toBeTruthy();
|
||||||
|
}, { timeout: 3000 });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("← Back"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Set Up AI Provider")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("completion", () => {
|
||||||
|
it("saves model selection and marks onboarding complete", async () => {
|
||||||
|
const onComplete = vi.fn();
|
||||||
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ModelOnboardingModal onComplete={onComplete} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
// Wait for auto-advance to model step
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Choose Default Model")).toBeTruthy();
|
||||||
|
}, { timeout: 3000 });
|
||||||
|
|
||||||
|
// Select a model
|
||||||
|
const dropdown = screen.getByTestId("mock-model-dropdown");
|
||||||
|
fireEvent.change(dropdown, { target: { value: "anthropic/claude-sonnet-4-5" } });
|
||||||
|
|
||||||
|
// Click Complete Setup
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Complete Setup")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Complete Setup"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
modelOnboardingComplete: true,
|
||||||
|
defaultProvider: "anthropic",
|
||||||
|
defaultModelId: "claude-sonnet-4-5",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should show completion screen
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("All Set!")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click Get Started to close
|
||||||
|
fireEvent.click(screen.getByText("Get Started"));
|
||||||
|
expect(onComplete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes without model selection", async () => {
|
||||||
|
const onComplete = vi.fn();
|
||||||
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ModelOnboardingModal onComplete={onComplete} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
// Wait for auto-advance
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Choose Default Model")).toBeTruthy();
|
||||||
|
}, { timeout: 3000 });
|
||||||
|
|
||||||
|
// Click Complete Setup without selecting a model
|
||||||
|
fireEvent.click(screen.getByText("Complete Setup"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
modelOnboardingComplete: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dismiss / skip", () => {
|
||||||
|
it("marks onboarding complete when dismissed via X button", async () => {
|
||||||
|
const onComplete = vi.fn();
|
||||||
|
|
||||||
|
render(<ModelOnboardingModal onComplete={onComplete} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Set Up AI Provider")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click the X close button
|
||||||
|
const closeBtn = screen.getByLabelText("Skip onboarding");
|
||||||
|
fireEvent.click(closeBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({
|
||||||
|
modelOnboardingComplete: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onComplete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks onboarding complete when Skip for now is clicked", async () => {
|
||||||
|
const onComplete = vi.fn();
|
||||||
|
|
||||||
|
render(<ModelOnboardingModal onComplete={onComplete} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Skip for now")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Skip for now"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({
|
||||||
|
modelOnboardingComplete: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onComplete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still calls onComplete even if global settings save fails", async () => {
|
||||||
|
const onComplete = vi.fn();
|
||||||
|
mockUpdateGlobalSettings.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
|
||||||
|
render(<ModelOnboardingModal onComplete={onComplete} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Skip for now")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Skip for now"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onComplete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("shows empty state when no providers are configured", async () => {
|
||||||
|
mockFetchAuthStatus.mockResolvedValueOnce({ providers: [] });
|
||||||
|
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/No AI providers are configured/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows empty state when no models are available on model step", async () => {
|
||||||
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
mockFetchModels.mockResolvedValueOnce({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||||
|
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Choose Default Model")).toBeTruthy();
|
||||||
|
}, { timeout: 3000 });
|
||||||
|
|
||||||
|
expect(screen.getByText(/No models available/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles auth status fetch failure gracefully", async () => {
|
||||||
|
mockFetchAuthStatus.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Should still render the modal without crashing
|
||||||
|
expect(screen.getByText("Set Up AI Provider")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading state while fetching providers", () => {
|
||||||
|
// Make the fetch hang
|
||||||
|
mockFetchAuthStatus.mockReturnValue(new Promise(() => {}));
|
||||||
|
mockFetchModels.mockReturnValue(new Promise(() => {}));
|
||||||
|
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Loading providers…")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,6 +33,8 @@ vi.mock("../../api", () => ({
|
|||||||
fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }] })),
|
fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }] })),
|
||||||
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
|
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
|
||||||
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
|
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
|
||||||
|
saveApiKey: vi.fn(() => Promise.resolve({ success: true })),
|
||||||
|
clearApiKey: vi.fn(() => Promise.resolve({ success: true })),
|
||||||
fetchModels: vi.fn(() => Promise.resolve({
|
fetchModels: vi.fn(() => Promise.resolve({
|
||||||
models: [
|
models: [
|
||||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
||||||
@@ -44,7 +46,7 @@ vi.mock("../../api", () => ({
|
|||||||
testNtfyNotification: vi.fn(() => Promise.resolve({ success: true })),
|
testNtfyNotification: vi.fn(() => Promise.resolve({ success: true })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification } from "../../api";
|
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification } from "../../api";
|
||||||
|
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
const addToast = vi.fn();
|
const addToast = vi.fn();
|
||||||
@@ -997,6 +999,257 @@ describe("SettingsModal", () => {
|
|||||||
expect(screen.getByText("GitHub")).toBeTruthy();
|
expect(screen.getByText("GitHub")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- API key provider tests ---
|
||||||
|
|
||||||
|
it("renders password input and Save button for api_key providers", async () => {
|
||||||
|
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Authentication"));
|
||||||
|
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText("Enter API key") as HTMLInputElement;
|
||||||
|
expect(input).toBeTruthy();
|
||||||
|
expect(input.type).toBe("password");
|
||||||
|
|
||||||
|
// The Save button should be inside the auth-apikey-section, not the global save
|
||||||
|
const apiKeySection = container.querySelector(".auth-apikey-section");
|
||||||
|
expect(apiKeySection).toBeTruthy();
|
||||||
|
expect(apiKeySection!.querySelector("button")!.textContent?.trim()).toBe("Save");
|
||||||
|
|
||||||
|
// Should NOT show Login button for api_key providers
|
||||||
|
expect(screen.queryByText("Login")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Clear button for authenticated api_key providers", async () => {
|
||||||
|
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Authentication"));
|
||||||
|
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||||||
|
|
||||||
|
// The Clear button should be inside the auth-apikey-section
|
||||||
|
const apiKeySection = container.querySelector(".auth-apikey-section");
|
||||||
|
expect(apiKeySection).toBeTruthy();
|
||||||
|
expect(apiKeySection!.querySelector("button")!.textContent?.trim()).toBe("Clear");
|
||||||
|
|
||||||
|
// Should NOT show Logout button for api_key providers
|
||||||
|
expect(screen.queryByText("Logout")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves API key when Save is clicked", async () => {
|
||||||
|
|
||||||
|
|
||||||
|
(fetchAuthStatus as ReturnType<typeof vi.fn>)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Authentication"));
|
||||||
|
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText("Enter API key");
|
||||||
|
fireEvent.change(input, { target: { value: "sk-test-key-123" } });
|
||||||
|
|
||||||
|
const apiKeySection = container.querySelector(".auth-apikey-section")!;
|
||||||
|
fireEvent.click(apiKeySection.querySelector("button")!);
|
||||||
|
|
||||||
|
await waitFor(() => expect(saveApiKey).toHaveBeenCalledWith("openrouter", "sk-test-key-123"));
|
||||||
|
expect(addToast).toHaveBeenCalledWith("API key saved", "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error when saving empty API key", async () => {
|
||||||
|
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Authentication"));
|
||||||
|
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const apiKeySection = container.querySelector(".auth-apikey-section")!;
|
||||||
|
fireEvent.click(apiKeySection.querySelector("button")!);
|
||||||
|
|
||||||
|
expect(screen.getByText("API key is required")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears API key when Clear is clicked", async () => {
|
||||||
|
|
||||||
|
|
||||||
|
(fetchAuthStatus as ReturnType<typeof vi.fn>)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" as const },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Authentication"));
|
||||||
|
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const apiKeySection = container.querySelector(".auth-apikey-section")!;
|
||||||
|
fireEvent.click(apiKeySection.querySelector("button")!);
|
||||||
|
|
||||||
|
await waitFor(() => expect(clearApiKey).toHaveBeenCalledWith("openrouter"));
|
||||||
|
expect(addToast).toHaveBeenCalledWith("API key cleared", "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles API key save error gracefully", async () => {
|
||||||
|
|
||||||
|
(saveApiKey as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
|
||||||
|
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Authentication"));
|
||||||
|
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText("Enter API key");
|
||||||
|
fireEvent.change(input, { target: { value: "sk-key" } });
|
||||||
|
|
||||||
|
const apiKeySection = container.querySelector(".auth-apikey-section")!;
|
||||||
|
fireEvent.click(apiKeySection.querySelector("button")!);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("Network error")).toBeTruthy());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows input field after clearing API key (badge updates to not authenticated)", async () => {
|
||||||
|
|
||||||
|
|
||||||
|
(fetchAuthStatus as ReturnType<typeof vi.fn>)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" as const },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Authentication"));
|
||||||
|
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||||||
|
|
||||||
|
// Initially should show "Clear" button (authenticated)
|
||||||
|
expect(container.querySelector(".auth-apikey-section button")?.textContent).toContain("Clear");
|
||||||
|
|
||||||
|
// Click Clear
|
||||||
|
fireEvent.click(container.querySelector(".auth-apikey-section button")!);
|
||||||
|
|
||||||
|
await waitFor(() => expect(clearApiKey).toHaveBeenCalledWith("openrouter"));
|
||||||
|
|
||||||
|
// After clearing, fetchAuthStatus should be called again (loadAuthStatus)
|
||||||
|
// and the UI should show an input field (not authenticated state)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(container.querySelector(".auth-apikey-section input")).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(addToast).toHaveBeenCalledWith("API key cleared", "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles API key clear error gracefully", async () => {
|
||||||
|
|
||||||
|
|
||||||
|
(clearApiKey as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Clear failed"));
|
||||||
|
|
||||||
|
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Authentication"));
|
||||||
|
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const apiKeySection = container.querySelector(".auth-apikey-section")!;
|
||||||
|
fireEvent.click(apiKeySection.querySelector("button")!);
|
||||||
|
|
||||||
|
await waitFor(() => expect(clearApiKey).toHaveBeenCalledWith("openrouter"));
|
||||||
|
expect(addToast).toHaveBeenCalledWith("Clear failed", "error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Login for oauth providers and password input for api_key providers side by side", async () => {
|
||||||
|
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" as const },
|
||||||
|
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Authentication"));
|
||||||
|
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||||||
|
|
||||||
|
// OAuth provider shows Login
|
||||||
|
expect(screen.getByText("Login")).toBeTruthy();
|
||||||
|
// API key provider shows password input
|
||||||
|
expect(screen.getByPlaceholderText("Enter API key")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not prefill stored key values in the input", async () => {
|
||||||
|
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Authentication"));
|
||||||
|
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText("Enter API key") as HTMLInputElement;
|
||||||
|
expect(input.value).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
// --- Mobile structure tests (DOM classes that responsive CSS targets) ---
|
// --- Mobile structure tests (DOM classes that responsive CSS targets) ---
|
||||||
|
|
||||||
it("has .settings-layout wrapping sidebar and content", async () => {
|
it("has .settings-layout wrapping sidebar and content", async () => {
|
||||||
|
|||||||
@@ -2844,6 +2844,45 @@ body {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
.auth-apikey-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.auth-apikey-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.auth-apikey-input {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
width: 200px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.auth-apikey-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||||||
|
}
|
||||||
|
.auth-apikey-input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.auth-apikey-progress {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
.auth-apikey-error {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-error);
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
.settings-empty-state {
|
.settings-empty-state {
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -17373,3 +17412,239 @@ html .column.drag-over * {
|
|||||||
padding: var(--space-sm) 0;
|
padding: var(--space-sm) 0;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Model Onboarding Modal ===== */
|
||||||
|
|
||||||
|
.model-onboarding-modal {
|
||||||
|
max-width: 560px;
|
||||||
|
width: 90vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-onboarding-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 24px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-onboarding-header .modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-onboarding-header .modal-close:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-onboarding-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-onboarding-title svg {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step indicator */
|
||||||
|
.model-onboarding-steps {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-onboarding-step-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-onboarding-step-indicator.active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-onboarding-step-indicator.active .step-number {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-onboarding-step-indicator.done .step-number {
|
||||||
|
background: var(--success, #22c55e);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-onboarding-step-indicator .step-number {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-onboarding-step-indicator .step-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-onboarding-step-connector {
|
||||||
|
width: 40px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content area */
|
||||||
|
.model-onboarding-content {
|
||||||
|
padding: 20px 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-onboarding-description {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-onboarding-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-onboarding-empty {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-onboarding-providers {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Provider rows in onboarding */
|
||||||
|
.onboarding-provider-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-provider-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-provider-info strong {
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-apikey-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-apikey-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-apikey-input {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
width: 220px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-apikey-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Model selector in onboarding */
|
||||||
|
.onboarding-model-selector {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-model-preview {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Complete step */
|
||||||
|
.model-onboarding-complete {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-onboarding-complete .success-icon {
|
||||||
|
color: var(--success, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-onboarding-complete p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 380px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.model-onboarding-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-onboarding-footer .btn-primary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user