Skip to content

Commit 64bd5ef

Browse files
committed
feat(clone): Phase 4 — warn on clones with missing auth headers
Autonomous decisions: - Light clone-wizard touch (header-name preview, not a new form step) — Phase 1 already seeds parent extra_headers; a preview plus updated footer copy is enough visibility at create time - Expected header keys from parent extra_headers plus required ${input:ID} HTTP definition headers — reuses existing clone/parent and registry shapes without new backend metadata - Warn-only on enable/reconnect/refresh/retry via toast plus persistent row banner — matches Decision #3; no blocking of enable or connect Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 5efd85d commit 64bd5ef

3 files changed

Lines changed: 127 additions & 1 deletion

File tree

apps/desktop/src/features/servers/CloneAccountModal.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ export function CloneAccountModal({
5454
const previewAlias = deriveCloneAlias(suffix);
5555
const hasSuffix = suffix.trim().length > 0;
5656
const hasCollision = hasSuffix && isAvailable === false;
57+
const sourceHeaderKeys =
58+
sourceServer.transport.type === 'http'
59+
? Object.keys(sourceServer.extra_headers ?? {}).filter((key) => key.trim())
60+
: [];
5761

5862
useEffect(() => {
5963
if (!open) {
@@ -293,6 +297,31 @@ export function CloneAccountModal({
293297
</div>
294298
)}
295299

300+
{sourceServer.transport.type === 'http' && (
301+
<div className="rounded-lg border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface-dim))] p-3">
302+
<p className="text-sm font-medium text-[rgb(var(--foreground))]">
303+
{t('cloneModal.headersPreviewTitle')}
304+
</p>
305+
<p className="mt-1 text-xs text-[rgb(var(--muted))]">
306+
{t('cloneModal.headersPreviewDesc')}
307+
</p>
308+
{sourceHeaderKeys.length > 0 ? (
309+
<ul
310+
className="mt-2 space-y-1 font-mono text-xs text-[rgb(var(--foreground))]"
311+
data-testid="clone-headers-preview"
312+
>
313+
{sourceHeaderKeys.map((headerKey) => (
314+
<li key={headerKey}>{headerKey}</li>
315+
))}
316+
</ul>
317+
) : (
318+
<p className="mt-2 text-xs text-[rgb(var(--muted))]">
319+
{t('cloneModal.headersPreviewEmpty')}
320+
</p>
321+
)}
322+
</div>
323+
)}
324+
296325
<p className="text-xs text-[rgb(var(--muted))]">{t('cloneModal.footerNote')}</p>
297326

298327
{submitError && (

apps/desktop/src/features/servers/ServersPage.tsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,70 @@ function resolveCloneSource(
112112
return current;
113113
}
114114

115+
/**
116+
* Resolve the parent server row used to infer expected HTTP header keys for a clone.
117+
*/
118+
function getCloneHeaderParent(
119+
server: ServerViewModelWithClone,
120+
allServers: ServerViewModelWithClone[]
121+
): ServerViewModelWithClone | undefined {
122+
if (!server.cloned_from) {
123+
return undefined;
124+
}
125+
126+
return (
127+
allServers.find((candidate) => candidate.id === server.cloned_from) ??
128+
resolveCloneSource(server, allServers)
129+
);
130+
}
131+
132+
/**
133+
* Header keys a clone is expected to override based on its parent and HTTP definition.
134+
*/
135+
function getExpectedCloneHeaderKeys(
136+
server: ServerViewModelWithClone,
137+
allServers: ServerViewModelWithClone[]
138+
): string[] {
139+
if (!server.cloned_from || server.transport.type !== 'http') {
140+
return [];
141+
}
142+
143+
const keys = new Set<string>();
144+
145+
const parent = getCloneHeaderParent(server, allServers);
146+
if (parent?.extra_headers) {
147+
for (const key of Object.keys(parent.extra_headers)) {
148+
if (key.trim()) {
149+
keys.add(key);
150+
}
151+
}
152+
}
153+
154+
const inputs = server.transport.metadata?.inputs ?? [];
155+
const requiredInputIds = new Set(
156+
inputs.filter((input) => input.required).map((input) => input.id)
157+
);
158+
for (const [headerKey, headerValue] of Object.entries(server.transport.headers ?? {})) {
159+
const match = headerValue.match(/\$\{input:([^}]+)\}/);
160+
if (match && requiredInputIds.has(match[1])) {
161+
keys.add(headerKey);
162+
}
163+
}
164+
165+
return [...keys];
166+
}
167+
168+
/**
169+
* Returns true when a clone is missing expected HTTP header overrides.
170+
*/
171+
function hasCloneMissingAuthHeaders(
172+
server: ServerViewModelWithClone,
173+
allServers: ServerViewModelWithClone[]
174+
): boolean {
175+
const cloneHeaders = server.extra_headers ?? {};
176+
return getExpectedCloneHeaderKeys(server, allServers).some((key) => !cloneHeaders[key]?.trim());
177+
}
178+
115179
// Helper to merge definitions with states (same as registryStore)
116180
function mergeDefinitionsWithStates(
117181
definitions: ServerDefinition[],
@@ -389,6 +453,18 @@ export function ServersPage() {
389453
setTimeout(() => setToast(null), 5000);
390454
}, []);
391455

456+
/**
457+
* Show a non-blocking toast when a clone is missing expected HTTP header overrides.
458+
*/
459+
const warnCloneMissingAuthHeaders = useCallback(
460+
(server: ServerViewModel) => {
461+
if (hasCloneMissingAuthHeaders(server, installedServers)) {
462+
showToast(t('cloneAuthWarning.connectToast'), 'warning');
463+
}
464+
},
465+
[installedServers, showToast, t]
466+
);
467+
392468
const loadData = useCallback(async () => {
393469
try {
394470
setIsLoading(true);
@@ -850,6 +926,8 @@ export function ServersPage() {
850926
return;
851927
}
852928

929+
warnCloneMissingAuthHeaders(server);
930+
853931
setActionLoading(`enable-${server.id}`);
854932
// Optimistically mark as enabled so runtime status events (Connecting/Error)
855933
// are reflected in the UI immediately instead of showing stale "Enable" button
@@ -1216,6 +1294,7 @@ export function ServersPage() {
12161294

12171295
// Retry connection - uses new ServerManager v2
12181296
const handleRetry = async (server: ServerViewModel) => {
1297+
warnCloneMissingAuthHeaders(server);
12191298
setActionLoading(`retry-${server.id}`);
12201299
try {
12211300
await retryConnectionV2(server.id);
@@ -1441,6 +1520,7 @@ export function ServersPage() {
14411520
// Refresh server - Quick reconnect with EXISTING credentials
14421521
// If succeeds → connected, if fails → shows Connect button
14431522
const handleRefresh = async (server: ServerViewModel) => {
1523+
warnCloneMissingAuthHeaders(server);
14441524
setActionLoading(`refresh-${server.id}`);
14451525
try {
14461526
await retryConnectionV2(server.id);
@@ -1455,6 +1535,7 @@ export function ServersPage() {
14551535
// Reconnect server - Logout + auto-start OAuth (for OAuth servers)
14561536
// For non-OAuth servers, just does a fresh connection
14571537
const handleReconnect = async (server: ServerViewModel) => {
1538+
warnCloneMissingAuthHeaders(server);
14581539
setActionLoading(`reconnect-${server.id}`);
14591540
try {
14601541
// OAuth is detected at runtime - check if server has oauth_connected or auth type
@@ -1795,6 +1876,15 @@ export function ServersPage() {
17951876
</button>
17961877
</div>
17971878
)}
1879+
1880+
{hasCloneMissingAuthHeaders(server, installedServers) && (
1881+
<div
1882+
className="mt-2 px-3 py-2 rounded-lg text-xs bg-[rgb(var(--warning))]/10 text-[rgb(var(--warning))]"
1883+
data-testid={`clone-auth-warning-${server.id}`}
1884+
>
1885+
{t('cloneAuthWarning.banner')}
1886+
</div>
1887+
)}
17981888
</div>
17991889
</div>
18001890

apps/desktop/src/locales/en/servers.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,17 @@
165165
"serverId": "Server ID",
166166
"toolPrefix": "Tool prefix",
167167
"checkingAvailability": "Checking availability…",
168-
"footerNote": "The clone copies the server definition but not credentials. You will configure this account before enabling it.",
168+
"headersPreviewTitle": "HTTP headers copied from source",
169+
"headersPreviewDesc": "Header names from the source account are copied into the new clone. Update their values in Configure before enabling.",
170+
"headersPreviewEmpty": "No custom HTTP headers on the source account.",
171+
"footerNote": "The clone copies the server definition and HTTP header overrides from the source. Swap account-specific header values in Configure before enabling.",
169172
"creating": "Creating…",
170173
"createAccount": "Create account"
171174
},
175+
"cloneAuthWarning": {
176+
"banner": "This clone may be using the wrong credentials — review its headers in Configure.",
177+
"connectToast": "This clone is missing HTTP headers from its source — review Configure before connecting."
178+
},
172179
"uninstallClones": {
173180
"title": "Uninstall server with account clones?",
174181
"description_one": "{{sourceName}} has {{count}} account clone in this space: {{dependentList}}.",

0 commit comments

Comments
 (0)