Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/src/wizard/assets/index.html

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion cli/src/wizard/bundle-meta/index.hash
Original file line number Diff line number Diff line change
@@ -1 +1 @@
62ff938228e4ec18e5fa1a811ec1f9fe4cbe015f89cd26966722a926cc175377
56f4f63078e728e5700f0c57bd4418ba2f02100ebb08a84591686aca9c402cd5
110 changes: 108 additions & 2 deletions frontend/src/components/cli-wizard/ai-key-confirm-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
// point — regression guard for the existing `service add <slug>`
// flow.

const { mockPost } = vi.hoisted(() => ({
const { mockGet, mockPost } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPost: vi.fn(),
}));

vi.mock("@/lib/api-client", () => ({
api: {
post: mockPost,
get: vi.fn(),
get: mockGet,
},
ApiError: class ApiError extends Error {
status: number;
Expand Down Expand Up @@ -421,3 +422,108 @@ describe("AiKeyConfirm — custom-service back reset", () => {
expect(onSlugPicked).toHaveBeenCalledTimes(1);
});
});

describe("AiKeyConfirm — catalog services routed via node", () => {
const catalogEntry = {
slug: "llm-openai",
name: "OpenAI",
base_url: "https://api.openai.com",
auth_method: "bearer",
service_type: "rest",
requires_credential: true,
requires_gateway_url: false,
};

beforeEach(() => {
mockGet.mockReset();
mockPost.mockReset();
mockGet.mockResolvedValue(catalogEntry);
baseProps.onSuccess = vi.fn();
baseProps.onSlugPicked = vi.fn();
});

it("hides credential, shows node badge, and omits credential in POST body", async () => {
const user = userEvent.setup();
mockPost.mockResolvedValue({
id: "svc-id-abc",
slug: "llm-openai",
label: "Test",
});

render(
<AiKeyConfirm
{...baseProps}
prefill={{
slug: "llm-openai",
via_node: "node-uuid-12345",
label: "Test",
}}
/>,
{ wrapper: createWrapper() },
);

await screen.findByText(/Routed via node/i);

expect(screen.queryByLabelText(/API key/i)).not.toBeInTheDocument();
expect(screen.getByText("node-uuid-12345")).toBeInTheDocument();

await user.click(screen.getByRole("button", { name: /Connect via node/i }));

await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(
"/keys",
expect.objectContaining({
service_slug: "llm-openai",
label: "Test",
node_id: "node-uuid-12345",
}),
);
});

const [, body] = mockPost.mock.calls.find(([path]) => path === "/keys") ?? [];
expect(body).not.toHaveProperty("credential");
});

it("enables submit without a credential when via_node is set", async () => {
render(
<AiKeyConfirm
{...baseProps}
prefill={{
slug: "llm-openai",
via_node: "node-uuid-12345",
label: "Test",
}}
/>,
{ wrapper: createWrapper() },
);

await screen.findByText(/Routed via node/i);

expect(
screen.getByRole("button", { name: /Connect via node/i }),
).not.toBeDisabled();
});

it("keeps submit disabled when label is empty", async () => {
const user = userEvent.setup();
render(
<AiKeyConfirm
{...baseProps}
prefill={{
slug: "llm-openai",
via_node: "node-uuid-12345",
label: "Test",
}}
/>,
{ wrapper: createWrapper() },
);

await screen.findByText(/Routed via node/i);

await user.clear(screen.getByLabelText("Label"));

expect(
screen.getByRole("button", { name: /Connect via node/i }),
).toBeDisabled();
});
});
31 changes: 23 additions & 8 deletions frontend/src/components/cli-wizard/ai-key-confirm-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,7 @@ function CatalogConfirmForm({
const [tokenFields, setTokenFields] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const viaNode = prefill.via_node?.trim() ?? "";
// Set once the user clicks "Continue" for an OAuth / device-code
// flow; drops the confirm UI and mounts the sub-flow panel. Reset
// to `null` if the sub-flow reports cancel so the user can edit
Expand All @@ -676,7 +677,7 @@ function CatalogConfirmForm({
service_slug: entry.slug,
label,
};
if (shape === "token-exchange") {
if (shape === "token-exchange" && !viaNode) {
// Multi-field credential: validate required-ness and JSON-
// encode. Mirror wizard.js submit path at wizard.js:723-734.
const fields = entry.token_exchange_credential_fields ?? [];
Expand All @@ -691,7 +692,7 @@ function CatalogConfirmForm({
creds[f.name] = val;
}
body.credential = JSON.stringify(creds);
} else if (shape === "api-key" && entry.requires_credential) {
} else if (shape === "api-key" && entry.requires_credential && !viaNode) {
body.credential = credential;
}
// `no-auth` / `oauth` / `device-code` skip credential entirely;
Expand All @@ -700,8 +701,8 @@ function CatalogConfirmForm({
if (entry.requires_gateway_url || endpointUrl) {
body.endpoint_url = endpointUrl;
}
if (prefill.via_node) {
body.node_id = prefill.via_node;
if (viaNode) {
body.node_id = viaNode;
}

// Reserve the destructive action server-side before creating
Expand Down Expand Up @@ -873,6 +874,7 @@ function CatalogConfirmForm({
const needsCredentialInput = shape === "api-key" && entry.requires_credential;
const submitLabel = (() => {
if (loading) return "Creating...";
if (viaNode) return "Connect via node";
if (shape === "oauth") return "Continue with provider sign-in";
if (shape === "device-code") return "Get device code";
if (shape === "no-auth") return "Connect";
Expand All @@ -889,9 +891,9 @@ function CatalogConfirmForm({
const submitDisabled =
loading ||
!label.trim() ||
(needsCredentialInput && !credential.trim()) ||
(needsCredentialInput && !viaNode && !credential.trim()) ||
(entry.requires_gateway_url && !endpointUrl.trim()) ||
!tokenExchangeComplete;
(!viaNode && !tokenExchangeComplete);

function handleSubmit() {
if (shape === "oauth" || shape === "device-code") {
Expand Down Expand Up @@ -951,7 +953,7 @@ function CatalogConfirmForm({
</Field>
) : null}

{needsCredentialInput ? (
{needsCredentialInput && !viaNode ? (
<Field label="API key" htmlFor="pair-aikey-credential">
<Input
id="pair-aikey-credential"
Expand All @@ -977,7 +979,7 @@ function CatalogConfirmForm({
</Field>
) : null}

{shape === "token-exchange"
{shape === "token-exchange" && !viaNode
? (entry.token_exchange_credential_fields ?? []).map((f) => (
<Field
key={f.name}
Expand All @@ -999,6 +1001,19 @@ function CatalogConfirmForm({
))
: null}

{viaNode ? (
<div className="rounded-[10px] border border-border bg-muted/40 px-3 py-2">
<p className="text-xs font-medium text-foreground">Routed via node</p>
<code className="font-mono text-[11px] text-muted-foreground">
{viaNode}
</code>
<p className="text-[11px] text-muted-foreground mt-1">
Credential will be configured on the node agent. NyxID
never sees or stores it.
</p>
</div>
) : null}

{shape === "no-auth" ? (
<p className="text-xs text-muted-foreground">
This service doesn't need a credential. Click Connect to
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/wizard-entry.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";

import { shouldShowDisconnectBanner } from "./wizard-entry";

describe("shouldShowDisconnectBanner", () => {
it("returns false when the CLI is still connected", () => {
expect(shouldShowDisconnectBanner("claimed", false)).toBe(false);
});

it.each(["claimed", "secret", "acking"] as const)(
"returns true for disconnected non-terminal phase %s",
(phase) => {
expect(shouldShowDisconnectBanner(phase, true)).toBe(true);
},
);

it.each(["done", "cancelled"] as const)(
"returns false for disconnected terminal phase %s",
(phase) => {
expect(shouldShowDisconnectBanner(phase, true)).toBe(false);
},
);
});
12 changes: 11 additions & 1 deletion frontend/src/wizard-entry.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/* eslint-disable react-refresh/only-export-components -- standalone entry exports a pure helper for focused tests. */

/**
* Standalone entry for the CLI's locally-served wizard (Mode A).
*
Expand Down Expand Up @@ -105,6 +107,14 @@ const queryClient = new QueryClient({
},
})

export function shouldShowDisconnectBanner(
phase: ModeAPhase["phase"],
disconnected: boolean,
): boolean {
if (!disconnected) return false
return phase !== "done" && phase !== "cancelled"
}

function WizardApp() {
const [stage, setStage] = useState<ModeAPhase>({ phase: "claimed" })
const [completeError, setCompleteError] = useState<string | null>(null)
Expand Down Expand Up @@ -167,7 +177,7 @@ function WizardApp() {

return (
<WizardShell context="local" step={step}>
{disconnected ? (
{shouldShowDisconnectBanner(stage.phase, disconnected) ? (
<DisconnectBanner state="disconnected" context="local" />
) : null}
{stage.phase === "claimed" ? (
Expand Down
Loading