Improve editor progressive disclosure

This commit is contained in:
Victor Wiebe 2026-08-07 10:58:16 -04:00
parent 2cc6e7cfd8
commit 933201dc50
9 changed files with 298 additions and 55 deletions

View File

@ -326,3 +326,6 @@ Date: 2026-07-20
- Standard Compose is restored healthy with empty history/secrets/configuration after automated cleanup. No commit or push occurred. - Standard Compose is restored healthy with empty history/secrets/configuration after automated cleanup. No commit or push occurred.
- On 2026-08-07, the user reported all six manual workflows pass. Stored-history and backend-log marker scans passed, the expected redaction and `SECRET_NOT_FOUND` evidence was present, and cleanup restored healthy standard Compose with empty executions, secrets, secret-key configuration, and internal-origin configuration. - On 2026-08-07, the user reported all six manual workflows pass. Stored-history and backend-log marker scans passed, the expected redaction and `SECRET_NOT_FOUND` evidence was present, and cleanup restored healthy standard Compose with empty executions, secrets, secret-key configuration, and internal-origin configuration.
- The Actions & Bindings information-density feedback is tracked in `SLICE6a.md` as a separate UX follow-up. The user granted explicit final Slice 6 sign-off on 2026-08-07 with the exact wording: “I grant explicit final Slice 6 sign-off.” Slice 6 is complete. - The Actions & Bindings information-density feedback is tracked in `SLICE6a.md` as a separate UX follow-up. The user granted explicit final Slice 6 sign-off on 2026-08-07 with the exact wording: “I grant explicit final Slice 6 sign-off.” Slice 6 is complete.
- Slice 6a implementation and automation are complete through the manual gate: accessible collapsible sections/cards, compact record summaries, grouped REST editing, explicit secret creation, visible collapsed issue counts, and mounted-state preservation are implemented. Focused coverage passes at 18 tests; full frontend coverage passes at 25 suites / 520 tests; TypeScript and production build pass. `Slice 6a Density Manual` (`#22`) is prepared for `SLICE6a_MANUAL_TEST.md`.
- The user reported all Slice 6a manual UX tests pass and that the changes look good. Disposable project `#22` was deleted; backend health, frontend HTTP 200, empty secrets, and empty history pass.
- The user granted explicit final Slice 6a sign-off on 2026-08-07 with the exact wording: “I grant explicit final Slice 6a sign-off.” Slice 6a is complete.

View File

@ -23,7 +23,7 @@ The slice files remain the detailed implementation plans. When an older list con
| [Slice 4](SLICE4.md) | Proxy security and observability | Complete | None | | [Slice 4](SLICE4.md) | Proxy security and observability | Complete | None |
| [Slice 5](SLICE5.md) | Validation and error handling | Complete | None | | [Slice 5](SLICE5.md) | Validation and error handling | Complete | None |
| [Slice 6](SLICE6.md) | Testing and release validation | Complete | None | | [Slice 6](SLICE6.md) | Testing and release validation | Complete | None |
| [Slice 6a](SLICE6a.md) | Editor information density and progressive disclosure | Planned | Collapsible sections/cards and a less overwhelming REST-action editing experience | | [Slice 6a](SLICE6a.md) | Editor information density and progressive disclosure | Complete | None |
| [Slice 7](SLICE7.md) | MVP scope decision | Complete | Cross-document reconciliation is carried by Slices 6 and 8 | | [Slice 7](SLICE7.md) | MVP scope decision | Complete | Cross-document reconciliation is carried by Slices 6 and 8 |
| [Slice 8](SLICE8.md) | Documentation and release packaging | Partial | Unified roadmap complete; broader documentation ownership, reconciliation, guides, and release packaging remain | | [Slice 8](SLICE8.md) | Documentation and release packaging | Partial | Unified roadmap complete; broader documentation ownership, reconciliation, guides, and release packaging remain |

View File

@ -73,6 +73,16 @@ Automation and human acceptance remain distinct. `SLICE6_MANUAL_TEST.md`, cleanu
The user granted explicit final Slice 5 sign-off on 2026-08-04 with the wording: “Confirm slice5 signoff.” Slice 5 is complete. The user granted explicit final Slice 5 sign-off on 2026-08-04 with the wording: “Confirm slice5 signoff.” Slice 5 is complete.
## Slice 6a Progressive Disclosure Gate — 2026-08-07
Actions & Bindings now uses accessible collapsible major sections and independently collapsible action, binding, variable, and secret cards. REST editing is grouped into Identity, Endpoint & authentication, Request parameters, and Request body; the denser request groups start closed. Secret creation is explicit rather than exposing its full form whenever the section opens. Section hiding preserves mounted local editor state, and collapsed issue badges lead to expanded diagnostics.
Focused ActionInspector/SecretManager coverage passes at 18 tests. Full frontend coverage passes at 25 suites / 520 tests, standalone TypeScript passes, and the production build succeeds. Standard Compose was rebuilt successfully. A disposable high-density project, `Slice 6a Density Manual` (`#22`), is prepared for the proportional procedure in `SLICE6a_MANUAL_TEST.md`. Manual acceptance, cleanup, and explicit sign-off remain pending.
The user subsequently reported all five manual UX tests pass and that the changes look good. Cleanup deleted project `#22`, and final standard-stack checks returned backend health `ok`, frontend HTTP 200, empty secrets, and empty execution history.
The user granted explicit final Slice 6a sign-off on 2026-08-07 with the exact wording: “I grant explicit final Slice 6a sign-off.” Slice 6a is complete.
## Slice 4 Manual Gate ## Slice 4 Manual Gate
The five workflows in `SLICE4.md` passed on 2026-08-02 after focused remediation and repetition. Initial testing passed approved destinations, controls/recovery, and execution history; mixed-answer/rebinding DNS checks were initially unavailable, and private destination URLs in backend failure logs failed redaction. The logger was reduced to safe structured metadata. A disposable authoritative DNS fixture then proved mixed public/loopback answers fail closed and rebinding cannot bypass pinned validated addresses. The focused logging retest found zero private literals or raw transport errors. IPv6 literal classification was also corrected and retested. All disposable secrets, history, fixtures, networks, keys, and exceptions were removed. This is manual evidence separate from automation. The five workflows in `SLICE4.md` passed on 2026-08-02 after focused remediation and repetition. Initial testing passed approved destinations, controls/recovery, and execution history; mixed-answer/rebinding DNS checks were initially unavailable, and private destination URLs in backend failure logs failed redaction. The logger was reduced to safe structured metadata. A disposable authoritative DNS fixture then proved mixed public/loopback answers fail closed and rebinding cannot bypass pinned validated addresses. The focused logging retest found zero private literals or raw transport errors. IPv6 literal classification was also corrected and retested. All disposable secrets, history, fixtures, networks, keys, and exceptions were removed. This is manual evidence separate from automation.

View File

@ -47,6 +47,48 @@
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
} }
.sectionToggle,
.cardToggle {
appearance: none;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.sectionToggle {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
padding: 2px 0;
}
.sectionToggle > span:last-child {
display: flex;
flex-direction: column;
}
.sectionToggle:focus-visible,
.cardToggle:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 3px;
border-radius: 4px;
}
.sectionBody {
min-width: 0;
}
.chevron {
color: #57606a;
font-size: 14px;
line-height: 1;
flex-shrink: 0;
}
.sectionTitle { .sectionTitle {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
@ -89,6 +131,48 @@
gap: 8px; gap: 8px;
} }
.cardToggle {
display: flex;
flex-direction: column;
gap: 7px;
width: 100%;
padding: 0;
}
.toggleHeadingRow,
.toggleStatus {
display: flex;
align-items: center;
}
.toggleHeadingRow {
width: 100%;
justify-content: space-between;
gap: 12px;
}
.toggleStatus {
gap: 8px;
}
.issueBadge {
padding: 2px 7px;
border: 1px solid #fde68a;
border-radius: 999px;
background: #fffbeb;
color: #92400e;
font-size: 10px;
font-weight: 600;
}
.cardDetails {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 10px;
border-top: 1px solid #e5e7eb;
}
.recordList { .recordList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -263,6 +347,17 @@
gap: 8px; gap: 8px;
} }
.bindingCompactFlow {
display: block;
overflow: hidden;
color: #57606a;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
.bindingCardHeader { .bindingCardHeader {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -200,9 +200,59 @@ describe('Actions & Bindings visual REST action authoring', () => {
'Routes successful action responses into components or runtime variables.', 'Routes successful action responses into components or runtime variables.',
); );
expect(container.textContent).toContain('REST action'); expect(container.textContent).toContain('REST action');
click(container.querySelector('button[aria-label="Expand Response Bindings section"]'));
expect(container.textContent).toContain('Response binding'); expect(container.textContent).toContain('Response binding');
}); });
test('uses keyboard-accessible collapsed summaries and preserves local edits across section toggles', () => {
act(() => {
root.render(<ProjectProvider><ProjectSeed /><ActionInspector /><ProjectProbe /></ProjectProvider>);
});
const actionsSection = container.querySelector<HTMLButtonElement>('button[aria-label="Collapse REST Actions section"]');
const actionToggle = container.querySelector<HTMLButtonElement>('button[aria-label="Expand REST action Lookup"]');
const bindingsSection = container.querySelector<HTMLButtonElement>('button[aria-label="Expand Response Bindings section"]');
expect(actionsSection?.tagName).toBe('BUTTON');
expect(actionsSection?.getAttribute('aria-expanded')).toBe('true');
expect(actionToggle?.getAttribute('aria-expanded')).toBe('false');
expect(bindingsSection?.getAttribute('aria-expanded')).toBe('false');
expect(container.querySelector('button[aria-label="Edit REST action Lookup"]')).toBeNull();
actionToggle?.focus();
expect(document.activeElement).toBe(actionToggle);
click(actionToggle);
click(container.querySelector('button[aria-label="Edit REST action Lookup"]'));
const groups = Array.from(container.querySelectorAll<HTMLDetailsElement>('[data-testid="rest-action-editor-action_lookup"] details'));
expect(groups.map((group) => [group.querySelector('summary')?.textContent, group.open])).toEqual([
['Identity', true],
['Endpoint & authentication', true],
['Request parameters', false],
['Request body', false],
]);
const nameInput = container.querySelector<HTMLInputElement>('[data-testid="action-name"]');
setControlValue(nameInput, '');
expect(nameInput?.value).toBe('');
expect(currentDocument(container).project.actions[0].name).toBe('Lookup');
click(container.querySelector('button[aria-label="Collapse REST Actions section"]'));
click(container.querySelector('button[aria-label="Expand REST Actions section"]'));
expect(container.querySelector<HTMLInputElement>('[data-testid="action-name"]')?.value).toBe('');
expect(currentDocument(container).project.actions[0].name).toBe('Lookup');
});
test('keeps issue status visible on a collapsed card and reveals the diagnostic on expansion', () => {
act(() => {
root.render(<ProjectProvider><DiagnosticSeed trigger="none" /><ActionInspector /></ProjectProvider>);
});
const toggle = container.querySelector<HTMLButtonElement>('button[aria-label="Expand REST action Lookup"]');
expect(toggle?.textContent).toContain('1 issue');
expect(container.textContent).not.toContain('Action "Lookup" is not triggered by any component or page event.');
click(toggle);
expect(container.textContent).toContain('Action "Lookup" is not triggered by any component or page event.');
});
test('suggests canonical request references and common header names while preserving custom headers', () => { test('suggests canonical request references and common header names while preserving custom headers', () => {
act(() => { act(() => {
root.render( root.render(
@ -213,6 +263,7 @@ describe('Actions & Bindings visual REST action authoring', () => {
</ProjectProvider>, </ProjectProvider>,
); );
}); });
click(container.querySelector('button[aria-label="Expand REST action Lookup"]'));
click(container.querySelector('button[aria-label="Edit REST action Lookup"]')); click(container.querySelector('button[aria-label="Edit REST action Lookup"]'));
setControlValue( setControlValue(
@ -371,6 +422,7 @@ describe('Actions & Bindings visual REST action authoring', () => {
click(container.querySelector('[data-testid="add-response-binding"]')); click(container.querySelector('[data-testid="add-response-binding"]'));
setControlValue(container.querySelector<HTMLSelectElement>('select[aria-label="Binding target"]'), 'variables.capturedId'); setControlValue(container.querySelector<HTMLSelectElement>('select[aria-label="Binding target"]'), 'variables.capturedId');
const confirm = jest.spyOn(window, 'confirm').mockReturnValue(false); const confirm = jest.spyOn(window, 'confirm').mockReturnValue(false);
click(container.querySelector('button[aria-label="Expand variable capturedId"]'));
click(container.querySelector('button[aria-label="Delete variable capturedId"]')); click(container.querySelector('button[aria-label="Delete variable capturedId"]'));
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('binding "binding_response_1"')); expect(confirm).toHaveBeenCalledWith(expect.stringContaining('binding "binding_response_1"'));
expect(currentDocument(container).project.variables.capturedId).toBeDefined(); expect(currentDocument(container).project.variables.capturedId).toBeDefined();
@ -394,6 +446,8 @@ describe('Actions & Bindings visual REST action authoring', () => {
act(() => { act(() => {
root.render(<ProjectProvider><ProjectSeed legacyBinding /><ActionInspector /><ProjectProbe /></ProjectProvider>); root.render(<ProjectProvider><ProjectSeed legacyBinding /><ActionInspector /><ProjectProbe /></ProjectProvider>);
}); });
click(container.querySelector('button[aria-label="Expand Response Bindings section"]'));
click(container.querySelector('button[aria-label="Expand binding binding_legacy"]'));
click(container.querySelector('button[aria-label="Edit binding binding_legacy"]')); click(container.querySelector('button[aria-label="Edit binding binding_legacy"]'));
const trigger = container.querySelector<HTMLSelectElement>('select[aria-label="Binding trigger"]'); const trigger = container.querySelector<HTMLSelectElement>('select[aria-label="Binding trigger"]');
expect(Array.from(trigger?.options ?? []).map((option) => option.value)).toEqual(['onSuccess', 'onClick']); expect(Array.from(trigger?.options ?? []).map((option) => option.value)).toEqual(['onSuccess', 'onClick']);
@ -421,6 +475,10 @@ describe('Actions & Bindings visual REST action authoring', () => {
root.render(<ProjectProvider><DiagnosticSeed trigger="none" /><ActionInspector /></ProjectProvider>); root.render(<ProjectProvider><DiagnosticSeed trigger="none" /><ActionInspector /></ProjectProvider>);
}); });
click(container.querySelector('button[aria-label="Expand REST action Lookup"]'));
click(container.querySelector('button[aria-label="Expand Response Bindings section"]'));
click(container.querySelector('button[aria-label="Expand binding binding_lookup"]'));
expect(container.textContent).toContain( expect(container.textContent).toContain(
'Action "Lookup" is not triggered by any component or page event.', 'Action "Lookup" is not triggered by any component or page event.',
); );

View File

@ -594,6 +594,8 @@ type ActionCardProps = {
allComponents: CanvasComponent[]; allComponents: CanvasComponent[];
isEditing: boolean; isEditing: boolean;
actionsLocked: boolean; actionsLocked: boolean;
expanded: boolean;
onToggle: () => void;
onEdit: () => void; onEdit: () => void;
onDuplicate: () => void; onDuplicate: () => void;
onDelete: () => void; onDelete: () => void;
@ -605,6 +607,8 @@ function ActionCard({
allComponents, allComponents,
isEditing, isEditing,
actionsLocked, actionsLocked,
expanded,
onToggle,
onEdit, onEdit,
onDuplicate, onDuplicate,
onDelete, onDelete,
@ -651,28 +655,27 @@ function ActionCard({
return ( return (
<div className={styles.actionCard}> <div className={styles.actionCard}>
<div className={styles.recordType}>REST action</div> <button type="button" className={styles.cardToggle} onClick={onToggle} aria-expanded={expanded} aria-label={`${expanded ? 'Collapse' : 'Expand'} REST action ${action.name}`}>
{/* ── Header: method + name + id ── */} <span className={styles.toggleHeadingRow}>
<div className={styles.actionCardHeader}> <span className={styles.recordType}>REST action</span>
<span className={[styles.methodBadge, methodClass(action.method)].join(' ')}> <span className={styles.toggleStatus}>
{action.method} {diags.length > 0 && <span className={styles.issueBadge}>{diags.length} issue{diags.length === 1 ? '' : 's'}</span>}
<span className={styles.chevron} aria-hidden="true">{expanded ? '▾' : '▸'}</span>
</span>
</span> </span>
<span className={styles.actionName}>{action.name}</span> <span className={styles.actionCardHeader}>
<span className={styles.actionId}>{action.id}</span> <span className={[styles.methodBadge, methodClass(action.method)].join(' ')}>{action.method}</span>
</div> <span className={styles.actionName}>{action.name}</span>
<span className={styles.actionId}>{action.id}</span>
{/* ── URL ── */}
<div className={styles.actionUrl}>{action.url}</div>
{/* ── Auth + description ── */}
<div className={styles.actionMeta}>
<span className={styles.authBadge}>
{AUTH_LABELS[action.authenticationType] ?? action.authenticationType}
</span> </span>
{action.description && ( <span className={styles.actionUrl}>{action.url}</span>
<span className={styles.actionDescription}>{action.description}</span> <span className={styles.actionMeta}>
)} <span className={styles.authBadge}>{AUTH_LABELS[action.authenticationType] ?? action.authenticationType}</span>
</div> </span>
</button>
{expanded && <div className={styles.cardDetails}>
{action.description && <span className={styles.actionDescription}>{action.description}</span>}
{/* ── Template references (Step 16.5) ── */} {/* ── Template references (Step 16.5) ── */}
{hasTemplates && ( {hasTemplates && (
@ -780,6 +783,7 @@ function ActionCard({
</div> </div>
)} )}
</div> </div>
</div>}
</div> </div>
); );
} }
@ -791,6 +795,8 @@ type BindingCardProps = {
allComponents: CanvasComponent[]; allComponents: CanvasComponent[];
allActions: RestAction[]; allActions: RestAction[];
diags: Diagnostic[]; diags: Diagnostic[];
expanded: boolean;
onToggle: () => void;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
}; };
@ -800,6 +806,8 @@ function BindingCard({
allComponents, allComponents,
allActions, allActions,
diags, diags,
expanded,
onToggle,
onEdit, onEdit,
onDelete, onDelete,
}: BindingCardProps): React.ReactElement { }: BindingCardProps): React.ReactElement {
@ -822,12 +830,19 @@ function BindingCard({
return ( return (
<div className={styles.bindingCard}> <div className={styles.bindingCard}>
<div className={styles.recordType}>{sourceActionId ? 'Response binding' : 'Component binding'}</div> <button type="button" className={styles.cardToggle} onClick={onToggle} aria-expanded={expanded} aria-label={`${expanded ? 'Collapse' : 'Expand'} binding ${binding.id}`}>
{/* ── Header: id + trigger ── */} <span className={styles.toggleHeadingRow}>
<div className={styles.bindingCardHeader}> <span className={styles.recordType}>{sourceActionId ? 'Response binding' : 'Component binding'}</span>
<span className={styles.bindingId}>{binding.id}</span> <span className={styles.toggleStatus}>
<span className={styles.triggerBadge}>{trigger}</span> {diags.length > 0 && <span className={styles.issueBadge}>{diags.length} issue{diags.length === 1 ? '' : 's'}</span>}
</div> <span className={styles.chevron} aria-hidden="true">{expanded ? '▾' : '▸'}</span>
</span>
</span>
<span className={styles.bindingCardHeader}><span className={styles.bindingId}>{binding.id}</span><span className={styles.triggerBadge}>{trigger}</span></span>
<span className={styles.bindingCompactFlow}>{binding.source} <span aria-hidden="true"></span> {binding.target}</span>
</button>
{expanded && <div className={styles.cardDetails}>
{/* ── Source / Target flow ── */} {/* ── Source / Target flow ── */}
<div className={styles.bindingFlow}> <div className={styles.bindingFlow}>
@ -883,6 +898,7 @@ function BindingCard({
<button type="button" className={styles.smallButton} onClick={onEdit} aria-label={`Edit binding ${binding.id}`} disabled={!sourceActionId} title={!sourceActionId ? 'Edit component bindings in the JSON Editor.' : undefined}>Edit</button> <button type="button" className={styles.smallButton} onClick={onEdit} aria-label={`Edit binding ${binding.id}`} disabled={!sourceActionId} title={!sourceActionId ? 'Edit component bindings in the JSON Editor.' : undefined}>Edit</button>
<button type="button" className={styles.dangerButton} onClick={onDelete} aria-label={`Delete binding ${binding.id}`}>Delete</button> <button type="button" className={styles.dangerButton} onClick={onDelete} aria-label={`Delete binding ${binding.id}`}>Delete</button>
</div> </div>
</div>}
</div> </div>
); );
} }
@ -903,6 +919,18 @@ function ActionInspector(): React.ReactElement {
const [editingVariableName, setEditingVariableName] = useState<string | null>(null); const [editingVariableName, setEditingVariableName] = useState<string | null>(null);
const [addingVariable, setAddingVariable] = useState(false); const [addingVariable, setAddingVariable] = useState(false);
const [secrets, setSecrets] = useState<SecretMetadata[]>([]); const [secrets, setSecrets] = useState<SecretMetadata[]>([]);
const [openSections, setOpenSections] = useState(() => new Set(['actions']));
const [openActions, setOpenActions] = useState<Set<string>>(() => new Set());
const [openBindings, setOpenBindings] = useState<Set<string>>(() => new Set());
const [openVariables, setOpenVariables] = useState<Set<string>>(() => new Set());
const toggleOpen = useCallback((setter: React.Dispatch<React.SetStateAction<Set<string>>>, key: string) => {
setter((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
}, []);
const refreshSecrets = useCallback(async () => { const refreshSecrets = useCallback(async () => {
try { setSecrets(await listSecrets()); } catch { setSecrets([]); } try { setSecrets(await listSecrets()); } catch { setSecrets([]); }
@ -932,6 +960,7 @@ function ActionInspector(): React.ReactElement {
); );
setDoc((current) => appendRestAction(current, action)); setDoc((current) => appendRestAction(current, action));
setEditingActionIndex(actions.length); setEditingActionIndex(actions.length);
setOpenActions((current) => new Set(current).add(`${action.id}-${actions.length}`));
}, [actions, doc, setDoc]); }, [actions, doc, setDoc]);
const handleUpdateAction = useCallback( const handleUpdateAction = useCallback(
@ -950,6 +979,7 @@ function ActionInspector(): React.ReactElement {
); );
setDoc((current) => appendRestAction(current, duplicate)); setDoc((current) => appendRestAction(current, duplicate));
setEditingActionIndex(actions.length); setEditingActionIndex(actions.length);
setOpenActions((current) => new Set(current).add(`${duplicate.id}-${actions.length}`));
}, },
[actions, doc, setDoc], [actions, doc, setDoc],
); );
@ -978,6 +1008,7 @@ function ActionInspector(): React.ReactElement {
const binding = createResponseBinding(bindings, actions[0], targetOptions[0].value); const binding = createResponseBinding(bindings, actions[0], targetOptions[0].value);
setDoc((current) => ({ ...current, project: { ...current.project, bindings: [...current.project.bindings, binding] } })); setDoc((current) => ({ ...current, project: { ...current.project, bindings: [...current.project.bindings, binding] } }));
setEditingBindingIndex(bindings.length); setEditingBindingIndex(bindings.length);
setOpenBindings((current) => new Set(current).add(`${binding.id}-${bindings.length}`));
}, [actions, bindings, setDoc, targetOptions]); }, [actions, bindings, setDoc, targetOptions]);
const handleDeleteVariable = useCallback((name: string) => { const handleDeleteVariable = useCallback((name: string) => {
@ -1036,17 +1067,17 @@ function ActionInspector(): React.ReactElement {
{/* ══ Actions section ══════════════════════════════════════════ */} {/* ══ Actions section ══════════════════════════════════════════ */}
<div className={styles.section}> <div className={styles.section}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<div> <button type="button" className={styles.sectionToggle} onClick={() => toggleOpen(setOpenSections, 'actions')} aria-expanded={openSections.has('actions')} aria-label={`${openSections.has('actions') ? 'Collapse' : 'Expand'} REST Actions section`}>
<div className={styles.sectionTitle}>REST Actions</div> <span className={styles.chevron} aria-hidden="true">{openSections.has('actions') ? '▾' : '▸'}</span>
<div className={styles.sectionDescription}>Requests sent through the backend proxy.</div> <span><span className={styles.sectionTitle}>REST Actions</span><span className={styles.sectionDescription}>Requests sent through the backend proxy.</span></span>
</div> </button>
<div className={styles.sectionHeaderMeta}> <div className={styles.sectionHeaderMeta}>
<span className={styles.sectionCount}>{actions.length}</span> <span className={styles.sectionCount}>{actions.length}</span>
<button <button
type="button" type="button"
className={styles.primaryButton} className={styles.primaryButton}
data-testid="add-rest-action" data-testid="add-rest-action"
onClick={handleAddAction} onClick={() => { setOpenSections((current) => new Set(current).add('actions')); handleAddAction(); }}
disabled={editingActionIndex !== null} disabled={editingActionIndex !== null}
title={editingActionIndex !== null title={editingActionIndex !== null
? 'Finish the current action edit before adding another.' ? 'Finish the current action edit before adding another.'
@ -1057,6 +1088,8 @@ function ActionInspector(): React.ReactElement {
</div> </div>
</div> </div>
<div className={styles.sectionBody} hidden={!openSections.has('actions')}>
{/* Summary banner */} {/* Summary banner */}
{actions.length > 0 && (actionWarnings + actionInfos) === 0 && ( {actions.length > 0 && (actionWarnings + actionInfos) === 0 && (
<div className={styles.diagSummaryOk}> <div className={styles.diagSummaryOk}>
@ -1084,7 +1117,9 @@ function ActionInspector(): React.ReactElement {
allComponents={allComponents} allComponents={allComponents}
isEditing={editingActionIndex === actionIndex} isEditing={editingActionIndex === actionIndex}
actionsLocked={editingActionIndex !== null} actionsLocked={editingActionIndex !== null}
onEdit={() => setEditingActionIndex(actionIndex)} expanded={openActions.has(`${action.id}-${actionIndex}`)}
onToggle={() => toggleOpen(setOpenActions, `${action.id}-${actionIndex}`)}
onEdit={() => { setEditingActionIndex(actionIndex); setOpenActions((current) => new Set(current).add(`${action.id}-${actionIndex}`)); }}
onDuplicate={() => handleDuplicateAction(action)} onDuplicate={() => handleDuplicateAction(action)}
onDelete={() => handleDeleteAction(actionIndex, action)} onDelete={() => handleDeleteAction(actionIndex, action)}
/> />
@ -1106,21 +1141,24 @@ function ActionInspector(): React.ReactElement {
))} ))}
</div> </div>
)} )}
</div>
</div> </div>
{/* ══ Bindings section ═════════════════════════════════════════ */} {/* ══ Bindings section ═════════════════════════════════════════ */}
<div className={styles.section}> <div className={styles.section}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<div> <button type="button" className={styles.sectionToggle} onClick={() => toggleOpen(setOpenSections, 'bindings')} aria-expanded={openSections.has('bindings')} aria-label={`${openSections.has('bindings') ? 'Collapse' : 'Expand'} Response Bindings section`}>
<div className={styles.sectionTitle}>Response Bindings</div> <span className={styles.chevron} aria-hidden="true">{openSections.has('bindings') ? '▾' : '▸'}</span>
<div className={styles.sectionDescription}>Routes successful action responses into components or runtime variables.</div> <span><span className={styles.sectionTitle}>Response Bindings</span><span className={styles.sectionDescription}>Routes successful action responses into components or runtime variables.</span></span>
</div> </button>
<div className={styles.sectionHeaderMeta}> <div className={styles.sectionHeaderMeta}>
<span className={styles.sectionCount}>{bindings.length}</span> <span className={styles.sectionCount}>{bindings.length}</span>
<button type="button" className={styles.primaryButton} data-testid="add-response-binding" onClick={handleAddBinding} disabled={actions.length === 0 || targetOptions.length === 0 || editingBindingIndex !== null} title={actions.length === 0 ? 'Add a REST action first.' : targetOptions.length === 0 ? 'Add a supported target component or variable first.' : undefined}>+ Add response binding</button> <button type="button" className={styles.primaryButton} data-testid="add-response-binding" onClick={() => { setOpenSections((current) => new Set(current).add('bindings')); handleAddBinding(); }} disabled={actions.length === 0 || targetOptions.length === 0 || editingBindingIndex !== null} title={actions.length === 0 ? 'Add a REST action first.' : targetOptions.length === 0 ? 'Add a supported target component or variable first.' : undefined}>+ Add response binding</button>
</div> </div>
</div> </div>
<div className={styles.sectionBody} hidden={!openSections.has('bindings')}>
{/* Summary banner */} {/* Summary banner */}
{bindings.length > 0 && bindingWarnings === 0 && ( {bindings.length > 0 && bindingWarnings === 0 && (
<div className={styles.diagSummaryOk}> <div className={styles.diagSummaryOk}>
@ -1142,7 +1180,7 @@ function ActionInspector(): React.ReactElement {
<div className={styles.recordList}> <div className={styles.recordList}>
{bindings.map((binding, bindingIndex) => ( {bindings.map((binding, bindingIndex) => (
<div className={styles.recordGroup} key={`${binding.id}-${bindingIndex}`}> <div className={styles.recordGroup} key={`${binding.id}-${bindingIndex}`}>
<BindingCard binding={binding} allComponents={allComponents} allActions={actions} diags={diagMap[binding.id] ?? []} onEdit={() => setEditingBindingIndex(bindingIndex)} onDelete={() => { <BindingCard binding={binding} allComponents={allComponents} allActions={actions} diags={diagMap[binding.id] ?? []} expanded={openBindings.has(`${binding.id}-${bindingIndex}`)} onToggle={() => toggleOpen(setOpenBindings, `${binding.id}-${bindingIndex}`)} onEdit={() => { setEditingBindingIndex(bindingIndex); setOpenBindings((current) => new Set(current).add(`${binding.id}-${bindingIndex}`)); }} onDelete={() => {
if (window.confirm(`Delete binding "${binding.id}"?`)) { if (window.confirm(`Delete binding "${binding.id}"?`)) {
setDoc((current) => removeBindingAt(current, bindingIndex)); setDoc((current) => removeBindingAt(current, bindingIndex));
setEditingBindingIndex((current) => { setEditingBindingIndex((current) => {
@ -1156,29 +1194,36 @@ function ActionInspector(): React.ReactElement {
))} ))}
</div> </div>
)} )}
</div>
</div> </div>
<div className={styles.section}> <div className={styles.section}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<div> <button type="button" className={styles.sectionToggle} onClick={() => toggleOpen(setOpenSections, 'variables')} aria-expanded={openSections.has('variables')} aria-label={`${openSections.has('variables') ? 'Collapse' : 'Expand'} Variables section`}>
<div className={styles.sectionTitle}>Variables</div> <span className={styles.chevron} aria-hidden="true">{openSections.has('variables') ? '▾' : '▸'}</span>
<div className={styles.sectionDescription}>Typed defaults copied into ephemeral Preview runtime state.</div> <span><span className={styles.sectionTitle}>Variables</span><span className={styles.sectionDescription}>Typed defaults copied into ephemeral Preview runtime state.</span></span>
</div> </button>
<div className={styles.sectionHeaderMeta}><span className={styles.sectionCount}>{Object.keys(variables).length}</span><button type="button" className={styles.primaryButton} data-testid="add-variable" disabled={addingVariable || editingVariableName !== null} onClick={() => setAddingVariable(true)}>+ Add variable</button></div> <div className={styles.sectionHeaderMeta}><span className={styles.sectionCount}>{Object.keys(variables).length}</span><button type="button" className={styles.primaryButton} data-testid="add-variable" disabled={addingVariable || editingVariableName !== null} onClick={() => { setOpenSections((current) => new Set(current).add('variables')); setAddingVariable(true); }}>+ Add variable</button></div>
</div> </div>
<div className={styles.sectionBody} hidden={!openSections.has('variables')}>
{addingVariable && <VariableEditor name="" variable={{ type: 'string' }} variables={variables} isNew onSave={(name, variable) => { setDoc((current) => setVariable(current, name, variable)); setAddingVariable(false); }} onCancel={() => setAddingVariable(false)} />} {addingVariable && <VariableEditor name="" variable={{ type: 'string' }} variables={variables} isNew onSave={(name, variable) => { setDoc((current) => setVariable(current, name, variable)); setAddingVariable(false); }} onCancel={() => setAddingVariable(false)} />}
{Object.keys(variables).length === 0 && !addingVariable ? <div className={styles.empty}>No variables declared.</div> : <div className={styles.recordList}>{Object.entries(variables).map(([name, variable]) => ( {Object.keys(variables).length === 0 && !addingVariable ? <div className={styles.empty}>No variables declared.</div> : <div className={styles.recordList}>{Object.entries(variables).map(([name, variable]) => (
<div className={styles.recordGroup} key={name}> <div className={styles.recordGroup} key={name}>
<div className={styles.variableCard}> <div className={styles.variableCard}>
<div className={styles.recordType}>Runtime variable</div> <button type="button" className={styles.cardToggle} aria-expanded={openVariables.has(name)} aria-label={`${openVariables.has(name) ? 'Collapse' : 'Expand'} variable ${name}`} onClick={() => toggleOpen(setOpenVariables, name)}>
<div className={styles.bindingCardHeader}><span className={styles.variableName}>{name}</span> <span className={styles.triggerBadge}>{variable.type}</span></div> <span className={styles.toggleHeadingRow}><span className={styles.recordType}>Runtime variable</span><span className={styles.chevron} aria-hidden="true">{openVariables.has(name) ? '▾' : '▸'}</span></span>
<span className={styles.bindingCardHeader}><span className={styles.variableName}>{name}</span> <span className={styles.triggerBadge}>{variable.type}</span></span>
</button>
{openVariables.has(name) && <div className={styles.cardDetails}>
<div className={styles.variableDefault}><span className={styles.bindingLabel}>Default</span><code className={styles.bindingExpr}>{variable.defaultValue === undefined ? 'No default value' : JSON.stringify(variable.defaultValue)}</code></div> <div className={styles.variableDefault}><span className={styles.bindingLabel}>Default</span><code className={styles.bindingExpr}>{variable.defaultValue === undefined ? 'No default value' : JSON.stringify(variable.defaultValue)}</code></div>
{variable.description && <span className={styles.actionDescription}>{variable.description}</span>} {variable.description && <span className={styles.actionDescription}>{variable.description}</span>}
<div className={styles.actionControls}><button type="button" className={styles.smallButton} aria-label={`Edit variable ${name}`} onClick={() => setEditingVariableName(name)}>Edit</button><button type="button" className={styles.dangerButton} aria-label={`Delete variable ${name}`} onClick={() => handleDeleteVariable(name)}>Delete</button></div> <div className={styles.actionControls}><button type="button" className={styles.smallButton} aria-label={`Edit variable ${name}`} onClick={() => { setEditingVariableName(name); setOpenVariables((current) => new Set(current).add(name)); }}>Edit</button><button type="button" className={styles.dangerButton} aria-label={`Delete variable ${name}`} onClick={() => handleDeleteVariable(name)}>Delete</button></div>
</div>}
</div> </div>
{editingVariableName === name && <VariableEditor name={name} variable={variable} variables={variables} onSave={(nextName, nextVariable) => { setDoc((current) => setVariable(current, nextName, nextVariable, name)); setEditingVariableName(null); }} onCancel={() => setEditingVariableName(null)} />} {editingVariableName === name && <VariableEditor name={name} variable={variable} variables={variables} onSave={(nextName, nextVariable) => { setDoc((current) => setVariable(current, nextName, nextVariable, name)); setEditingVariableName(null); }} onCancel={() => setEditingVariableName(null)} />}
</div> </div>
))}</div>} ))}</div>}
</div>
</div> </div>
{/* ══ Overall summary (only shown when there are issues) ═══════ */} {/* ══ Overall summary (only shown when there are issues) ═══════ */}

View File

@ -343,6 +343,9 @@ function RestActionEditor({
</div> </div>
)} )}
<details className={styles.actionEditorDetails} open>
<summary>Identity</summary>
<div className={styles.detailsBody}>
<div className={styles.formGrid}> <div className={styles.formGrid}>
<label className={styles.formField}> <label className={styles.formField}>
<span className={styles.formLabel}>Action ID</span> <span className={styles.formLabel}>Action ID</span>
@ -383,7 +386,12 @@ function RestActionEditor({
rows={2} rows={2}
/> />
</label> </label>
</div>
</details>
<details className={styles.actionEditorDetails} open>
<summary>Endpoint &amp; authentication</summary>
<div className={styles.detailsBody}>
<div className={styles.endpointRow}> <div className={styles.endpointRow}>
<label className={styles.methodField}> <label className={styles.methodField}>
<span className={styles.formLabel}>HTTP method</span> <span className={styles.formLabel}>HTTP method</span>
@ -475,8 +483,10 @@ function RestActionEditor({
</span> </span>
</label> </label>
)} )}
</div>
</details>
<details className={styles.actionEditorDetails} open> <details className={styles.actionEditorDetails}>
<summary>Request parameters</summary> <summary>Request parameters</summary>
<div className={styles.detailsBody}> <div className={styles.detailsBody}>
<RequestInputEditor <RequestInputEditor
@ -526,7 +536,7 @@ function RestActionEditor({
</div> </div>
</details> </details>
<details className={styles.actionEditorDetails} open> <details className={styles.actionEditorDetails}>
<summary>Request body</summary> <summary>Request body</summary>
<div className={styles.detailsBody}> <div className={styles.detailsBody}>
<div className={styles.formField}> <div className={styles.formField}>

View File

@ -18,14 +18,27 @@ describe('SecretManager', () => {
beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); }); beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); });
afterEach(() => { act(() => root.unmount()); container.remove(); jest.resetAllMocks(); }); afterEach(() => { act(() => root.unmount()); container.remove(); jest.resetAllMocks(); });
test('starts with compact section and card summaries that expose expansion state', async () => {
await act(async () => { root.render(<SecretManager secrets={[metadata]} actions={[]} onChanged={jest.fn()} />); });
const section = container.querySelector<HTMLButtonElement>('button[aria-label="Expand Secrets section"]');
expect(section?.getAttribute('aria-expanded')).toBe('false');
await act(async () => { section!.click(); });
const card = container.querySelector<HTMLButtonElement>('button[aria-label="Expand secret Production token"]');
expect(card?.getAttribute('aria-expanded')).toBe('false');
expect(container.textContent).not.toContain('Replace value');
await act(async () => { card!.click(); });
expect(container.textContent).toContain('Replace value');
});
test('submits a masked credential and refreshes metadata without displaying its value', async () => { test('submits a masked credential and refreshes metadata without displaying its value', async () => {
mockedApi.createSecret.mockResolvedValue(metadata); mockedApi.createSecret.mockResolvedValue(metadata);
const changed = jest.fn().mockResolvedValue(undefined); const changed = jest.fn().mockResolvedValue(undefined);
await act(async () => { root.render(<SecretManager secrets={[]} actions={[]} onChanged={changed} />); }); await act(async () => { root.render(<SecretManager secrets={[]} actions={[]} onChanged={changed} />); });
await act(async () => { Array.from(container.querySelectorAll('button')).find((button) => button.textContent === '+ Add secret')!.click(); });
const inputs = container.querySelectorAll<HTMLInputElement>('input'); const inputs = container.querySelectorAll<HTMLInputElement>('input');
setInput(inputs[0], 'Production token'); setInput(inputs[1], 'stored-token-value'); setInput(inputs[0], 'Production token'); setInput(inputs[1], 'stored-token-value');
expect(inputs[1].type).toBe('password'); expect(inputs[1].type).toBe('password');
await act(async () => { container.querySelector<HTMLButtonElement>('button')!.click(); }); await act(async () => { Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Add secret')!.click(); });
expect(mockedApi.createSecret).toHaveBeenCalledWith('Production token', 'bearerToken', { token: 'stored-token-value' }); expect(mockedApi.createSecret).toHaveBeenCalledWith('Production token', 'bearerToken', { token: 'stored-token-value' });
expect(changed).toHaveBeenCalled(); expect(changed).toHaveBeenCalled();
expect(container.textContent).not.toContain('stored-token-value'); expect(container.textContent).not.toContain('stored-token-value');
@ -33,6 +46,8 @@ describe('SecretManager', () => {
test('blocks deletion when an unsaved action references the secret', async () => { test('blocks deletion when an unsaved action references the secret', async () => {
await act(async () => { root.render(<SecretManager secrets={[metadata]} actions={[{ id: 'a', name: 'Protected request', method: 'GET', url: 'https://example.test', authenticationType: 'bearerToken', secretReferenceId: metadata.id }]} onChanged={jest.fn()} />); }); await act(async () => { root.render(<SecretManager secrets={[metadata]} actions={[{ id: 'a', name: 'Protected request', method: 'GET', url: 'https://example.test', authenticationType: 'bearerToken', secretReferenceId: metadata.id }]} onChanged={jest.fn()} />); });
await act(async () => { container.querySelector<HTMLButtonElement>('button[aria-label="Expand Secrets section"]')!.click(); });
await act(async () => { container.querySelector<HTMLButtonElement>('button[aria-label="Expand secret Production token"]')!.click(); });
const deleteButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Delete')!; const deleteButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Delete')!;
await act(async () => { deleteButton.click(); }); await act(async () => { deleteButton.click(); });
expect(mockedApi.deleteSecret).not.toHaveBeenCalled(); expect(mockedApi.deleteSecret).not.toHaveBeenCalled();

View File

@ -11,11 +11,14 @@ const types: Array<{ value: CredentialType; label: string }> = [
]; ];
export default function SecretManager({ secrets, actions, onChanged }: Props): React.ReactElement { export default function SecretManager({ secrets, actions, onChanged }: Props): React.ReactElement {
const [sectionOpen, setSectionOpen] = useState(false);
const [openSecrets, setOpenSecrets] = useState<Set<string>>(() => new Set());
const [adding, setAdding] = useState(false);
const [editing, setEditing] = useState<SecretMetadata | null>(null); const [editing, setEditing] = useState<SecretMetadata | null>(null);
const [name, setName] = useState(''); const [type, setType] = useState<CredentialType>('bearerToken'); const [name, setName] = useState(''); const [type, setType] = useState<CredentialType>('bearerToken');
const [first, setFirst] = useState(''); const [second, setSecond] = useState(''); const [first, setFirst] = useState(''); const [second, setSecond] = useState('');
const [error, setError] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [busy, setBusy] = useState(false);
const reset = () => { setEditing(null); setName(''); setFirst(''); setSecond(''); setError(''); }; const reset = () => { setEditing(null); setAdding(false); setName(''); setFirst(''); setSecond(''); setError(''); };
const value = (): SecretValue => type === 'basicAuth' ? { username: first, password: second } : type === 'bearerToken' ? { token: first } : { parameterName: first, value: second }; const value = (): SecretValue => type === 'basicAuth' ? { username: first, password: second } : type === 'bearerToken' ? { token: first } : { parameterName: first, value: second };
const save = async () => { const save = async () => {
setBusy(true); setError(''); setBusy(true); setError('');
@ -28,13 +31,17 @@ export default function SecretManager({ secrets, actions, onChanged }: Props): R
if (!window.confirm(`Delete secret “${secret.name}”? This cannot be undone.`)) return; if (!window.confirm(`Delete secret “${secret.name}”? This cannot be undone.`)) return;
try { await api.deleteSecret(secret.id); await onChanged(); setError(''); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } try { await api.deleteSecret(secret.id); await onChanged(); setError(''); } catch (e) { setError(e instanceof Error ? e.message : String(e)); }
}; };
const toggleSecret = (id: string) => setOpenSecrets((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; });
return <div className={styles.section}> return <div className={styles.section}>
<div className={styles.sectionHeader}><div><div className={styles.sectionTitle}>Secrets</div><div className={styles.sectionDescription}>Encrypted server-side credentials. Stored values are never displayed after submission.</div></div><span className={styles.sectionCount}>{secrets.length}</span></div> <div className={styles.sectionHeader}><button type="button" className={styles.sectionToggle} aria-expanded={sectionOpen} aria-label={`${sectionOpen ? 'Collapse' : 'Expand'} Secrets section`} onClick={() => setSectionOpen((value) => !value)}><span className={styles.chevron} aria-hidden="true">{sectionOpen ? '▾' : '▸'}</span><span><span className={styles.sectionTitle}>Secrets</span><span className={styles.sectionDescription}>Encrypted server-side credentials. Stored values are never displayed after submission.</span></span></button><div className={styles.sectionHeaderMeta}><span className={styles.sectionCount}>{secrets.length}</span><button type="button" className={styles.primaryButton} disabled={adding || editing !== null} onClick={() => { setSectionOpen(true); setAdding(true); setName(''); setType('bearerToken'); setFirst(''); setSecond(''); setError(''); }}>+ Add secret</button></div></div>
{secrets.map((secret) => <div className={styles.recordGroup} key={secret.id}><div className={styles.actionCard}><strong>{secret.name}</strong> <span className={styles.authBadge}>{types.find((item) => item.value === secret.authenticationType)?.label}</span><div className={styles.actionId}>{secret.id}</div><div className={styles.actionControls}><button className={styles.smallButton} onClick={() => { setEditing(secret); setName(secret.name); setType(secret.authenticationType); setFirst(''); setSecond(''); }}>Replace value</button><button className={styles.dangerButton} onClick={() => void remove(secret)}>Delete</button></div></div></div>)} <div className={styles.sectionBody} hidden={!sectionOpen}>
<div className={styles.editorPanel}> <div className={styles.recordList}>{secrets.map((secret) => <div className={styles.recordGroup} key={secret.id}><div className={styles.actionCard}><button type="button" className={styles.cardToggle} aria-expanded={openSecrets.has(secret.id)} aria-label={`${openSecrets.has(secret.id) ? 'Collapse' : 'Expand'} secret ${secret.name}`} onClick={() => toggleSecret(secret.id)}><span className={styles.toggleHeadingRow}><span className={styles.recordType}>Stored secret</span><span className={styles.chevron} aria-hidden="true">{openSecrets.has(secret.id) ? '▾' : '▸'}</span></span><span className={styles.actionCardHeader}><strong className={styles.actionName}>{secret.name}</strong><span className={styles.authBadge}>{types.find((item) => item.value === secret.authenticationType)?.label}</span><span className={styles.actionId}>{secret.id}</span></span></button>{openSecrets.has(secret.id) && <div className={styles.cardDetails}><div className={styles.actionControls}><button className={styles.smallButton} onClick={() => { setAdding(false); setEditing(secret); setName(secret.name); setType(secret.authenticationType); setFirst(''); setSecond(''); setOpenSecrets((current) => new Set(current).add(secret.id)); }}>Replace value</button><button className={styles.dangerButton} onClick={() => void remove(secret)}>Delete</button></div></div>}</div></div>)}</div>
{error && <div className={styles.inlineError} role="alert">{error}</div>}
{(adding || editing) && <div className={styles.editorPanel}>
<div className={styles.formGrid}><label className={styles.formField}><span className={styles.formLabel}>Secret name</span><input className={styles.formInput} value={name} onChange={(e) => setName(e.target.value)} /></label><label className={styles.formField}><span className={styles.formLabel}>Authentication type</span><select className={styles.formSelect} value={type} disabled={!!editing} onChange={(e) => setType(e.target.value as CredentialType)}>{types.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label></div> <div className={styles.formGrid}><label className={styles.formField}><span className={styles.formLabel}>Secret name</span><input className={styles.formInput} value={name} onChange={(e) => setName(e.target.value)} /></label><label className={styles.formField}><span className={styles.formLabel}>Authentication type</span><select className={styles.formSelect} value={type} disabled={!!editing} onChange={(e) => setType(e.target.value as CredentialType)}>{types.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label></div>
<div className={styles.formGrid}><label className={styles.formField}><span className={styles.formLabel}>{type === 'basicAuth' ? 'Username' : type === 'bearerToken' ? 'Token' : type === 'apiKeyHeader' ? 'Header name' : 'Query parameter name'}</span><input className={styles.formInput} type={type === 'bearerToken' ? 'password' : 'text'} value={first} onChange={(e) => setFirst(e.target.value)} autoComplete="off" /></label>{type !== 'bearerToken' && <label className={styles.formField}><span className={styles.formLabel}>{type === 'basicAuth' ? 'Password' : 'API key'}</span><input className={styles.formInput} type="password" value={second} onChange={(e) => setSecond(e.target.value)} autoComplete="new-password" /></label>}</div> <div className={styles.formGrid}><label className={styles.formField}><span className={styles.formLabel}>{type === 'basicAuth' ? 'Username' : type === 'bearerToken' ? 'Token' : type === 'apiKeyHeader' ? 'Header name' : 'Query parameter name'}</span><input className={styles.formInput} type={type === 'bearerToken' ? 'password' : 'text'} value={first} onChange={(e) => setFirst(e.target.value)} autoComplete="off" /></label>{type !== 'bearerToken' && <label className={styles.formField}><span className={styles.formLabel}>{type === 'basicAuth' ? 'Password' : 'API key'}</span><input className={styles.formInput} type="password" value={second} onChange={(e) => setSecond(e.target.value)} autoComplete="new-password" /></label>}</div>
{error && <div className={styles.inlineError} role="alert">{error}</div>}<div className={styles.editorActions}><button className={styles.primaryButton} disabled={busy || !name.trim() || !first || (type !== 'bearerToken' && !second)} onClick={() => void save()}>{editing ? 'Replace secret' : 'Add secret'}</button>{editing && <button className={styles.smallButton} onClick={reset}>Cancel</button>}</div> <div className={styles.editorActions}><button className={styles.primaryButton} disabled={busy || !name.trim() || !first || (type !== 'bearerToken' && !second)} onClick={() => void save()}>{editing ? 'Replace secret' : 'Add secret'}</button><button className={styles.smallButton} onClick={reset}>Cancel</button></div>
</div>}
</div> </div>
</div>; </div>;
} }