refactor(secrets): strip Service dropdown from Add-Key form
The Add-Key form used to open with a required Service dropdown (GitHub / Anthropic / OpenRouter / Other) that gated everything else. The dropdown did no persistent work — the secret store only cares about (key_name, value); the Service label was never saved anywhere. It also suffered registry drift: today we support ~22 hermes-dispatched providers (MiniMax, Gemini, DeepSeek, Kimi, Qwen, NVIDIA, etc.); only 3 had entries. Everyone else landed in "Other" with no downside beyond the mandatory click. Replaces it with: 1. Key-name <datalist> autocomplete sourced from new KEY_NAME_SUGGESTIONS in lib/services.ts — 26 entries covering common infra keys + every hermes-supported provider. 2. inferGroup(keyName) derives classification at render time, matching what the store already does in getGrouped(). No behaviour change for list grouping. 3. Provider docs link renders inline only when inferGroup recognises the name. For 'custom' keys we stay quiet — no false-structure prompt. 4. Test-connection button still available when the inferred group supports it AND the value is format-valid. Same providers as before. SERVICES registry preserved for LIST rendering + test routing. Result: two fields instead of three. One fewer decision. Provider- agnostic by design — new providers work the moment someone types their canonical env var name; no UI code change per provider. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2b603164de
commit
d956164812
@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import type { SecretGroup } from '@/types/secrets';
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { useSecretsStore } from '@/stores/secrets-store';
|
||||
import { KeyValueField } from '@/components/ui/KeyValueField';
|
||||
import { ValidationHint } from '@/components/ui/ValidationHint';
|
||||
@ -10,7 +9,7 @@ import {
|
||||
isValidKeyName,
|
||||
inferGroup,
|
||||
} from '@/lib/validation/secret-formats';
|
||||
import { SERVICES, SERVICE_GROUP_ORDER, getDefaultKeyName } from '@/lib/services';
|
||||
import { SERVICES, KEY_NAME_SUGGESTIONS } from '@/lib/services';
|
||||
|
||||
const VALIDATION_DEBOUNCE_MS = 400;
|
||||
|
||||
@ -23,9 +22,21 @@ interface AddKeyFormProps {
|
||||
/**
|
||||
* Inline-expanding form for adding a new API key.
|
||||
*
|
||||
* Flow (from spec §4.2):
|
||||
* Form Open → select service → key name auto-fills → type value →
|
||||
* optional Test Connection → Save
|
||||
* Design note (2026-04-22): the form used to open with a Service
|
||||
* dropdown (GitHub / Anthropic / OpenRouter / Other) gating what to
|
||||
* do next. That added friction — the storage layer only cares about
|
||||
* (key_name, value), and the provider can always be inferred from the
|
||||
* key name itself. We removed the dropdown and rely on:
|
||||
*
|
||||
* - A datalist of common key-name suggestions so autocomplete
|
||||
* replaces "pick a provider then the name auto-fills"
|
||||
* - inferGroup(keyName) to classify the secret for validation +
|
||||
* list-view grouping + test-connection routing, derived at render
|
||||
* time from what the user actually typed
|
||||
*
|
||||
* Result: fewer fields, provider-agnostic by design, no UI code change
|
||||
* needed to onboard a new provider (MiniMax, DeepSeek, etc. just work
|
||||
* as soon as you type their canonical env var name).
|
||||
*/
|
||||
export function AddKeyForm({
|
||||
workspaceId,
|
||||
@ -34,8 +45,7 @@ export function AddKeyForm({
|
||||
}: AddKeyFormProps) {
|
||||
const createSecret = useSecretsStore((s) => s.createSecret);
|
||||
|
||||
const [selectedGroup, setSelectedGroup] = useState<SecretGroup>('github');
|
||||
const [keyName, setKeyName] = useState(getDefaultKeyName('github'));
|
||||
const [keyName, setKeyName] = useState('');
|
||||
const [value, setValue] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [keyNameError, setKeyNameError] = useState<string | null>(null);
|
||||
@ -43,23 +53,13 @@ export function AddKeyForm({
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const service = SERVICES[selectedGroup];
|
||||
|
||||
// Auto-fill key name when service changes
|
||||
const handleServiceChange = useCallback(
|
||||
(group: SecretGroup) => {
|
||||
setSelectedGroup(group);
|
||||
const defaultName = getDefaultKeyName(group);
|
||||
if (defaultName) {
|
||||
setKeyName(defaultName);
|
||||
}
|
||||
// Reset validation
|
||||
setValidationError(null);
|
||||
setKeyNameError(null);
|
||||
setSaveError(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
// Group is derived, not selected. Falls back to 'custom' for any
|
||||
// key name that doesn't match a known provider pattern — validation
|
||||
// and test-connection still work, just without provider-specific
|
||||
// format hints.
|
||||
const inferredGroup = useMemo(() => inferGroup(keyName || ''), [keyName]);
|
||||
const service = SERVICES[inferredGroup];
|
||||
|
||||
// Validate key name
|
||||
useEffect(() => {
|
||||
@ -78,7 +78,7 @@ export function AddKeyForm({
|
||||
setKeyNameError(null);
|
||||
}, [keyName, existingNames]);
|
||||
|
||||
// Debounced value validation
|
||||
// Debounced value validation against the inferred provider's format.
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setValidationError(null);
|
||||
@ -86,18 +86,17 @@ export function AddKeyForm({
|
||||
}
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setValidationError(validateSecretValue(value, selectedGroup));
|
||||
setValidationError(validateSecretValue(value, inferredGroup));
|
||||
}, VALIDATION_DEBOUNCE_MS);
|
||||
return () => clearTimeout(debounceRef.current);
|
||||
}, [value, selectedGroup]);
|
||||
}, [value, inferredGroup]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
// Final validation pass
|
||||
if (!isValidKeyName(keyName)) {
|
||||
setKeyNameError('Key name must be UPPER_SNAKE_CASE');
|
||||
return;
|
||||
}
|
||||
const valErr = validateSecretValue(value, selectedGroup);
|
||||
const valErr = validateSecretValue(value, inferredGroup);
|
||||
if (valErr) {
|
||||
setValidationError(valErr);
|
||||
return;
|
||||
@ -114,32 +113,21 @@ export function AddKeyForm({
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [keyName, value, selectedGroup, createSecret, workspaceId]);
|
||||
}, [keyName, value, inferredGroup, createSecret, workspaceId]);
|
||||
|
||||
const canSave = keyName && value && !keyNameError && !validationError && !isSaving;
|
||||
|
||||
// Show the provider-specific docs hint only when the key name
|
||||
// matches a known provider. For 'custom' (unknown key name) we stay
|
||||
// quiet — no false-structure prompt.
|
||||
const showProviderHint = inferredGroup !== 'custom' && service.docsUrl;
|
||||
|
||||
return (
|
||||
<div className="add-key-form">
|
||||
<div className="add-key-form__header">Add New Key</div>
|
||||
|
||||
{/* Service selector */}
|
||||
<label className="add-key-form__label">
|
||||
Service
|
||||
<select
|
||||
value={selectedGroup}
|
||||
onChange={(e) => handleServiceChange(e.target.value as SecretGroup)}
|
||||
disabled={isSaving}
|
||||
className="add-key-form__select"
|
||||
>
|
||||
{SERVICE_GROUP_ORDER.map((group) => (
|
||||
<option key={group} value={group}>
|
||||
{SERVICES[group].label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* Key name */}
|
||||
{/* Key name — autocomplete replaces the old Service dropdown.
|
||||
inferGroup(keyName) derives classification at render time. */}
|
||||
<label className="add-key-form__label">
|
||||
Key name
|
||||
<input
|
||||
@ -147,14 +135,32 @@ export function AddKeyForm({
|
||||
value={keyName}
|
||||
onChange={(e) => setKeyName(e.target.value.toUpperCase())}
|
||||
disabled={isSaving}
|
||||
placeholder="MY_API_KEY"
|
||||
placeholder="e.g. ANTHROPIC_API_KEY, MINIMAX_API_KEY, GITHUB_TOKEN"
|
||||
className="add-key-form__input"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
list="add-key-name-suggestions"
|
||||
/>
|
||||
</label>
|
||||
{keyNameError && (
|
||||
<ValidationHint error={keyNameError} />
|
||||
<datalist id="add-key-name-suggestions">
|
||||
{KEY_NAME_SUGGESTIONS.map((name) => (
|
||||
<option key={name} value={name} />
|
||||
))}
|
||||
</datalist>
|
||||
{keyNameError && <ValidationHint error={keyNameError} />}
|
||||
{showProviderHint && (
|
||||
<div className="add-key-form__hint" data-testid="provider-hint">
|
||||
<span className="add-key-form__hint-label">{service.label}</span>
|
||||
{' — '}
|
||||
<a
|
||||
href={service.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="add-key-form__hint-link"
|
||||
>
|
||||
get a key
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key value */}
|
||||
@ -172,22 +178,21 @@ export function AddKeyForm({
|
||||
showValid={!validationError && value.length > 0}
|
||||
/>
|
||||
|
||||
{/* Test connection (only for supported services) */}
|
||||
{/* Test connection (only when the inferred group supports it AND
|
||||
value looks format-valid). */}
|
||||
{service.testSupported && value && !validationError && (
|
||||
<TestConnectionButton
|
||||
provider={selectedGroup}
|
||||
provider={inferredGroup}
|
||||
secretValue={value}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Save error */}
|
||||
{saveError && (
|
||||
<div className="add-key-form__error" role="alert">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="add-key-form__actions">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -1,10 +1,18 @@
|
||||
import type { ServiceConfig, SecretGroup } from '@/types/secrets';
|
||||
|
||||
/**
|
||||
* Static service registry. Each known provider maps to its display
|
||||
* properties, expected key names, and whether test-connection is supported.
|
||||
* Static service registry — used for LIST-view rendering: the
|
||||
* per-group icon, the "get a key" docs link shown as a hint once
|
||||
* the user types a matching key name, and the test-connection
|
||||
* routing for the 3 providers with backend test endpoints.
|
||||
*
|
||||
* Keys not matching any known service fall into the "custom" catch-all.
|
||||
*
|
||||
* Note (2026-04-22): the Add-Key form no longer uses this as a
|
||||
* user-facing dropdown. It reads keyNames[0] via getDefaultKeyName
|
||||
* — still referenced by a couple of legacy call sites — and the
|
||||
* Add form's autocomplete source lives in KEY_NAME_SUGGESTIONS
|
||||
* below. SERVICES is purely for post-save display + test routing.
|
||||
*/
|
||||
export const SERVICES: Record<SecretGroup, ServiceConfig> = {
|
||||
github: {
|
||||
@ -49,3 +57,43 @@ export const SERVICE_GROUP_ORDER: SecretGroup[] = [
|
||||
export function getDefaultKeyName(group: SecretGroup): string {
|
||||
return SERVICES[group].keyNames[0] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Autocomplete suggestions for the Add-Key form's key-name input.
|
||||
*
|
||||
* Covers the providers hermes-agent supports natively + the common
|
||||
* infra keys (GitHub, platform-side). Adding a new provider here is
|
||||
* a one-line change — the Add form picks it up via <datalist>, and
|
||||
* classification (for validation + list grouping) comes from
|
||||
* inferGroup in lib/validation/secret-formats.ts.
|
||||
*
|
||||
* Order: alphabetical for stable display in autocomplete popups.
|
||||
*/
|
||||
export const KEY_NAME_SUGGESTIONS: readonly string[] = [
|
||||
'AI_GATEWAY_API_KEY',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ARCEEAI_API_KEY',
|
||||
'COPILOT_GITHUB_TOKEN',
|
||||
'DASHSCOPE_API_KEY',
|
||||
'DEEPSEEK_API_KEY',
|
||||
'GEMINI_API_KEY',
|
||||
'GH_TOKEN',
|
||||
'GITHUB_TOKEN',
|
||||
'GLM_API_KEY',
|
||||
'GOOGLE_API_KEY',
|
||||
'HERMES_API_KEY',
|
||||
'HF_TOKEN',
|
||||
'KILOCODE_API_KEY',
|
||||
'KIMI_API_KEY',
|
||||
'KIMI_CN_API_KEY',
|
||||
'MINIMAX_API_KEY',
|
||||
'MINIMAX_CN_API_KEY',
|
||||
'NOUS_API_KEY',
|
||||
'NVIDIA_API_KEY',
|
||||
'OLLAMA_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'OPENCODE_GO_API_KEY',
|
||||
'OPENCODE_ZEN_API_KEY',
|
||||
'OPENROUTER_API_KEY',
|
||||
'XIAOMI_API_KEY',
|
||||
] as const;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user