feat(FN-1935): add API key validation feedback to onboarding modal
- Add provider-specific API key format rules, display names, and fallback validation hints - Render inline format hints plus error/success feedback in the API key form - Add styled input states for error/success and success message auto-clear timers - Expand onboarding modal tests to cover validation, network/server errors, success lifecycle, and skip flow behavior
This commit is contained in:
@@ -103,6 +103,78 @@ const PROVIDER_INFO: Record<string, ProviderInfo> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PROVIDER_KEY_HINTS: Record<string, {
|
||||||
|
pattern: RegExp;
|
||||||
|
hint: string;
|
||||||
|
example: string;
|
||||||
|
}> = {
|
||||||
|
anthropic: { pattern: /^sk-ant-/, hint: "Starts with sk-ant-", example: "sk-ant-api03-..." },
|
||||||
|
openai: { pattern: /^sk-/, hint: "Starts with sk-", example: "sk-..." },
|
||||||
|
"openai-codex": { pattern: /^sk-/, hint: "Starts with sk-", example: "sk-..." },
|
||||||
|
openrouter: { pattern: /^sk-or-/, hint: "Starts with sk-or-", example: "sk-or-v1-..." },
|
||||||
|
google: { pattern: /^AIza/, hint: "Starts with AIza", example: "AIza..." },
|
||||||
|
gemini: { pattern: /^AIza/, hint: "Starts with AIza", example: "AIza..." },
|
||||||
|
minimax: { pattern: /^.{8,}$/, hint: "At least 8 characters", example: "..." },
|
||||||
|
ollama: { pattern: /^.+$/, hint: "Any non-empty value", example: "ollama" },
|
||||||
|
zai: { pattern: /^.{8,}$/, hint: "At least 8 characters", example: "..." },
|
||||||
|
kimi: { pattern: /^.{8,}$/, hint: "At least 8 characters", example: "..." },
|
||||||
|
"kimi-coding": { pattern: /^.{8,}$/, hint: "At least 8 characters", example: "..." },
|
||||||
|
moonshot: { pattern: /^.{8,}$/, hint: "At least 8 characters", example: "..." },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROVIDER_KEY_HINTS_FALLBACK = {
|
||||||
|
pattern: /^.{8,}$/,
|
||||||
|
hint: "At least 8 characters",
|
||||||
|
example: "...",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||||
|
anthropic: "Anthropic",
|
||||||
|
openai: "OpenAI",
|
||||||
|
"openai-codex": "OpenAI Codex",
|
||||||
|
openrouter: "OpenRouter",
|
||||||
|
google: "Google",
|
||||||
|
gemini: "Gemini",
|
||||||
|
minimax: "MiniMax",
|
||||||
|
ollama: "Ollama",
|
||||||
|
zai: "Zhipu AI",
|
||||||
|
kimi: "Kimi",
|
||||||
|
"kimi-coding": "Kimi Coding",
|
||||||
|
moonshot: "Moonshot",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getProviderDisplayName(providerId: string): string {
|
||||||
|
if (PROVIDER_DISPLAY_NAMES[providerId]) {
|
||||||
|
return PROVIDER_DISPLAY_NAMES[providerId];
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = providerId.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return "This provider";
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
.split(/[-_\s]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateApiKeyFormat(providerId: string, key: string): string | null {
|
||||||
|
const trimmedKey = key.trim();
|
||||||
|
if (!trimmedKey) {
|
||||||
|
return "API key is required";
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerHint = PROVIDER_KEY_HINTS[providerId] ?? PROVIDER_KEY_HINTS_FALLBACK;
|
||||||
|
if (providerHint.pattern.test(trimmedKey)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerName = getProviderDisplayName(providerId);
|
||||||
|
return `${providerName} keys should follow this format: ${providerHint.hint} (e.g. ${providerHint.example})`;
|
||||||
|
}
|
||||||
|
|
||||||
const API_KEY_INFO_FALLBACK: ApiKeyInfo = {
|
const API_KEY_INFO_FALLBACK: ApiKeyInfo = {
|
||||||
fieldLabel: "API Key",
|
fieldLabel: "API Key",
|
||||||
setupInstructions: "Enter your API key for this provider.",
|
setupInstructions: "Enter your API key for this provider.",
|
||||||
@@ -169,6 +241,7 @@ interface ApiKeyEntryFormProps {
|
|||||||
inputValue: string;
|
inputValue: string;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
success?: string | null;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
onInputChange: (providerId: string, key: string) => void;
|
onInputChange: (providerId: string, key: string) => void;
|
||||||
onSave: (providerId: string, key: string) => void | Promise<void>;
|
onSave: (providerId: string, key: string) => void | Promise<void>;
|
||||||
@@ -181,6 +254,7 @@ function ApiKeyEntryForm({
|
|||||||
inputValue,
|
inputValue,
|
||||||
isSaving,
|
isSaving,
|
||||||
error,
|
error,
|
||||||
|
success,
|
||||||
isConnected,
|
isConnected,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
onSave,
|
onSave,
|
||||||
@@ -188,6 +262,10 @@ function ApiKeyEntryForm({
|
|||||||
}: ApiKeyEntryFormProps) {
|
}: ApiKeyEntryFormProps) {
|
||||||
const inputId = `onboarding-apikey-input-${provider.id}`;
|
const inputId = `onboarding-apikey-input-${provider.id}`;
|
||||||
const saveDisabled = isSaving || !inputValue.trim();
|
const saveDisabled = isSaving || !inputValue.trim();
|
||||||
|
const providerKeyHint = PROVIDER_KEY_HINTS[provider.id];
|
||||||
|
const inputClassName = `input onboarding-apikey-input${
|
||||||
|
error ? " onboarding-apikey-input--error" : ""
|
||||||
|
}${success ? " onboarding-apikey-input--success" : ""}`;
|
||||||
|
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
return (
|
return (
|
||||||
@@ -218,7 +296,7 @@ function ApiKeyEntryForm({
|
|||||||
<input
|
<input
|
||||||
id={inputId}
|
id={inputId}
|
||||||
type="password"
|
type="password"
|
||||||
className="input onboarding-apikey-input"
|
className={inputClassName}
|
||||||
placeholder={apiKeyInfo.inputPlaceholder ?? "Enter API key"}
|
placeholder={apiKeyInfo.inputPlaceholder ?? "Enter API key"}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => onInputChange(provider.id, e.target.value)}
|
onChange={(e) => onInputChange(provider.id, e.target.value)}
|
||||||
@@ -238,7 +316,20 @@ function ApiKeyEntryForm({
|
|||||||
{isSaving ? "Saving…" : "Save"}
|
{isSaving ? "Saving…" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{providerKeyHint && (
|
||||||
|
<small className="onboarding-apikey-hint">
|
||||||
|
Format: {providerKeyHint.hint}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
{error && <small className="field-error">{error}</small>}
|
{error && <small className="field-error">{error}</small>}
|
||||||
|
{success && !error && (
|
||||||
|
<small
|
||||||
|
className="onboarding-apikey-success"
|
||||||
|
data-testid={`onboarding-apikey-success-${provider.id}`}
|
||||||
|
>
|
||||||
|
{success}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
<p className="onboarding-apikey-instructions">{apiKeyInfo.setupInstructions}</p>
|
<p className="onboarding-apikey-instructions">{apiKeyInfo.setupInstructions}</p>
|
||||||
{apiKeyInfo.dashboardUrl && (
|
{apiKeyInfo.dashboardUrl && (
|
||||||
<a
|
<a
|
||||||
@@ -323,6 +414,8 @@ export function ModelOnboardingModal({
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
|
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
|
||||||
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
|
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
|
||||||
|
const [apiKeySuccess, setApiKeySuccess] = useState<Record<string, string | null>>({});
|
||||||
|
const apiKeySuccessTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const [loginOutcomes, setLoginOutcomes] = useState<Record<string, LoginOutcome>>({});
|
const [loginOutcomes, setLoginOutcomes] = useState<Record<string, LoginOutcome>>({});
|
||||||
const [isGithubSkipped, setIsGithubSkipped] = useState<boolean>(() => {
|
const [isGithubSkipped, setIsGithubSkipped] = useState<boolean>(() => {
|
||||||
@@ -575,6 +668,8 @@ export function ModelOnboardingModal({
|
|||||||
if (pollIntervalRef.current) {
|
if (pollIntervalRef.current) {
|
||||||
clearInterval(pollIntervalRef.current);
|
clearInterval(pollIntervalRef.current);
|
||||||
}
|
}
|
||||||
|
Object.values(apiKeySuccessTimers.current).forEach(clearTimeout);
|
||||||
|
apiKeySuccessTimers.current = {};
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -714,6 +809,13 @@ export function ModelOnboardingModal({
|
|||||||
...prev,
|
...prev,
|
||||||
[providerId]: value,
|
[providerId]: value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const successTimer = apiKeySuccessTimers.current[providerId];
|
||||||
|
if (successTimer) {
|
||||||
|
clearTimeout(successTimer);
|
||||||
|
delete apiKeySuccessTimers.current[providerId];
|
||||||
|
}
|
||||||
|
|
||||||
setApiKeyErrors((prev) => {
|
setApiKeyErrors((prev) => {
|
||||||
if (!prev[providerId]) {
|
if (!prev[providerId]) {
|
||||||
return prev;
|
return prev;
|
||||||
@@ -722,39 +824,108 @@ export function ModelOnboardingModal({
|
|||||||
delete next[providerId];
|
delete next[providerId];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setApiKeySuccess((prev) => {
|
||||||
|
if (!prev[providerId]) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[providerId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// API key save handler
|
// API key save handler
|
||||||
const handleSaveApiKey = useCallback(
|
const handleSaveApiKey = useCallback(
|
||||||
async (providerId: string, keyValue?: string) => {
|
async (providerId: string, keyValue?: string) => {
|
||||||
const key = (keyValue ?? apiKeyInputs[providerId] ?? "").trim();
|
const key = (keyValue ?? apiKeyInputs[providerId] ?? "").trim();
|
||||||
if (!key) {
|
const validationError = validateApiKeyFormat(providerId, key);
|
||||||
|
if (validationError) {
|
||||||
setApiKeyErrors((prev) => ({
|
setApiKeyErrors((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[providerId]: "API key is required",
|
[providerId]: validationError,
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingTimer = apiKeySuccessTimers.current[providerId];
|
||||||
|
if (existingTimer) {
|
||||||
|
clearTimeout(existingTimer);
|
||||||
|
delete apiKeySuccessTimers.current[providerId];
|
||||||
|
}
|
||||||
|
|
||||||
setAuthActionInProgress(providerId);
|
setAuthActionInProgress(providerId);
|
||||||
setApiKeyErrors((prev) => {
|
setApiKeyErrors((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[providerId];
|
delete next[providerId];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setApiKeySuccess((prev) => {
|
||||||
|
if (!prev[providerId]) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[providerId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await saveApiKey(providerId, key);
|
await saveApiKey(providerId, key);
|
||||||
await loadAuthStatus();
|
await loadAuthStatus();
|
||||||
|
|
||||||
setApiKeyInputs((prev) => {
|
setApiKeyInputs((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[providerId];
|
delete next[providerId];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setApiKeyErrors((prev) => {
|
||||||
|
if (!prev[providerId]) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[providerId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setApiKeySuccess((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[providerId]: "✓ Key saved",
|
||||||
|
}));
|
||||||
|
|
||||||
|
apiKeySuccessTimers.current[providerId] = setTimeout(() => {
|
||||||
|
setApiKeySuccess((prev) => {
|
||||||
|
if (!prev[providerId]) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[providerId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
delete apiKeySuccessTimers.current[providerId];
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
addToast("API key saved", "success");
|
addToast("API key saved", "success");
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
addToast(
|
const errorMessage =
|
||||||
err instanceof Error ? err.message : "Failed to save API key",
|
err instanceof TypeError && err.message.includes("Failed to fetch")
|
||||||
"error",
|
? "Could not reach the server. Check your connection and try again."
|
||||||
);
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to save API key";
|
||||||
|
|
||||||
|
setApiKeyErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[providerId]: errorMessage,
|
||||||
|
}));
|
||||||
|
setApiKeySuccess((prev) => {
|
||||||
|
if (!prev[providerId]) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[providerId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
addToast(errorMessage, "error");
|
||||||
} finally {
|
} finally {
|
||||||
setAuthActionInProgress(null);
|
setAuthActionInProgress(null);
|
||||||
}
|
}
|
||||||
@@ -1247,6 +1418,7 @@ export function ModelOnboardingModal({
|
|||||||
inputValue={apiKeyInputs[provider.id] ?? ""}
|
inputValue={apiKeyInputs[provider.id] ?? ""}
|
||||||
isSaving={authActionInProgress === provider.id}
|
isSaving={authActionInProgress === provider.id}
|
||||||
error={apiKeyErrors[provider.id]}
|
error={apiKeyErrors[provider.id]}
|
||||||
|
success={apiKeySuccess[provider.id]}
|
||||||
isConnected={provider.authenticated}
|
isConnected={provider.authenticated}
|
||||||
onInputChange={handleApiKeyInputChange}
|
onInputChange={handleApiKeyInputChange}
|
||||||
onSave={handleSaveApiKey}
|
onSave={handleSaveApiKey}
|
||||||
|
|||||||
@@ -489,6 +489,257 @@ describe("ModelOnboardingModal", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("API key validation and error feedback", () => {
|
||||||
|
it("shows required validation when API key is empty", 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.keyDown(input, { key: "Enter" });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("API key is required")).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(mockSaveApiKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows format validation for known provider and blocks save", 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: "abc" } });
|
||||||
|
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("OpenAI keys should follow this format: Starts with sk- (e.g. sk-...)"),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(mockSaveApiKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes valid format to server", 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" } });
|
||||||
|
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSaveApiKey).toHaveBeenCalledWith("openai", "sk-test-key");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows actionable network error message", async () => {
|
||||||
|
mockSaveApiKey.mockRejectedValueOnce(new TypeError("Failed to fetch"));
|
||||||
|
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-network-test" } });
|
||||||
|
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Could not reach the server. Check your connection and try again.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(input).toHaveClass("onboarding-apikey-input--error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows server error message inline", async () => {
|
||||||
|
mockSaveApiKey.mockRejectedValueOnce(new Error("Unknown API key provider: xyz"));
|
||||||
|
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-server-test" } });
|
||||||
|
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Unknown API key provider: xyz")).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(input).toHaveClass("onboarding-apikey-input--error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows inline success confirmation", 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-success-test" } });
|
||||||
|
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("onboarding-apikey-success-openai")).toHaveTextContent("✓ Key saved");
|
||||||
|
});
|
||||||
|
expect(input).toHaveClass("onboarding-apikey-input--success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-clears success message after timeout", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByTestId("onboarding-apikey-input-openai");
|
||||||
|
fireEvent.change(input, { target: { value: "sk-timeout-test" } });
|
||||||
|
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("onboarding-apikey-success-openai")).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(3100);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("onboarding-apikey-success-openai")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears inline error state when input changes", 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");
|
||||||
|
const formatError = "OpenAI keys should follow this format: Starts with sk- (e.g. sk-...)";
|
||||||
|
fireEvent.change(input, { target: { value: "abc" } });
|
||||||
|
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(formatError)).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(input).toHaveClass("onboarding-apikey-input--error");
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: "sk-corrected" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText(formatError)).toBeNull();
|
||||||
|
});
|
||||||
|
expect(input).not.toHaveClass("onboarding-apikey-input--error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears inline success state when input changes", 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-before-edit" } });
|
||||||
|
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("onboarding-apikey-success-openai")).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(input).toHaveClass("onboarding-apikey-input--success");
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: "sk-after-edit" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("onboarding-apikey-success-openai")).toBeNull();
|
||||||
|
});
|
||||||
|
expect(input).not.toHaveClass("onboarding-apikey-input--success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows format hint for known providers", async () => {
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Format: Starts with sk-")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses fallback validation for unknown providers", async () => {
|
||||||
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
|
providers: [
|
||||||
|
{ id: "mystery-provider", name: "Mystery AI", authenticated: false, type: "api_key" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("onboarding-apikey-input-mystery-provider")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByTestId("onboarding-apikey-input-mystery-provider");
|
||||||
|
fireEvent.change(input, { target: { value: "short" } });
|
||||||
|
fireEvent.click(screen.getByTestId("onboarding-apikey-save-mystery-provider"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/At least 8 characters/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(mockSaveApiKey).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: "longenoughkey" } });
|
||||||
|
fireEvent.click(screen.getByTestId("onboarding-apikey-save-mystery-provider"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSaveApiKey).toHaveBeenCalledWith("mystery-provider", "longenoughkey");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows skipping and continuing even when validation errors exist", 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: "abc" } });
|
||||||
|
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("OpenAI keys should follow this format: Starts with sk- (e.g. sk-...)"),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Skip setup →" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Connect GitHub")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "← Back" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Set Up AI")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Next →" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Connect GitHub")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("GitHub step", () => {
|
describe("GitHub step", () => {
|
||||||
it("GitHub step shows fallback when no GitHub provider", async () => {
|
it("GitHub step shows fallback when no GitHub provider", async () => {
|
||||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|||||||
@@ -24052,6 +24052,30 @@ html .column.drag-over * {
|
|||||||
box-shadow: var(--focus-ring);
|
box-shadow: var(--focus-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* API key input validation states */
|
||||||
|
.onboarding-apikey-input--error {
|
||||||
|
border-color: var(--color-error);
|
||||||
|
box-shadow: var(--glow-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-apikey-input--success {
|
||||||
|
border-color: var(--color-success);
|
||||||
|
box-shadow: var(--glow-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline validation feedback for API key entry */
|
||||||
|
.onboarding-apikey-success {
|
||||||
|
font-size: var(--space-md);
|
||||||
|
color: var(--color-success);
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-apikey-hint {
|
||||||
|
font-size: var(--space-md);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.onboarding-apikey-instructions {
|
.onboarding-apikey-instructions {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
Reference in New Issue
Block a user