Skip to content
Open
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
78 changes: 78 additions & 0 deletions src/components/settings/ConfigurationSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, waitFor } from "@testing-library/react";
import { IntlProvider } from "react-intl";
import { toast } from "sonner";

import { importExportApi, type ImportResult } from "@/api/importExport";
import enMessages from "@/i18n/locales/en-US";

import { ConfigurationSettings } from "./ConfigurationSettings";

vi.mock("sonner", () => ({ toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn() } }));
vi.mock("@/auth/useAuth", () => ({ useAuth: () => ({ hasPermission: () => true }) }));
vi.mock("@/api/importExport", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/api/importExport")>();
return { ...actual, importExportApi: { importConfig: vi.fn() } };
});

const RESULT: ImportResult = {
import_id: "imp-1",
status: "completed",
progress: { total: 2, processed: 2, created: 1, updated: 1, skipped: 0, failed: 0 },
errors: [],
warnings: [],
started_at: "2026-11-02T10:00:00Z",
completed_at: "2026-11-02T10:00:01Z",
};

const EXPORT = {
version: "1",
exported_at: "2026-11-02T10:00:00Z",
entities: { gateways: [{ name: "g1" }], servers: [{ name: "s1" }, { name: "s2" }] },
};

// jsdom's File has no .text(); parseImportFile only needs that.
const FILE = { text: async () => JSON.stringify(EXPORT) } as unknown as File;

function pickVia(buttonName: string) {
const { getByRole, getByTestId } = render(
<IntlProvider locale="en" messages={enMessages}>
<ConfigurationSettings />
</IntlProvider>,
);
fireEvent.click(getByRole("button", { name: buttonName }));
fireEvent.change(getByTestId("import-config-input"), { target: { files: [FILE] } });
}

describe("ConfigurationSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(importExportApi.importConfig).mockResolvedValue(RESULT);
});

it("imports the picked file and toasts the outcome", async () => {
pickVia("Import configuration");

await waitFor(() => expect(toast.success).toHaveBeenCalledTimes(1));
expect(vi.mocked(toast.success).mock.calls[0][0]).toBe(
"Imported 1 MCP server and 2 virtual servers: 1 created, 1 updated, 0 skipped.",
);
expect(importExportApi.importConfig).toHaveBeenCalledTimes(1);
expect(vi.mocked(importExportApi.importConfig).mock.calls[0][0]).toMatchObject({
data: EXPORT,
dryRun: false,
});
});

it("dry run summarises the file by type and says nothing changed", async () => {
pickVia("Dry run");

await waitFor(() => expect(toast.success).toHaveBeenCalledTimes(1));
expect(vi.mocked(importExportApi.importConfig).mock.calls[0][0]).toMatchObject({
dryRun: true,
});
expect(vi.mocked(toast.success).mock.calls[0][0]).toBe(
"Dry run: would import 1 MCP server and 2 virtual servers. Nothing was changed.",
);
});
});
111 changes: 111 additions & 0 deletions src/components/settings/ConfigurationSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { useEffect, useRef } from "react";
import { useIntl, type IntlShape } from "react-intl";
import { toast } from "sonner";
import { ArrowDownFromLine } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ConfigExport, ExportEntityType } from "@/api/importExport";
import { importOutcome, useImportConfig } from "@/hooks/useImportConfig";

const OUTCOME_TOAST = {
success: { fn: toast.success, id: "settings.configuration.import.success" },
partial: { fn: toast.warning, id: "settings.configuration.import.partial" },
failed: { fn: toast.error, id: "settings.configuration.import.allFailed" },
} as const;

const TYPE_ORDER: ExportEntityType[] = [
"gateways",
"servers",
"tools",
"prompts",
"resources",
"roots",
];

// Backend reports only aggregate counts (and none on dry run), so the per-type
// split comes from the file; it matches what's processed since we never send selected_entities.
function typeSummary(data: ConfigExport, intl: IntlShape): string | null {
const items = TYPE_ORDER.filter((t) => data.entities[t]?.length).map((t) =>
intl.formatMessage(
{ id: `settings.configuration.type.${t}` },
{ count: data.entities[t]!.length },
),
);
return items.length ? intl.formatList(items, { type: "conjunction" }) : null;
}

export function ConfigurationSettings() {
const intl = useIntl();
const title = intl.formatMessage({ id: "settings.configuration.import.title" });
const { loadFile, runImport, dryRun, reset, data, isLoading, error, canImport } =
useImportConfig();
const inputRef = useRef<HTMLInputElement>(null);
const dryRunRef = useRef(false); // which button opened the picker

// Submit as soon as a file parses; reset after so data can't re-trigger it.
useEffect(() => {
if (!data) return;
const isDry = dryRunRef.current;
const items = typeSummary(data, intl);
void (isDry ? dryRun : runImport)().then((result) => {
if (!result) return;
const { fn, id } = OUTCOME_TOAST[importOutcome(result)];
fn(
!items
? intl.formatMessage({ id: "settings.configuration.empty" })
: intl.formatMessage(
{ id: isDry ? "settings.configuration.dryRun.result" : id },
{ ...result.progress, items },
),
);
reset();
});
}, [data, runImport, dryRun, reset, intl]);

const pick = (isDry: boolean) => {
dryRunRef.current = isDry;
inputRef.current?.click();
};

useEffect(() => {
if (error) toast.error(error);
}, [error]);

return (
<section className="flex flex-col items-start gap-2 rounded-lg border p-6 mt-6">
<h2 className="flex items-center gap-2 text-base font-semibold">
<span className="flex size-6 items-center justify-center rounded-sm bg-config-icon-bg text-black">
<ArrowDownFromLine className="size-4" aria-hidden />
</span>
{title}
</h2>
<p className="py-4 text-[13px] leading-4">
{intl.formatMessage({ id: "settings.configuration.import.description" })}
</p>
<input
ref={inputRef}
type="file"
accept=".json,application/json"
hidden
data-testid="import-config-input"
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = ""; // allow re-picking the same file
if (file) void loadFile(file);
}}
/>
<div className="flex gap-2">
<Button size="sm" disabled={!canImport || isLoading} onClick={() => pick(false)}>
{title}
</Button>
<Button
size="sm"
variant="outline"
disabled={!canImport || isLoading}
onClick={() => pick(true)}
>
{intl.formatMessage({ id: "settings.configuration.dryRun.title" })}
</Button>
</div>
</section>
);
}
5 changes: 1 addition & 4 deletions src/i18n/locales/en-US/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,11 @@
"common.team.label": "Team",
"common.team.placeholder": "Select a team",
"importExport.export.failed": "Export failed",
"importExport.import.success": "Import complete: {created} created, {updated} updated, {skipped} skipped, {failed} failed",
"importExport.import.failed": "Import failed",
"importExport.import.invalidJson": "The selected file is not valid JSON",
"importExport.import.invalidShape": "The selected file is not a ContextForge export",
"importExport.import.tooLarge": "The file is too large to import",
"importExport.import.conflict": "Import stopped: some items already exist",
"importExport.import.validationFailed": "The file failed import validation",
"importExport.import.forbidden": "You don't have permission to import this configuration",
"importExport.import.partial": "Import partially applied: {created} created, {updated} updated, {skipped} skipped, {failed} failed",
"importExport.import.allFailed": "Import failed: none of the {failed} items could be applied"
"importExport.import.forbidden": "You don't have permission to import this configuration"
}
17 changes: 16 additions & 1 deletion src/i18n/locales/en-US/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,24 @@
"settings.tabs.users": "Users",
"settings.tabs.teams": "Teams",
"settings.tabs.sso": "SSO",
"settings.tabs.configuration": "Configuration",
"settings.sso.statusLabel": "Status",
"settings.sso.statusEnabled": "Enabled",
"settings.sso.statusDisabled": "Disabled",
"settings.sso.providerLabel": "Provider",
"settings.sso.hint": "SSO is configured via environment variables on the server. This page is read-only."
"settings.sso.hint": "SSO is configured via environment variables on the server. This page is read-only.",
"settings.configuration.import.title": "Import configuration",
"settings.configuration.import.description": "Restore servers, tools, prompts and other gateway entities from a previously exported configuration file.",
"settings.configuration.dryRun.title": "Dry run",
"settings.configuration.dryRun.result": "Dry run: would import {items}. Nothing was changed.",
"settings.configuration.import.success": "Imported {items}: {created} created, {updated} updated, {skipped} skipped.",
"settings.configuration.import.partial": "Import partially applied to {items}: {created} created, {updated} updated, {skipped} skipped, {failed} failed.",
"settings.configuration.import.allFailed": "Import failed: none of {items} could be applied.",
"settings.configuration.empty": "Nothing to import.",
"settings.configuration.type.gateways": "{count, plural, one {# MCP server} other {# MCP servers}}",
"settings.configuration.type.servers": "{count, plural, one {# virtual server} other {# virtual servers}}",
"settings.configuration.type.tools": "{count, plural, one {# tool} other {# tools}}",
"settings.configuration.type.prompts": "{count, plural, one {# prompt} other {# prompts}}",
"settings.configuration.type.resources": "{count, plural, one {# resource} other {# resources}}",
"settings.configuration.type.roots": "{count, plural, one {# root} other {# roots}}"
}
5 changes: 1 addition & 4 deletions src/i18n/locales/es-ES/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,11 @@
"common.team.label": "Equipo",
"common.team.placeholder": "Seleccione un equipo",
"importExport.export.failed": "Error al exportar",
"importExport.import.success": "Importación completada: {created} creados, {updated} actualizados, {skipped} omitidos, {failed} con error",
"importExport.import.failed": "Error al importar",
"importExport.import.invalidJson": "El archivo seleccionado no es un JSON válido",
"importExport.import.invalidShape": "El archivo seleccionado no es una exportación de ContextForge",
"importExport.import.tooLarge": "El archivo es demasiado grande para importarlo",
"importExport.import.conflict": "Importación detenida: algunos elementos ya existen",
"importExport.import.validationFailed": "El archivo no superó la validación de importación",
"importExport.import.forbidden": "No tiene permiso para importar esta configuración",
"importExport.import.partial": "Importación aplicada parcialmente: {created} creados, {updated} actualizados, {skipped} omitidos, {failed} con error",
"importExport.import.allFailed": "Error al importar: no se pudo aplicar ninguno de los {failed} elementos"
"importExport.import.forbidden": "No tiene permiso para importar esta configuración"
}
19 changes: 17 additions & 2 deletions src/i18n/locales/es-ES/settings.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
{
"settings.title": "Configuración",
"settings.title": "Configuraciones",
"settings.tabs.apiTokens": "Tokens de API",
"settings.tabs.users": "Usuarios",
"settings.tabs.teams": "Equipos",
"settings.tabs.sso": "SSO",
"settings.tabs.configuration": "Configuraciones",
"settings.sso.statusLabel": "Estado",
"settings.sso.statusEnabled": "Activado",
"settings.sso.statusDisabled": "Desactivado",
"settings.sso.providerLabel": "Proveedor",
"settings.sso.hint": "El SSO se configura mediante variables de entorno en el servidor. Esta página es de solo lectura."
"settings.sso.hint": "El SSO se configura mediante variables de entorno en el servidor. Esta página es de solo lectura.",
"settings.configuration.import.title": "Importar configuraciones",
"settings.configuration.import.description": "Restaura servidores, herramientas, prompts y otras entidades del gateway desde un archivo de configuración exportado previamente.",
"settings.configuration.dryRun.title": "Simulación",
"settings.configuration.dryRun.result": "Simulación: se importarían {items}. No se cambió nada.",
"settings.configuration.import.success": "Importados {items}: {created} creados, {updated} actualizados, {skipped} omitidos.",
"settings.configuration.import.partial": "Importación parcial de {items}: {created} creados, {updated} actualizados, {skipped} omitidos, {failed} fallidos.",
"settings.configuration.import.allFailed": "La importación falló: no se pudo aplicar ninguno de {items}.",
"settings.configuration.empty": "No hay nada que importar.",
"settings.configuration.type.gateways": "{count, plural, one {# servidor MCP} other {# servidores MCP}}",
"settings.configuration.type.servers": "{count, plural, one {# servidor virtual} other {# servidores virtuales}}",
"settings.configuration.type.tools": "{count, plural, one {# herramienta} other {# herramientas}}",
"settings.configuration.type.prompts": "{count, plural, one {# prompt} other {# prompts}}",
"settings.configuration.type.resources": "{count, plural, one {# recurso} other {# recursos}}",
"settings.configuration.type.roots": "{count, plural, one {# raíz} other {# raíces}}"
}
5 changes: 1 addition & 4 deletions src/i18n/locales/pt-BR/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,11 @@
"common.team.label": "Equipe",
"common.team.placeholder": "Selecione uma equipe",
"importExport.export.failed": "Falha ao exportar",
"importExport.import.success": "Importação concluída: {created} criados, {updated} atualizados, {skipped} ignorados, {failed} com falha",
"importExport.import.failed": "Falha ao importar",
"importExport.import.invalidJson": "O arquivo selecionado não é um JSON válido",
"importExport.import.invalidShape": "O arquivo selecionado não é uma exportação do ContextForge",
"importExport.import.tooLarge": "O arquivo é grande demais para ser importado",
"importExport.import.conflict": "Importação interrompida: alguns itens já existem",
"importExport.import.validationFailed": "O arquivo não passou na validação de importação",
"importExport.import.forbidden": "Você não tem permissão para importar esta configuração",
"importExport.import.partial": "Importação aplicada parcialmente: {created} criados, {updated} atualizados, {skipped} ignorados, {failed} com falha",
"importExport.import.allFailed": "Falha ao importar: nenhum dos {failed} itens pôde ser aplicado"
"importExport.import.forbidden": "Você não tem permissão para importar esta configuração"
}
17 changes: 16 additions & 1 deletion src/i18n/locales/pt-BR/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,24 @@
"settings.tabs.users": "Usuários",
"settings.tabs.teams": "Equipes",
"settings.tabs.sso": "SSO",
"settings.tabs.configuration": "Configurações",
"settings.sso.statusLabel": "Status",
"settings.sso.statusEnabled": "Ativado",
"settings.sso.statusDisabled": "Desativado",
"settings.sso.providerLabel": "Provedor",
"settings.sso.hint": "O SSO é configurado por variáveis de ambiente no servidor. Esta página é somente leitura."
"settings.sso.hint": "O SSO é configurado por variáveis de ambiente no servidor. Esta página é somente leitura.",
"settings.configuration.import.title": "Importar configurações",
"settings.configuration.import.description": "Restaure servidores, ferramentas, prompts e outras entidades do gateway a partir de um arquivo de configuração exportado anteriormente.",
"settings.configuration.dryRun.title": "Simulação",
"settings.configuration.dryRun.result": "Simulação: seriam importados {items}. Nada foi alterado.",
"settings.configuration.import.success": "Importados {items}: {created} criados, {updated} atualizados, {skipped} ignorados.",
"settings.configuration.import.partial": "Importação parcial de {items}: {created} criados, {updated} atualizados, {skipped} ignorados, {failed} com falha.",
"settings.configuration.import.allFailed": "Falha na importação: nenhum de {items} pôde ser aplicado.",
"settings.configuration.empty": "Nada a importar.",
"settings.configuration.type.gateways": "{count, plural, one {# servidor MCP} other {# servidores MCP}}",
"settings.configuration.type.servers": "{count, plural, one {# servidor virtual} other {# servidores virtuais}}",
"settings.configuration.type.tools": "{count, plural, one {# ferramenta} other {# ferramentas}}",
"settings.configuration.type.prompts": "{count, plural, one {# prompt} other {# prompts}}",
"settings.configuration.type.resources": "{count, plural, one {# recurso} other {# recursos}}",
"settings.configuration.type.roots": "{count, plural, one {# raiz} other {# raízes}}"
}
3 changes: 3 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
--color-server-icon-bg: var(--server-icon-bg);
--color-agent-icon-bg: var(--agent-icon-bg);
--color-team-icon-bg: var(--team-icon-bg);
--color-config-icon-bg: var(--config-icon-bg);
--color-catalog-icon-tile: var(--catalog-icon-tile);
--color-catalog-icon-backing: var(--catalog-icon-backing);
--color-tool-status-active: var(--tool-status-active);
Expand Down Expand Up @@ -164,6 +165,8 @@
/* Same rationale again: a chip background carrying a black glyph, so no
`.dark` override. Brand cyan, as on the status headline. */
--team-icon-bg: #70f9ff;
/* Chip background with black glyph; no .dark override. */
--config-icon-bg: #ffa4ff;
--catalog-icon-tile: oklch(1 0 0);
--catalog-icon-backing: var(--color-white);
/* emerald-400 falls under 3:1 on white; .dark restores it */
Expand Down
2 changes: 2 additions & 0 deletions src/pages/Settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ describe("Settings", () => {
expect(screen.getByRole("tab", { name: "Users" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Teams" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "SSO" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Configuration" })).toBeInTheDocument();
expect(screen.getByText("Tokens tab content")).toBeInTheDocument();
});

Expand Down Expand Up @@ -121,6 +122,7 @@ describe("Settings", () => {
expect(screen.queryByRole("tab", { name: "Users" })).not.toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "Teams" })).not.toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "SSO" })).not.toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "Configuration" })).not.toBeInTheDocument();
expect(screen.getByText("Tokens tab content")).toBeInTheDocument();
});

Expand Down
Loading
Loading