diff --git a/backend/app/gateway/routers/channel_connections.py b/backend/app/gateway/routers/channel_connections.py index e9366af3e..260a94b5d 100644 --- a/backend/app/gateway/routers/channel_connections.py +++ b/backend/app/gateway/routers/channel_connections.py @@ -23,6 +23,7 @@ router = APIRouter(prefix="/api/channels", tags=["channel-connections"]) logger = logging.getLogger(__name__) _STATE_TTL_SECONDS = 600 +_MASKED_CREDENTIAL_VALUE = "********" class ChannelCredentialFieldResponse(BaseModel): @@ -42,6 +43,7 @@ class ChannelProviderResponse(BaseModel): auth_mode: str connection_status: str credential_fields: list[ChannelCredentialFieldResponse] = Field(default_factory=list) + credential_values: dict[str, str] = Field(default_factory=dict) class ChannelProvidersResponse(BaseModel): @@ -304,6 +306,20 @@ def _credential_fields(provider: str) -> list[ChannelCredentialFieldResponse]: return [ChannelCredentialFieldResponse(**field) for field in fields] +def _credential_values(provider: str, channels_config: dict[str, Any]) -> dict[str, str]: + runtime_config = channels_config.get(provider) + if not isinstance(runtime_config, dict): + return {} + + values: dict[str, str] = {} + for field in _credential_fields(provider): + value = str(runtime_config.get(field.name) or "").strip() + if not value: + continue + values[field.name] = _MASKED_CREDENTIAL_VALUE if field.type == "password" else value + return values + + def _provider_response( config: ChannelConnectionsConfig, channels_config: dict[str, Any], @@ -318,6 +334,11 @@ def _provider_response( connection_status = "connected" else: connection_status = "not_connected" + credential_values = _credential_values(provider, channels_config) + if provider == "telegram" and not credential_values.get("bot_username"): + bot_username = str(_provider_config(config, provider).bot_username or "").strip() + if bot_username: + credential_values["bot_username"] = bot_username return ChannelProviderResponse( provider=provider, display_name=meta["display_name"], @@ -328,15 +349,26 @@ def _provider_response( auth_mode=meta["auth_mode"], connection_status=connection_status, credential_fields=_credential_fields(provider), + credential_values=credential_values, ) -def _required_runtime_values(provider: str, values: dict[str, str]) -> dict[str, str]: +def _required_runtime_values( + provider: str, + values: dict[str, str], + existing_config: dict[str, Any] | None = None, +) -> dict[str, str]: fields = _credential_fields(provider) cleaned: dict[str, str] = {} missing: list[str] = [] + existing_config = existing_config or {} for field in fields: raw_value = values.get(field.name, "") + if field.type == "password" and raw_value == _MASKED_CREDENTIAL_VALUE: + existing_value = str(existing_config.get(field.name) or "").strip() + if existing_value: + cleaned[field.name] = existing_value + continue value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value or "").strip() if field.required and not value: missing.append(field.label) @@ -509,10 +541,10 @@ async def configure_channel_provider_runtime( if not provider_config.enabled: raise HTTPException(status_code=400, detail="Channel provider is not enabled") - values = _required_runtime_values(provider, body.values) channels_config = _get_channels_config(request) existing = channels_config.get(provider) runtime_config = dict(existing) if isinstance(existing, dict) else {} + values = _required_runtime_values(provider, body.values, runtime_config) runtime_config["enabled"] = True for key in _RUNTIME_REQUIREMENTS[provider]: diff --git a/backend/tests/test_channel_connections_router.py b/backend/tests/test_channel_connections_router.py index 4944e2420..23ff0afd9 100644 --- a/backend/tests/test_channel_connections_router.py +++ b/backend/tests/test_channel_connections_router.py @@ -132,20 +132,42 @@ def test_get_providers_uses_existing_channels_config(tmp_path): assert set(by_provider) == {"telegram", "slack", "discord", "feishu", "dingtalk", "wechat", "wecom"} assert by_provider["telegram"]["configured"] is True assert by_provider["telegram"]["auth_mode"] == "deep_link" + assert by_provider["telegram"]["credential_values"] == { + "bot_token": "********", + "bot_username": "deerflow_bot", + } assert by_provider["slack"]["configured"] is True assert by_provider["slack"]["auth_mode"] == "binding_code" assert by_provider["slack"]["connection_status"] == "connected" + assert by_provider["slack"]["credential_values"] == { + "bot_token": "********", + "app_token": "********", + } assert by_provider["discord"]["configured"] is True assert by_provider["discord"]["auth_mode"] == "binding_code" + assert by_provider["discord"]["credential_values"] == {"bot_token": "********"} assert by_provider["feishu"]["configured"] is True assert by_provider["feishu"]["auth_mode"] == "binding_code" assert by_provider["feishu"]["connection_status"] == "connected" + assert by_provider["feishu"]["credential_values"] == { + "app_id": "feishu-app", + "app_secret": "********", + } assert by_provider["dingtalk"]["configured"] is True assert by_provider["dingtalk"]["auth_mode"] == "binding_code" + assert by_provider["dingtalk"]["credential_values"] == { + "client_id": "dingtalk-client", + "client_secret": "********", + } assert by_provider["wechat"]["configured"] is True assert by_provider["wechat"]["auth_mode"] == "binding_code" + assert by_provider["wechat"]["credential_values"] == {"bot_token": "********"} assert by_provider["wecom"]["configured"] is True assert by_provider["wecom"]["auth_mode"] == "binding_code" + assert by_provider["wecom"]["credential_values"] == { + "bot_id": "wecom-bot", + "bot_secret": "********", + } anyio.run(repo.close) @@ -459,6 +481,62 @@ def test_configure_provider_runtime_credentials_survive_local_restart(tmp_path): anyio.run(repo.close) +def test_configure_provider_runtime_credentials_preserves_masked_secrets(tmp_path): + import anyio + + repo = anyio.run(_make_repo, tmp_path) + config = ChannelConnectionsConfig.model_validate( + { + "enabled": True, + "feishu": {"enabled": True}, + } + ) + runtime_config_store = ChannelRuntimeConfigStore(tmp_path / "channels" / "runtime-config.json") + app = _make_app( + config, + repo, + { + "feishu": { + "enabled": True, + "app_id": "old-app-id", + "app_secret": "old-secret", + } + }, + runtime_config_store=runtime_config_store, + ) + + with TestClient(app) as client: + configure_response = client.post( + "/api/channels/feishu/runtime-config", + json={ + "values": { + "app_id": "new-app-id", + "app_secret": "********", + } + }, + ) + providers_response = client.get("/api/channels/providers") + + assert configure_response.status_code == 200 + assert app.state.channels_config["feishu"] == { + "enabled": True, + "app_id": "new-app-id", + "app_secret": "old-secret", + } + assert runtime_config_store.get_provider_config("feishu") == { + "enabled": True, + "app_id": "new-app-id", + "app_secret": "old-secret", + } + by_provider = {item["provider"]: item for item in providers_response.json()["providers"]} + assert by_provider["feishu"]["credential_values"] == { + "app_id": "new-app-id", + "app_secret": "********", + } + + anyio.run(repo.close) + + def test_disconnect_provider_runtime_config_clears_connected_state(tmp_path): import anyio diff --git a/frontend/src/components/workspace/channels/channel-runtime-config-dialog.tsx b/frontend/src/components/workspace/channels/channel-runtime-config-dialog.tsx index c857d5449..cd5ccee8a 100644 --- a/frontend/src/components/workspace/channels/channel-runtime-config-dialog.tsx +++ b/frontend/src/components/workspace/channels/channel-runtime-config-dialog.tsx @@ -57,6 +57,10 @@ export function ChannelRuntimeConfigDialog({ () => provider?.credential_fields ?? [], [provider?.credential_fields], ); + const credentialValues = useMemo( + () => provider?.credential_values ?? {}, + [provider?.credential_values], + ); useEffect(() => { if (!open || !provider) { @@ -64,11 +68,14 @@ export function ChannelRuntimeConfigDialog({ return; } setValues( - Object.fromEntries(fields.map((field) => [field.name, ""])) as - | ChannelRuntimeConfigValues - | {}, + Object.fromEntries( + fields.map((field) => [ + field.name, + credentialValues[field.name] ?? "", + ]), + ) as ChannelRuntimeConfigValues, ); - }, [fields, open, provider]); + }, [credentialValues, fields, open, provider]); if (!provider) { return null; diff --git a/frontend/src/core/channels/types.ts b/frontend/src/core/channels/types.ts index 557ac191d..c0cdce388 100644 --- a/frontend/src/core/channels/types.ts +++ b/frontend/src/core/channels/types.ts @@ -7,6 +7,8 @@ export interface ChannelCredentialField { required: boolean; } +export type ChannelRuntimeConfigValues = Record; + export interface ChannelProvider { provider: ChannelProviderId; display_name: string; @@ -17,6 +19,7 @@ export interface ChannelProvider { auth_mode: string; connection_status: string; credential_fields: ChannelCredentialField[]; + credential_values?: ChannelRuntimeConfigValues; } export interface ChannelProvidersResponse { @@ -48,5 +51,3 @@ export interface ChannelConnectResponse { instruction: string; expires_in: number; } - -export type ChannelRuntimeConfigValues = Record; diff --git a/frontend/tests/e2e/channels.spec.ts b/frontend/tests/e2e/channels.spec.ts index 7a8a3507f..653f00d73 100644 --- a/frontend/tests/e2e/channels.spec.ts +++ b/frontend/tests/e2e/channels.spec.ts @@ -27,6 +27,7 @@ type MockChannelProvider = { type: string; required: boolean; }>; + credential_values?: Record; }; function defaultProviders(): MockChannelProvider[] { @@ -166,6 +167,12 @@ test.describe("IM channels", () => { required: true, }, ], + credential_values: slackConfigured + ? { + bot_token: "********", + app_token: "********", + } + : {}, }, { provider: "discord", @@ -208,6 +215,7 @@ test.describe("IM channels", () => { auth_mode: "binding_code", connection_status: "connected", credential_fields: [], + credential_values: {}, }), }); }); @@ -244,9 +252,59 @@ test.describe("IM channels", () => { await expect( page.getByRole("dialog", { name: "Modify Slack" }), ).toBeVisible(); + await expect(page.getByLabel("Bot token")).toHaveValue("********"); + await expect(page.getByLabel("App token")).toHaveValue("********"); expect(submittedValues).toEqual({ bot_token: "xoxb-ui", app_token: "xapp-ui", }); }); + + test("runtime setup dialog prefills editable credential values", async ({ + page, + }) => { + mockLangGraphAPI(page); + mockChannelsAPI(page, [ + { + provider: "feishu", + display_name: "Feishu", + enabled: true, + configured: true, + connectable: true, + auth_mode: "binding_code", + connection_status: "connected", + credential_fields: [ + { + name: "app_id", + label: "App ID", + type: "text", + required: true, + }, + { + name: "app_secret", + label: "App secret", + type: "password", + required: true, + }, + ], + credential_values: { + app_id: "cli_feishu_app", + app_secret: "********", + }, + }, + ]); + + await page.goto("/workspace/chats/new"); + + const sidebar = page.locator("[data-sidebar='sidebar']"); + await expect(sidebar.getByText("Feishu")).toBeVisible({ timeout: 15_000 }); + await sidebar.getByRole("button", { name: "Connected" }).click(); + + const setupDialog = page.getByRole("dialog", { name: "Modify Feishu" }); + await expect(setupDialog).toBeVisible(); + await expect(setupDialog.getByLabel("App ID")).toHaveValue( + "cli_feishu_app", + ); + await expect(setupDialog.getByLabel("App secret")).toHaveValue("********"); + }); }); diff --git a/frontend/tests/unit/core/channels/api.test.ts b/frontend/tests/unit/core/channels/api.test.ts index 6fa449630..c8fa80aac 100644 --- a/frontend/tests/unit/core/channels/api.test.ts +++ b/frontend/tests/unit/core/channels/api.test.ts @@ -45,6 +45,10 @@ describe("channels api", () => { configured: true, auth_mode: "deep_link", connection_status: "not_connected", + credential_values: { + bot_token: "********", + bot_username: "deerflow_bot", + }, }, ], }), @@ -52,7 +56,16 @@ describe("channels api", () => { await expect(listChannelProviders()).resolves.toMatchObject({ enabled: true, - providers: [{ provider: "telegram", display_name: "Telegram" }], + providers: [ + { + provider: "telegram", + display_name: "Telegram", + credential_values: { + bot_token: "********", + bot_username: "deerflow_bot", + }, + }, + ], }); expect(mockedFetch).toHaveBeenCalledWith("/backend/api/channels/providers"); });