From dad5dcc30f99979a5774621254d0d87ce4edcf4d Mon Sep 17 00:00:00 2001 From: wenjie Date: Thu, 2 Apr 2026 19:09:33 +0800 Subject: [PATCH] refactor(web): load channel configs without exposing secret values (#2277) * refactor(web): load channel configs without exposing secret values - add a dedicated channel config API that returns sanitized config plus configured secret metadata - update channel config pages and forms to use secret presence for placeholders, validation, reset, and save behavior - refresh the channel settings layout and clean up related i18n copy - add backend tests for the new channel config endpoint * fix(config): restore missing strings import --- pkg/config/config.go | 1 + web/backend/api/channels.go | 184 +++++++ web/backend/api/channels_test.go | 87 +++ web/frontend/src/api/channels.ts | 17 +- .../channels/channel-config-fields.ts | 101 ++++ .../channels/channel-config-page.tsx | 198 +++---- .../channels/channel-forms/discord-form.tsx | 135 ++--- .../channels/channel-forms/feishu-form.tsx | 194 +++---- .../channels/channel-forms/generic-form.tsx | 498 ++++++++++-------- .../channels/channel-forms/slack-form.tsx | 115 ++-- .../channels/channel-forms/telegram-form.tsx | 202 +++---- .../channels/channel-forms/wecom-form.tsx | 50 +- .../channels/channel-forms/weixin-form.tsx | 90 ++-- web/frontend/src/components/shared-form.tsx | 47 +- web/frontend/src/i18n/locales/en.json | 9 - web/frontend/src/i18n/locales/zh.json | 113 ++-- 16 files changed, 1232 insertions(+), 809 deletions(-) create mode 100644 web/backend/api/channels_test.go create mode 100644 web/frontend/src/components/channels/channel-config-fields.ts diff --git a/pkg/config/config.go b/pkg/config/config.go index 30e5e1dd9..4e8733cbf 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -7,6 +7,7 @@ import ( "math/rand" "os" "path/filepath" + "strings" "sync/atomic" "time" diff --git a/web/backend/api/channels.go b/web/backend/api/channels.go index dd4c9af3d..88e6ec27c 100644 --- a/web/backend/api/channels.go +++ b/web/backend/api/channels.go @@ -3,6 +3,8 @@ package api import ( "encoding/json" "net/http" + + "github.com/sipeed/picoclaw/pkg/config" ) type channelCatalogItem struct { @@ -30,9 +32,22 @@ var channelCatalog = []channelCatalogItem{ {Name: "irc", ConfigKey: "irc"}, } +type channelConfigResponse struct { + Config any `json:"config"` + ConfiguredSecrets []string `json:"configured_secrets"` + ConfigKey string `json:"config_key"` + Variant string `json:"variant,omitempty"` +} + +type channelSecretPresence struct { + key string + configured bool +} + // registerChannelRoutes binds read-only channel catalog endpoints to the ServeMux. func (h *Handler) registerChannelRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/channels/catalog", h.handleListChannelCatalog) + mux.HandleFunc("GET /api/channels/{name}/config", h.handleGetChannelConfig) } // handleListChannelCatalog returns the channels supported by backend. @@ -44,3 +59,172 @@ func (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Reques "channels": channelCatalog, }) } + +// handleGetChannelConfig returns safe channel config plus secret presence metadata. +// +// GET /api/channels/{name}/config +func (h *Handler) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) { + channelName := r.PathValue("name") + item, ok := findChannelCatalogItem(channelName) + if !ok { + http.Error(w, "Channel not found", http.StatusNotFound) + return + } + + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, "Failed to load config", http.StatusInternalServerError) + return + } + + resp := buildChannelConfigResponse(cfg, item) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + +func findChannelCatalogItem(name string) (channelCatalogItem, bool) { + for _, item := range channelCatalog { + if item.Name == name { + return item, true + } + } + return channelCatalogItem{}, false +} + +func buildChannelConfigResponse(cfg *config.Config, item channelCatalogItem) channelConfigResponse { + resp := channelConfigResponse{ + ConfiguredSecrets: []string{}, + ConfigKey: item.ConfigKey, + Variant: item.Variant, + } + + switch item.Name { + case "weixin": + channelCfg := cfg.Channels.Weixin + resp.ConfiguredSecrets = collectConfiguredSecrets( + channelSecretPresence{key: "token", configured: channelCfg.Token.String() != ""}, + ) + channelCfg.Token = config.SecureString{} + resp.Config = channelCfg + case "telegram": + channelCfg := cfg.Channels.Telegram + resp.ConfiguredSecrets = collectConfiguredSecrets( + channelSecretPresence{key: "token", configured: channelCfg.Token.String() != ""}, + ) + channelCfg.Token = config.SecureString{} + resp.Config = channelCfg + case "discord": + channelCfg := cfg.Channels.Discord + resp.ConfiguredSecrets = collectConfiguredSecrets( + channelSecretPresence{key: "token", configured: channelCfg.Token.String() != ""}, + ) + channelCfg.Token = config.SecureString{} + resp.Config = channelCfg + case "slack": + channelCfg := cfg.Channels.Slack + resp.ConfiguredSecrets = collectConfiguredSecrets( + channelSecretPresence{key: "bot_token", configured: channelCfg.BotToken.String() != ""}, + channelSecretPresence{key: "app_token", configured: channelCfg.AppToken.String() != ""}, + ) + channelCfg.BotToken = config.SecureString{} + channelCfg.AppToken = config.SecureString{} + resp.Config = channelCfg + case "feishu": + channelCfg := cfg.Channels.Feishu + resp.ConfiguredSecrets = collectConfiguredSecrets( + channelSecretPresence{key: "app_secret", configured: channelCfg.AppSecret.String() != ""}, + channelSecretPresence{key: "encrypt_key", configured: channelCfg.EncryptKey.String() != ""}, + channelSecretPresence{key: "verification_token", configured: channelCfg.VerificationToken.String() != ""}, + ) + channelCfg.AppSecret = config.SecureString{} + channelCfg.EncryptKey = config.SecureString{} + channelCfg.VerificationToken = config.SecureString{} + resp.Config = channelCfg + case "dingtalk": + channelCfg := cfg.Channels.DingTalk + resp.ConfiguredSecrets = collectConfiguredSecrets( + channelSecretPresence{key: "client_secret", configured: channelCfg.ClientSecret.String() != ""}, + ) + channelCfg.ClientSecret = config.SecureString{} + resp.Config = channelCfg + case "line": + channelCfg := cfg.Channels.LINE + resp.ConfiguredSecrets = collectConfiguredSecrets( + channelSecretPresence{key: "channel_secret", configured: channelCfg.ChannelSecret.String() != ""}, + channelSecretPresence{ + key: "channel_access_token", + configured: channelCfg.ChannelAccessToken.String() != "", + }, + ) + channelCfg.ChannelSecret = config.SecureString{} + channelCfg.ChannelAccessToken = config.SecureString{} + resp.Config = channelCfg + case "qq": + channelCfg := cfg.Channels.QQ + resp.ConfiguredSecrets = collectConfiguredSecrets( + channelSecretPresence{key: "app_secret", configured: channelCfg.AppSecret.String() != ""}, + ) + channelCfg.AppSecret = config.SecureString{} + resp.Config = channelCfg + case "onebot": + channelCfg := cfg.Channels.OneBot + resp.ConfiguredSecrets = collectConfiguredSecrets( + channelSecretPresence{key: "access_token", configured: channelCfg.AccessToken.String() != ""}, + ) + channelCfg.AccessToken = config.SecureString{} + resp.Config = channelCfg + case "wecom": + channelCfg := cfg.Channels.WeCom + resp.ConfiguredSecrets = collectConfiguredSecrets( + channelSecretPresence{key: "secret", configured: channelCfg.Secret.String() != ""}, + ) + channelCfg.Secret = config.SecureString{} + resp.Config = channelCfg + case "whatsapp", "whatsapp_native": + resp.Config = cfg.Channels.WhatsApp + case "pico": + channelCfg := cfg.Channels.Pico + resp.ConfiguredSecrets = collectConfiguredSecrets( + channelSecretPresence{key: "token", configured: channelCfg.Token.String() != ""}, + ) + channelCfg.Token = config.SecureString{} + resp.Config = channelCfg + case "maixcam": + resp.Config = cfg.Channels.MaixCam + case "matrix": + channelCfg := cfg.Channels.Matrix + resp.ConfiguredSecrets = collectConfiguredSecrets( + channelSecretPresence{key: "access_token", configured: channelCfg.AccessToken.String() != ""}, + ) + channelCfg.AccessToken = config.SecureString{} + resp.Config = channelCfg + case "irc": + channelCfg := cfg.Channels.IRC + resp.ConfiguredSecrets = collectConfiguredSecrets( + channelSecretPresence{key: "password", configured: channelCfg.Password.String() != ""}, + channelSecretPresence{key: "nickserv_password", configured: channelCfg.NickServPassword.String() != ""}, + channelSecretPresence{key: "sasl_password", configured: channelCfg.SASLPassword.String() != ""}, + ) + channelCfg.Password = config.SecureString{} + channelCfg.NickServPassword = config.SecureString{} + channelCfg.SASLPassword = config.SecureString{} + resp.Config = channelCfg + default: + resp.Config = map[string]any{} + } + + return resp +} + +func collectConfiguredSecrets(secrets ...channelSecretPresence) []string { + configured := make([]string, 0, len(secrets)) + for _, secret := range secrets { + if secret.configured { + configured = append(configured, secret.key) + } + } + return configured +} diff --git a/web/backend/api/channels_test.go b/web/backend/api/channels_test.go new file mode 100644 index 000000000..73a4b39f3 --- /dev/null +++ b/web/backend/api/channels_test.go @@ -0,0 +1,87 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestHandleGetChannelConfig_ReturnsSecretPresenceWithoutLeakingSecrets(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Channels.Feishu.Enabled = true + cfg.Channels.Feishu.AppID = "cli_test_app" + cfg.Channels.Feishu.AppSecret = *config.NewSecureString("feishu-secret-from-security") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/channels/feishu/config", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf( + "GET /api/channels/feishu/config status = %d, want %d, body=%s", + rec.Code, + http.StatusOK, + rec.Body.String(), + ) + } + if strings.Contains(rec.Body.String(), "feishu-secret-from-security") { + t.Fatalf("response leaked secret value: %s", rec.Body.String()) + } + + var resp struct { + Config map[string]any `json:"config"` + ConfiguredSecrets []string `json:"configured_secrets"` + ConfigKey string `json:"config_key"` + Variant string `json:"variant"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if got := resp.ConfigKey; got != "feishu" { + t.Fatalf("config_key = %q, want %q", got, "feishu") + } + if got := resp.Config["app_id"]; got != "cli_test_app" { + t.Fatalf("config.app_id = %#v, want %q", got, "cli_test_app") + } + if _, exists := resp.Config["app_secret"]; exists { + t.Fatalf("config should omit app_secret, got %#v", resp.Config["app_secret"]) + } + if len(resp.ConfiguredSecrets) != 1 || resp.ConfiguredSecrets[0] != "app_secret" { + t.Fatalf("configured_secrets = %#v, want [\"app_secret\"]", resp.ConfiguredSecrets) + } +} + +func TestHandleGetChannelConfig_ReturnsNotFoundForUnknownChannel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/channels/not-a-channel/config", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("GET /api/channels/not-a-channel/config status = %d, want %d", rec.Code, http.StatusNotFound) + } +} diff --git a/web/frontend/src/api/channels.ts b/web/frontend/src/api/channels.ts index eb4d41fd7..42a3a0606 100644 --- a/web/frontend/src/api/channels.ts +++ b/web/frontend/src/api/channels.ts @@ -1,5 +1,3 @@ -// API client for channels navigation and channel-specific config flows. - import { launcherFetch } from "@/api/http" export type ChannelConfig = Record @@ -12,6 +10,13 @@ export interface SupportedChannel { variant?: string } +export interface ChannelConfigResponse { + config: ChannelConfig + configured_secrets: string[] + config_key: string + variant?: string +} + interface ChannelsCatalogResponse { channels: SupportedChannel[] } @@ -54,6 +59,14 @@ export async function getAppConfig(): Promise { return request("/api/config") } +export async function getChannelConfig( + channelName: string, +): Promise { + return request( + `/api/channels/${encodeURIComponent(channelName)}/config`, + ) +} + export async function patchAppConfig( patch: Record, ): Promise { diff --git a/web/frontend/src/components/channels/channel-config-fields.ts b/web/frontend/src/components/channels/channel-config-fields.ts new file mode 100644 index 000000000..35356954b --- /dev/null +++ b/web/frontend/src/components/channels/channel-config-fields.ts @@ -0,0 +1,101 @@ +import type { ChannelConfig } from "@/api/channels" + +export const SECRET_FIELD_MAP = { + token: "_token", + app_secret: "_app_secret", + client_secret: "_client_secret", + corp_secret: "_corp_secret", + channel_secret: "_channel_secret", + channel_access_token: "_channel_access_token", + access_token: "_access_token", + bot_token: "_bot_token", + app_token: "_app_token", + encoding_aes_key: "_encoding_aes_key", + encrypt_key: "_encrypt_key", + verification_token: "_verification_token", + secret: "_secret", + password: "_password", + nickserv_password: "_nickserv_password", + sasl_password: "_sasl_password", +} as const + +const CHANNEL_SECRET_FIELDS: Record = { + weixin: ["token"], + telegram: ["token"], + discord: ["token"], + slack: ["bot_token", "app_token"], + feishu: ["app_secret", "encrypt_key", "verification_token"], + dingtalk: ["client_secret"], + line: ["channel_secret", "channel_access_token"], + qq: ["app_secret"], + onebot: ["access_token"], + wecom: ["secret"], + pico: ["token"], + matrix: ["access_token"], + irc: ["password", "nickserv_password", "sasl_password"], +} + +const SECRET_FIELD_SET = new Set(Object.keys(SECRET_FIELD_MAP)) + +function asString(value: unknown): string { + return typeof value === "string" ? value : "" +} + +export function isSecretField(key: string): boolean { + return SECRET_FIELD_SET.has(key) +} + +export function buildEditConfig( + channelName: string, + config: ChannelConfig, +): ChannelConfig { + const edit: ChannelConfig = { ...config } + + for (const key of CHANNEL_SECRET_FIELDS[channelName] ?? []) { + if (!(key in edit)) { + edit[key] = "" + } + const editKey = SECRET_FIELD_MAP[key as keyof typeof SECRET_FIELD_MAP] + if (editKey) { + edit[editKey] = "" + } + } + + return edit +} + +export function hasConfiguredSecret( + configuredSecrets: readonly string[], + key: string, +): boolean { + return configuredSecrets.includes(key) +} + +export function getFieldValueForValidation( + config: ChannelConfig, + configuredSecrets: readonly string[], + key: string, +): unknown { + const editKey = SECRET_FIELD_MAP[key as keyof typeof SECRET_FIELD_MAP] + if (editKey) { + const incoming = asString(config[editKey]).trim() + if (incoming !== "") { + return incoming + } + if (hasConfiguredSecret(configuredSecrets, key)) { + return true + } + } + return config[key] +} + +export function getSecretInputPlaceholder( + configuredSecrets: readonly string[], + key: string, + configuredPlaceholder: string, + fallback = "", +): string { + return hasConfiguredSecret(configuredSecrets, key) + ? configuredPlaceholder + : fallback +} diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx index 3890924e0..7569712c4 100644 --- a/web/frontend/src/components/channels/channel-config-page.tsx +++ b/web/frontend/src/components/channels/channel-config-page.tsx @@ -1,14 +1,20 @@ -import { IconAlertTriangle, IconLoader2 } from "@tabler/icons-react" +import { IconLoader2 } from "@tabler/icons-react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { type ChannelConfig, type SupportedChannel, - getAppConfig, + getChannelConfig, getChannelsCatalog, patchAppConfig, } from "@/api/channels" +import { + SECRET_FIELD_MAP, + buildEditConfig, + getFieldValueForValidation, + isSecretField, +} from "@/components/channels/channel-config-fields" import { getChannelDisplayName } from "@/components/channels/channel-display-name" import { DiscordForm } from "@/components/channels/channel-forms/discord-form" import { FeishuForm } from "@/components/channels/channel-forms/feishu-form" @@ -27,24 +33,6 @@ interface ChannelConfigPageProps { channelName: string } -const SECRET_FIELD_MAP: Record = { - token: "_token", - app_secret: "_app_secret", - client_secret: "_client_secret", - corp_secret: "_corp_secret", - channel_secret: "_channel_secret", - channel_access_token: "_channel_access_token", - access_token: "_access_token", - bot_token: "_bot_token", - app_token: "_app_token", - encoding_aes_key: "_encoding_aes_key", - encrypt_key: "_encrypt_key", - verification_token: "_verification_token", - password: "_password", - nickserv_password: "_nickserv_password", - sasl_password: "_sasl_password", -} - function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record @@ -60,14 +48,6 @@ function asBool(value: unknown): boolean { return value === true } -function buildEditConfig(config: ChannelConfig): ChannelConfig { - const edit: ChannelConfig = { ...config } - for (const editKey of Object.values(SECRET_FIELD_MAP)) { - edit[editKey] = "" - } - return edit -} - function normalizeConfig( channel: SupportedChannel, rawConfig: ChannelConfig, @@ -92,7 +72,7 @@ function buildSavePayload( for (const [key, value] of Object.entries(editConfig)) { if (key.startsWith("_")) continue if (key === "enabled") continue - if (key in SECRET_FIELD_MAP) continue + if (isSecretField(key)) continue payload[key] = value } @@ -103,8 +83,9 @@ function buildSavePayload( payload[secretKey] = incoming continue } - if (secretKey in editConfig) { - payload[secretKey] = editConfig[secretKey] + const existing = asString(editConfig[secretKey]).trim() + if (existing !== "") { + payload[secretKey] = existing } } @@ -121,51 +102,50 @@ function buildSavePayload( function isConfigured( channel: SupportedChannel, config: ChannelConfig, + configuredSecrets: readonly string[], ): boolean { + const hasValue = (key: string) => + !isMissingRequiredValue( + getFieldValueForValidation(config, configuredSecrets, key), + ) + switch (channel.name) { case "telegram": - return asString(config.token) !== "" + return hasValue("token") case "discord": - return asString(config.token) !== "" + return hasValue("token") case "slack": - return asString(config.bot_token) !== "" + return hasValue("bot_token") case "feishu": - return ( - asString(config.app_id) !== "" && asString(config.app_secret) !== "" - ) + return hasValue("app_id") && hasValue("app_secret") case "dingtalk": - return ( - asString(config.client_id) !== "" && - asString(config.client_secret) !== "" - ) + return hasValue("client_id") && hasValue("client_secret") case "line": - return asString(config.channel_access_token) !== "" + return hasValue("channel_secret") && hasValue("channel_access_token") case "qq": - return ( - asString(config.app_id) !== "" && asString(config.app_secret) !== "" - ) + return hasValue("app_id") && hasValue("app_secret") case "onebot": - return asString(config.ws_url) !== "" + return hasValue("ws_url") case "weixin": - return asString(config.account_id) !== "" + return hasValue("account_id") case "wecom": - return asString(config.bot_id) !== "" + return hasValue("bot_id") case "whatsapp": - return asString(config.bridge_url) !== "" + return hasValue("bridge_url") case "whatsapp_native": return asBool(config.use_native) case "pico": - return asString(config.token) !== "" + return hasValue("token") case "maixcam": - return asString(config.host) !== "" + return hasValue("host") case "matrix": return ( - asString(config.homeserver) !== "" && - asString(config.user_id) !== "" && - asString(config.access_token) !== "" + hasValue("homeserver") && + hasValue("user_id") && + hasValue("access_token") ) case "irc": - return asString(config.server) !== "" + return hasValue("server") default: return false } @@ -245,21 +225,23 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { const [channel, setChannel] = useState(null) const [baseConfig, setBaseConfig] = useState({}) const [editConfig, setEditConfig] = useState({}) + const [configuredSecrets, setConfiguredSecrets] = useState([]) const [enabled, setEnabled] = useState(false) const loadData = useCallback( async (silent = false) => { if (!silent) setLoading(true) try { - const [catalog, appConfig] = await Promise.all([ - getChannelsCatalog(), - getAppConfig(), - ]) + const catalog = await getChannelsCatalog() const matched = catalog.channels.find((item) => item.name === channelName) ?? null if (!matched) { setChannel(null) + setBaseConfig({}) + setEditConfig({}) + setConfiguredSecrets([]) + setEnabled(false) setFetchError( t("channels.page.notFound", { name: channelName, @@ -268,18 +250,20 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { return } - const channelsConfig = asRecord(asRecord(appConfig).channels) - const raw = asRecord(channelsConfig[matched.config_key]) + const channelConfig = await getChannelConfig(channelName) + const raw = asRecord(channelConfig.config) const normalized = normalizeConfig(matched, raw) setChannel(matched) setBaseConfig(normalized) - setEditConfig(buildEditConfig(normalized)) + setEditConfig(buildEditConfig(matched.name, normalized)) + setConfiguredSecrets(channelConfig.configured_secrets ?? []) setEnabled(asBool(normalized.enabled)) setFetchError("") setServerError("") setFieldErrors({}) } catch (e) { + setConfiguredSecrets([]) setFetchError(e instanceof Error ? e.message : t("channels.loadError")) } finally { if (!silent) setLoading(false) @@ -307,9 +291,9 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { }, [channel, editConfig, enabled]) const configured = useMemo(() => { - if (!channel || !savePayload) return false - return isConfigured(channel, savePayload) - }, [channel, savePayload]) + if (!channel) return false + return isConfigured(channel, editConfig, configuredSecrets) + }, [channel, configuredSecrets, editConfig]) const docsUrl = useMemo(() => { if (!channel) return "" @@ -362,7 +346,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { }, []) const handleReset = () => { - setEditConfig(buildEditConfig(baseConfig)) + if (!channel) return + setEditConfig(buildEditConfig(channel.name, baseConfig)) setEnabled(asBool(baseConfig.enabled)) setServerError("") setFieldErrors({}) @@ -372,7 +357,9 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { if (!channel || !savePayload) return const missingRequiredFields = requiredKeys.filter((key) => - isMissingRequiredValue(savePayload[key]), + isMissingRequiredValue( + getFieldValueForValidation(editConfig, configuredSecrets, key), + ), ) if (missingRequiredFields.length > 0) { const requiredFieldError = t("channels.validation.requiredField") @@ -456,7 +443,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { ) @@ -465,7 +452,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { ) @@ -474,7 +461,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { ) @@ -483,7 +470,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { ) @@ -510,7 +497,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { - {enabled ? ( - - {t("channels.page.enabled")} - - ) : configured ? ( - - {t("channels.status.configured")} - - ) : null} - - ) : undefined + channel && + docsUrl && ( + + {t("channels.page.docLink")} + + ) } /> @@ -562,46 +547,9 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { {fetchError} ) : ( -
-
-

- {t("channels.edit", { - name: channelDisplayName, - })} -

- {channel && docsUrl && ( - - {t("channels.page.docLink")} - - )} -
- - {channel?.name === "weixin" && ( -
-
- -
-

- {t("channels.weixin.warningTitle")} -

-

- {t("channels.weixin.warningDesc")} -

-
-
-
- )} - +
{!hidesPageLevelEnableToggle && ( -
+

{t("channels.page.enableLabel")}

diff --git a/web/frontend/src/components/channels/channel-forms/discord-form.tsx b/web/frontend/src/components/channels/channel-forms/discord-form.tsx index 300175e20..f72e1c5c7 100644 --- a/web/frontend/src/components/channels/channel-forms/discord-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/discord-form.tsx @@ -1,14 +1,15 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" -import { maskedSecretPlaceholder } from "@/components/secret-placeholder" +import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" +import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" interface DiscordFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void - isEdit: boolean + configuredSecrets: string[] fieldErrors?: Record } @@ -35,75 +36,83 @@ function asRecord(value: unknown): Record { export function DiscordForm({ config, onChange, - isEdit, + configuredSecrets, fieldErrors = {}, }: DiscordFormProps) { const { t } = useTranslation() const groupTriggerConfig = asRecord(config.group_trigger) - const tokenExtraHint = - isEdit && asString(config.token) - ? ` ${t("channels.field.secretHintSet")}` - : "" return ( -
- - onChange("_token", v)} - placeholder={maskedSecretPlaceholder( - config.token, - t("channels.field.tokenPlaceholder"), - )} - /> - +
+ + + + onChange("_token", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "token", + t("channels.field.secretHintSet"), + t("channels.field.tokenPlaceholder"), + )} + /> + + + - - onChange("proxy", e.target.value)} - placeholder="http://127.0.0.1:7890" - /> - - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + + + + onChange("proxy", e.target.value)} + placeholder="http://127.0.0.1:7890" + /> + + + + onChange( + "allow_from", + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) + } + placeholder={t("channels.field.allowFromPlaceholder")} + /> + - { - onChange("group_trigger", { - ...groupTriggerConfig, - mention_only: checked, - }) - }} - ariaLabel={t("channels.field.mentionOnly")} - /> +
+ { + onChange("group_trigger", { + ...groupTriggerConfig, + mention_only: checked, + }) + }} + ariaLabel={t("channels.field.mentionOnly")} + /> +
+
+
) } diff --git a/web/frontend/src/components/channels/channel-forms/feishu-form.tsx b/web/frontend/src/components/channels/channel-forms/feishu-form.tsx index 386adf9a5..5c77fe3f9 100644 --- a/web/frontend/src/components/channels/channel-forms/feishu-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/feishu-form.tsx @@ -1,14 +1,15 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" -import { maskedSecretPlaceholder } from "@/components/secret-placeholder" +import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" +import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" interface FeishuFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void - isEdit: boolean + configuredSecrets: string[] fieldErrors?: Record } @@ -28,104 +29,111 @@ function asStringArray(value: unknown): string[] { export function FeishuForm({ config, onChange, - isEdit, + configuredSecrets, fieldErrors = {}, }: FeishuFormProps) { const { t } = useTranslation() - const appSecretExtraHint = - isEdit && asString(config.app_secret) - ? ` ${t("channels.field.secretHintSet")}` - : "" - const verificationExtraHint = - isEdit && asString(config.verification_token) - ? ` ${t("channels.field.secretHintSet")}` - : "" - const encryptExtraHint = - isEdit && asString(config.encrypt_key) - ? ` ${t("channels.field.secretHintSet")}` - : "" return ( -
- - onChange("app_id", e.target.value)} - placeholder="cli_xxxx" - /> - +
+ + + + onChange("app_id", e.target.value)} + placeholder="cli_xxxx" + /> + - - onChange("_app_secret", v)} - placeholder={maskedSecretPlaceholder( - config.app_secret, - t("channels.field.secretPlaceholder"), - )} - /> - + + onChange("_app_secret", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "app_secret", + t("channels.field.secretHintSet"), + t("channels.field.secretPlaceholder"), + )} + /> + + + - - onChange("_verification_token", v)} - placeholder={maskedSecretPlaceholder( - config.verification_token, - t("channels.field.secretPlaceholder"), - )} - /> - - - onChange("_encrypt_key", v)} - placeholder={maskedSecretPlaceholder( - config.encrypt_key, - t("channels.field.secretPlaceholder"), - )} - /> - - onChange("is_lark", checked)} - /> - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + + + + onChange("_verification_token", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "verification_token", + t("channels.field.secretHintSet"), + t("channels.field.secretPlaceholder"), + )} + /> + + + onChange("_encrypt_key", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "encrypt_key", + t("channels.field.secretHintSet"), + t("channels.field.secretPlaceholder"), + )} + /> + + + + + onChange( + "allow_from", + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) + } + placeholder={t("channels.field.allowFromPlaceholder")} + /> + + +
+ onChange("is_lark", checked)} + ariaLabel={t("channels.field.isLark")} + /> +
+
+
) } diff --git a/web/frontend/src/components/channels/channel-forms/generic-form.tsx b/web/frontend/src/components/channels/channel-forms/generic-form.tsx index 936802944..526a3c808 100644 --- a/web/frontend/src/components/channels/channel-forms/generic-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/generic-form.tsx @@ -1,39 +1,23 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" -import { maskedSecretPlaceholder } from "@/components/secret-placeholder" +import { + getSecretInputPlaceholder, + isSecretField, +} from "@/components/channels/channel-config-fields" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" +import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" interface GenericFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void - isEdit: boolean + configuredSecrets?: string[] hiddenKeys?: string[] requiredKeys?: string[] fieldErrors?: Record } -// Secret field names that should use masked input. -const SECRET_FIELDS = new Set([ - "token", - "app_secret", - "client_secret", - "corp_secret", - "channel_secret", - "channel_access_token", - "access_token", - "bot_token", - "app_token", - "encoding_aes_key", - "encrypt_key", - "verification_token", - "secret", - "password", - "nickserv_password", - "sasl_password", -]) - // Fields to skip in the generic form (handled by enabled toggle or internal). const SKIP_FIELDS = new Set(["enabled", "reasoning_channel_id"]) @@ -83,7 +67,7 @@ function asBool(value: unknown): boolean { export function GenericForm({ config, onChange, - isEdit, + configuredSecrets = [], hiddenKeys = [], requiredKeys = [], fieldErrors = {}, @@ -96,7 +80,7 @@ export function GenericForm({ const placeholderConfig = asRecord(config.placeholder) const placeholderEnabled = asBool(placeholderConfig.enabled) - const fields = Object.keys(config).filter( + const rawFields = Object.keys(config).filter( (k) => !k.startsWith("_") && !SKIP_FIELDS.has(k) && @@ -160,231 +144,291 @@ export function GenericForm({ ) } - return ( -
- {fields.map((key) => { - const isRequired = requiredFieldSet.has(key) - if (SECRET_FIELDS.has(key)) { - const editKey = `_${key}` - const extraHint = - isEdit && config[key] ? ` ${t("channels.field.secretHintSet")}` : "" - return ( - - onChange(editKey, v)} - placeholder={maskedSecretPlaceholder(config[key])} - /> - - ) - } - - const value = config[key] - if (typeof value === "boolean") { - return ( - onChange(key, checked)} - ariaLabel={formatLabel(key)} - /> - ) - } - - if (Array.isArray(value)) { - return ( - - - onChange( - key, - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - /> - - ) - } - - return ( - - { - // Attempt to preserve number types - const v = e.target.value - if (typeof config[key] === "number") { - onChange(key, v === "" ? 0 : Number(v)) - } else { - onChange(key, v) - } - }} - /> - - ) - })} - - {/* Allow From field */} - {config.allow_from !== undefined && !hiddenFieldSet.has("allow_from") && ( + const renderField = (key: string) => { + const isRequired = requiredFieldSet.has(key) + if (isSecretField(key)) { + const editKey = `_${key}` + return ( + onChange(editKey, v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + key, + t("channels.field.secretHintSet"), + t("channels.field.secretPlaceholder"), + )} + /> + + ) + } + + const value = config[key] + if (typeof value === "boolean") { + return ( + onChange(key, checked)} + ariaLabel={formatLabel(key)} + /> + ) + } + + if (Array.isArray(value)) { + return ( + onChange( - "allow_from", + key, e.target.value .split(",") .map((s: string) => s.trim()) .filter(Boolean), ) } - placeholder={t("channels.field.allowFromPlaceholder")} /> - )} + ) + } - {config.allow_origins !== undefined && - !hiddenFieldSet.has("allow_origins") && ( - - - onChange( - "allow_origins", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowOriginsPlaceholder")} - /> - - )} - - {config.allow_token_query !== undefined && - !hiddenFieldSet.has("allow_token_query") && ( - - onChange("allow_token_query", checked) + return ( + + { + const v = e.target.value + if (typeof config[key] === "number") { + onChange(key, v === "" ? 0 : Number(v)) + } else { + onChange(key, v) } - ariaLabel={formatLabel("allow_token_query")} - /> - )} - - {config.group_trigger !== undefined && - !hiddenFieldSet.has("group_trigger") && ( - <> - - onChange("group_trigger", { - ...groupTriggerConfig, - mention_only: checked, - }) - } - ariaLabel={t("channels.field.groupTriggerMentionOnly")} - /> - - - onChange("group_trigger", { - ...groupTriggerConfig, - prefixes: e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - }) - } - placeholder={t("channels.field.groupTriggerPrefixes")} - /> - - - )} - - {config.typing !== undefined && !hiddenFieldSet.has("typing") && ( - - onChange("typing", { ...typingConfig, enabled: checked }) - } - ariaLabel={t("channels.field.typingEnabled")} + }} /> + + ) + } + + const isBasicField = (key: string) => { + if (requiredFieldSet.has(key)) return true + if ( + key.endsWith("id") || + key.endsWith("token") || + key.endsWith("secret") || + key.endsWith("url") || + key === "server" || + key === "host" || + key === "port" + ) { + return true + } + return false + } + + const basicFields = rawFields.filter(isBasicField) + const advancedFields = rawFields.filter((key) => !isBasicField(key)) + + const hasAdvancedContent = + advancedFields.length > 0 || + (config.allow_from !== undefined && !hiddenFieldSet.has("allow_from")) || + (config.allow_origins !== undefined && + !hiddenFieldSet.has("allow_origins")) || + (config.allow_token_query !== undefined && + !hiddenFieldSet.has("allow_token_query")) || + (config.group_trigger !== undefined && + !hiddenFieldSet.has("group_trigger")) || + (config.typing !== undefined && !hiddenFieldSet.has("typing")) || + (config.placeholder !== undefined && !hiddenFieldSet.has("placeholder")) + + return ( +
+ {basicFields.length > 0 && ( + + + {basicFields.map(renderField)} + + )} - {config.placeholder !== undefined && - !hiddenFieldSet.has("placeholder") && ( - - onChange("placeholder", { - ...placeholderConfig, - enabled: checked, - }) - } - ariaLabel={t("channels.field.placeholderEnabled")} - > - {placeholderEnabled && ( -
- - onChange("placeholder", { - ...placeholderConfig, - text: e.target.value, - }) + {hasAdvancedContent && ( + + + {advancedFields.map(renderField)} + + {config.allow_from !== undefined && + !hiddenFieldSet.has("allow_from") && ( + + + onChange( + "allow_from", + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) + } + placeholder={t("channels.field.allowFromPlaceholder")} + /> + + )} + + {config.allow_origins !== undefined && + !hiddenFieldSet.has("allow_origins") && ( + + + onChange( + "allow_origins", + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) + } + placeholder={t("channels.field.allowOriginsPlaceholder")} + /> + + )} + + {config.allow_token_query !== undefined && + !hiddenFieldSet.has("allow_token_query") && ( +
+ + onChange("allow_token_query", checked) + } + ariaLabel={formatLabel("allow_token_query")} + /> +
+ )} + + {config.group_trigger !== undefined && + !hiddenFieldSet.has("group_trigger") && ( + <> +
+ + onChange("group_trigger", { + ...groupTriggerConfig, + mention_only: checked, + }) + } + ariaLabel={t("channels.field.groupTriggerMentionOnly")} + /> +
+ + + + onChange("group_trigger", { + ...groupTriggerConfig, + prefixes: e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + }) + } + placeholder={t("channels.field.groupTriggerPrefixes")} + /> + + + )} + + {config.typing !== undefined && !hiddenFieldSet.has("typing") && ( +
+ + onChange("typing", { ...typingConfig, enabled: checked }) } - placeholder={t("channels.field.placeholderText")} - aria-label={t("channels.field.placeholderText")} + ariaLabel={t("channels.field.typingEnabled")} />
)} - - )} + + {config.placeholder !== undefined && + !hiddenFieldSet.has("placeholder") && ( +
+ + onChange("placeholder", { + ...placeholderConfig, + enabled: checked, + }) + } + ariaLabel={t("channels.field.placeholderEnabled")} + > + {placeholderEnabled && ( +
+ + onChange("placeholder", { + ...placeholderConfig, + text: e.target.value, + }) + } + placeholder={t("channels.field.placeholderText")} + aria-label={t("channels.field.placeholderText")} + /> +
+ )} +
+
+ )} +
+
+ )}
) } diff --git a/web/frontend/src/components/channels/channel-forms/slack-form.tsx b/web/frontend/src/components/channels/channel-forms/slack-form.tsx index 54650e842..14ffa0913 100644 --- a/web/frontend/src/components/channels/channel-forms/slack-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/slack-form.tsx @@ -1,14 +1,15 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" -import { maskedSecretPlaceholder } from "@/components/secret-placeholder" +import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput } from "@/components/shared-form" +import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" interface SlackFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void - isEdit: boolean + configuredSecrets: string[] fieldErrors?: Record } @@ -24,63 +25,73 @@ function asStringArray(value: unknown): string[] { export function SlackForm({ config, onChange, - isEdit, + configuredSecrets, fieldErrors = {}, }: SlackFormProps) { const { t } = useTranslation() - const botTokenExtraHint = - isEdit && asString(config.bot_token) - ? ` ${t("channels.field.secretHintSet")}` - : "" - const appTokenExtraHint = - isEdit && asString(config.app_token) - ? ` ${t("channels.field.secretHintSet")}` - : "" return ( -
- - onChange("_bot_token", v)} - placeholder={maskedSecretPlaceholder(config.bot_token, "xoxb-xxxx")} - /> - +
+ + + + onChange("_bot_token", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "bot_token", + t("channels.field.secretHintSet"), + "xoxb-xxxx", + )} + /> + - - onChange("_app_token", v)} - placeholder={maskedSecretPlaceholder(config.app_token, "xapp-xxxx")} - /> - + + onChange("_app_token", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "app_token", + t("channels.field.secretHintSet"), + "xapp-xxxx", + )} + /> + + + - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + + + + + onChange( + "allow_from", + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) + } + placeholder={t("channels.field.allowFromPlaceholder")} + /> + + +
) } diff --git a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx index 169ddec63..696da245d 100644 --- a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx @@ -1,14 +1,15 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" -import { maskedSecretPlaceholder } from "@/components/secret-placeholder" +import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" +import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" interface TelegramFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void - isEdit: boolean + configuredSecrets: string[] fieldErrors?: Record } @@ -35,113 +36,124 @@ function asBool(value: unknown): boolean { export function TelegramForm({ config, onChange, - isEdit, + configuredSecrets, fieldErrors = {}, }: TelegramFormProps) { const { t } = useTranslation() const typingConfig = asRecord(config.typing) const placeholderConfig = asRecord(config.placeholder) const placeholderEnabled = asBool(placeholderConfig.enabled) - const tokenExtraHint = - isEdit && asString(config.token) - ? ` ${t("channels.field.secretHintSet")}` - : "" return ( -
- - onChange("_token", v)} - placeholder={maskedSecretPlaceholder( - config.token, - t("channels.field.tokenPlaceholder"), - )} - /> - +
+ + + + onChange("_token", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "token", + t("channels.field.secretHintSet"), + t("channels.field.tokenPlaceholder"), + )} + /> + - - onChange("base_url", e.target.value)} - placeholder="https://api.telegram.org" - /> - - - onChange("proxy", e.target.value)} - placeholder="http://127.0.0.1:7890" - /> - - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - - - - onChange("typing", { ...typingConfig, enabled: checked }) - } - ariaLabel={t("channels.field.typingEnabled")} - /> - - - onChange("placeholder", { - ...placeholderConfig, - enabled: checked, - }) - } - ariaLabel={t("channels.field.placeholderEnabled")} - > - {placeholderEnabled && ( -
+ onChange("base_url", e.target.value)} + placeholder="https://api.telegram.org" + /> + + + + + + + + onChange("proxy", e.target.value)} + placeholder="http://127.0.0.1:7890" + /> + + + - onChange("placeholder", { - ...placeholderConfig, - text: e.target.value, - }) + onChange( + "allow_from", + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) } - placeholder={t("channels.field.placeholderText")} - aria-label={t("channels.field.placeholderText")} + placeholder={t("channels.field.allowFromPlaceholder")} + /> + + +
+ + onChange("typing", { ...typingConfig, enabled: checked }) + } + ariaLabel={t("channels.field.typingEnabled")} />
- )} - + +
+ + onChange("placeholder", { + ...placeholderConfig, + enabled: checked, + }) + } + ariaLabel={t("channels.field.placeholderEnabled")} + > + {placeholderEnabled && ( +
+ + onChange("placeholder", { + ...placeholderConfig, + text: e.target.value, + }) + } + placeholder={t("channels.field.placeholderText")} + aria-label={t("channels.field.placeholderText")} + /> +
+ )} +
+
+
+
) } diff --git a/web/frontend/src/components/channels/channel-forms/wecom-form.tsx b/web/frontend/src/components/channels/channel-forms/wecom-form.tsx index 744c87ba2..b7e6ce849 100644 --- a/web/frontend/src/components/channels/channel-forms/wecom-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/wecom-form.tsx @@ -11,6 +11,13 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" import { patchAppConfig, pollWecomFlow, startWecomFlow } from "@/api/channels" import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" import { Switch } from "@/components/ui/switch" type BindingState = @@ -329,39 +336,32 @@ export function WecomForm({ } return ( -
-
-
-
-

- {t("channels.page.enableLabel")} -

-

- {isBound - ? t("channels.wecom.enableDesc") - : t("channels.wecom.enableBindFirst")} -

-
+
+
+

{t("channels.page.enableLabel")}

+
void handleEnabledChange(checked)} /> + {toggleError && ( +

+ {toggleError} +

+ )}
- {toggleError && ( -

{toggleError}

- )}
-
-
-

{t("channels.wecom.bindTitle")}

-

- {t("channels.wecom.bindDesc")} -

-
- {renderBindSection()} -
+ + + + {t("channels.wecom.bindTitle")} + + {t("channels.wecom.bindDesc")} + + {renderBindSection()} +
) } diff --git a/web/frontend/src/components/channels/channel-forms/weixin-form.tsx b/web/frontend/src/components/channels/channel-forms/weixin-form.tsx index 20e66ffc2..ec80520ea 100644 --- a/web/frontend/src/components/channels/channel-forms/weixin-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/weixin-form.tsx @@ -12,6 +12,13 @@ import type { ChannelConfig } from "@/api/channels" import { pollWeixinFlow, startWeixinFlow } from "@/api/channels" import { Field } from "@/components/shared-form" import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" import { Input } from "@/components/ui/input" type BindingState = @@ -301,51 +308,50 @@ export function WeixinForm({ } return ( -
- {/* QR Bind Section */} -
-
-

+

+ + + {t("channels.weixin.bindTitle")} -

-

- {t("channels.weixin.bindDesc")} -

-
- {renderBindSection()} -
+ + {t("channels.weixin.bindDesc")} + + {renderBindSection()} + - {/* allow_from */} - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + + + + + onChange( + "allow_from", + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) + } + placeholder={t("channels.field.allowFromPlaceholder")} + /> + - {/* proxy */} - - onChange("proxy", e.target.value)} - placeholder="http://localhost:7890" - /> - + + onChange("proxy", e.target.value)} + placeholder="http://localhost:7890" + /> + + +
) } diff --git a/web/frontend/src/components/shared-form.tsx b/web/frontend/src/components/shared-form.tsx index 14da8e1f1..e6dd2cee9 100644 --- a/web/frontend/src/components/shared-form.tsx +++ b/web/frontend/src/components/shared-form.tsx @@ -34,23 +34,28 @@ export function Field({ }: FieldProps) { if (layout === "setting-row") { return ( -
-
- +
+
+ {label} {required && *} {hint && ( - + {hint} )}
-
+
{children}
{error && ( - + {error} )} @@ -125,6 +130,7 @@ interface SwitchCardFieldProps { disabled?: boolean children?: ReactNode layout?: FieldLayout + transparent?: boolean } export function SwitchCardField({ @@ -137,19 +143,22 @@ export function SwitchCardField({ disabled, children, layout = "default", + transparent, }: SwitchCardFieldProps) { if (layout === "setting-row") { return ( -
-
-

{label}

+
+
+

+ {label} +

{hint && ( -

+

{hint}

)}
-
+
- {children &&
{children}
} + {children && ( +
+
{children}
+
+ )} {error && ( -

+

{error}

)} @@ -168,7 +181,11 @@ export function SwitchCardField({ } return ( -
+

{label}

@@ -185,7 +202,7 @@ export function SwitchCardField({ aria-label={ariaLabel ?? label} />
- {children &&
{children}
} + {children &&
{children}
} {error && (

{error}

)} diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index b99ff9594..851b0c8c4 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -244,10 +244,6 @@ }, "channels": { "loadError": "Failed to load channels", - "edit": "Configure {{name}}", - "status": { - "configured": "Configured" - }, "name": { "telegram": "Telegram", "discord": "Discord", @@ -267,8 +263,6 @@ "weixin": "WeChat" }, "weixin": { - "warningTitle": "Testing phase, use with caution", - "warningDesc": "The WeChat channel is still experimental and may carry a risk of account suspension. Use it only if you understand and accept the risk.", "bindTitle": "WeChat Account Binding", "bindDesc": "Scan the QR code with WeChat to bind your personal account.", "bind": "Bind WeChat", @@ -286,8 +280,6 @@ "wecom": { "bindTitle": "WeCom Binding", "bindDesc": "Scan the QR code with WeCom to bind your AI Bot.", - "enableDesc": "Once bound, you can enable or disable the channel here.", - "enableBindFirst": "Bind the bot first, then enable the channel.", "bind": "Bind WeCom", "rebind": "Re-bind", "bound": "WeCom Bound", @@ -329,7 +321,6 @@ "notFound": "Channel \"{{name}}\" is not supported.", "saveSuccess": "Channel configuration saved.", "saveError": "Failed to save channel configuration", - "enabled": "enabled", "docLink": "Documentation", "enableLabel": "Enable channel", "restartRequiredTitle": "Gateway restart required", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 9fa45e981..07538ace9 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -244,10 +244,6 @@ }, "channels": { "loadError": "加载频道列表失败", - "edit": "配置 {{name}}", - "status": { - "configured": "已配置" - }, "name": { "telegram": "Telegram", "discord": "Discord", @@ -267,8 +263,6 @@ "weixin": "微信" }, "weixin": { - "warningTitle": "测试阶段,请谨慎使用", - "warningDesc": "微信 Channel 当前仍处于测试阶段,存在封号风险。请仅在充分了解风险的前提下使用。", "bindTitle": "微信账号绑定", "bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。", "bind": "绑定微信", @@ -286,8 +280,6 @@ "wecom": { "bindTitle": "企业微信绑定", "bindDesc": "使用企业微信扫描二维码以绑定您的 AI Bot。", - "enableDesc": "绑定后可在这里直接启用或停用频道。", - "enableBindFirst": "请先完成绑定,然后再启用频道。", "bind": "绑定企业微信", "rebind": "重新绑定", "bound": "企业微信已绑定", @@ -323,13 +315,12 @@ "allowOrigins": "允许来源域名", "allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173", "secretPlaceholder": "输入密钥", - "secretHintSet": "已设置密钥,留空表示不修改。" + "secretHintSet": "配置已保存,留空表示不修改" }, "page": { "notFound": "不支持频道“{{name}}”。", "saveSuccess": "频道配置已保存。", "saveError": "保存频道配置失败", - "enabled": "已启用", "docLink": "配置文档", "enableLabel": "启用频道", "restartRequiredTitle": "需要重启服务", @@ -337,58 +328,58 @@ }, "form": { "desc": { - "token": "机器人访问令牌,用于连接平台 API。", - "botToken": "Bot Token,用于发送与接收消息。", + "token": "机器人访问令牌,用于连接平台 API", + "botToken": "Bot Token,用于发送与接收消息", "appToken": "App Token,用于 Socket 模式连接。", - "appId": "应用唯一标识,用于平台鉴权。", - "appSecret": "应用密钥,用于请求签名和鉴权。", - "verificationToken": "事件回调验证令牌。", - "encryptKey": "消息加密密钥,用于解密回调内容。", - "baseUrl": "平台 API 地址,默认使用官方地址。", - "proxy": "HTTP 代理地址,用于网络访问。", - "mentionOnly": "在群聊中仅当明确提及时才响应。", - "typingEnabled": "在生成回复时显示“正在输入”状态。", - "placeholderEnabled": "在最终回复发送前,先发送临时占位消息。", - "groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应。", - "groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔。", - "isLark": "使用 Lark 国际版域名(open.larksuite.com)替代飞书域名(open.feishu.cn)。", - "allowFrom": "允许访问的用户或群组 ID,多个值用逗号分隔。", - "allowOrigins": "允许访问的来源域名,多个值用逗号分隔。", - "wsUrl": "WebSocket 服务地址。", - "reconnectInterval": "断线后的重连间隔(秒)。", - "bridgeUrl": "桥接服务地址。", - "sessionStorePath": "本地会话存储目录路径。", - "useNative": "是否使用原生客户端模式连接。", - "host": "服务监听主机地址。", - "port": "服务监听端口。", - "homeserver": "Matrix homeserver 地址。", - "userId": "账号 ID。", - "deviceId": "设备 ID。", - "joinOnInvite": "收到邀请时是否自动加入房间。", - "clientId": "应用客户端 ID,用于平台鉴权。", - "corpId": "企业 ID。", - "agentId": "企业应用 Agent ID。", - "webhookUrl": "Webhook 完整地址。", - "webhookHost": "Webhook 监听主机。", - "webhookPort": "Webhook 监听端口。", - "webhookPath": "Webhook 路径。", - "replyTimeout": "回复超时时间(秒)。", - "maxSteps": "最大步骤数。", - "welcomeMessage": "新会话欢迎语内容。", - "allowTokenQuery": "是否允许 URL Query 方式传递 Token。", - "pingInterval": "连接心跳间隔(秒)。", - "readTimeout": "读取超时时间(秒)。", - "writeTimeout": "写入超时时间(秒)。", - "maxConnections": "最大并发连接数。", - "server": "IRC 服务器地址。", - "tls": "是否启用 TLS 连接。", - "nick": "机器人昵称。", - "user": "IRC 用户名。", - "realName": "显示名称。", - "channels": "要加入的 IRC 频道列表。", - "requestCaps": "连接时请求的 IRC 扩展能力列表。", - "maxBase64FileSizeMiB": "本地文件转为 base64 上传的最大体积,单位 MiB;0 表示不限制,仅影响本地文件,不影响 URL 直传。", - "genericField": "用于配置{{field}}。" + "appId": "应用唯一标识,用于平台鉴权", + "appSecret": "应用密钥,用于请求签名和鉴权", + "verificationToken": "事件回调验证令牌", + "encryptKey": "消息加密密钥,用于解密回调内容", + "baseUrl": "平台 API 地址,默认使用官方地址", + "proxy": "HTTP 代理地址,用于网络访问", + "mentionOnly": "在群聊中仅当明确提及时才响应", + "typingEnabled": "在生成回复时显示“正在输入”状态", + "placeholderEnabled": "在最终回复发送前,先发送临时占位消息", + "groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应", + "groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔", + "isLark": "使用 Lark 国际版域名(open.larksuite.com)替代飞书域名(open.feishu.cn)", + "allowFrom": "允许访问的用户或群组 ID,多个值用逗号分隔", + "allowOrigins": "允许访问的来源域名,多个值用逗号分隔", + "wsUrl": "WebSocket 服务地址", + "reconnectInterval": "断线后的重连间隔(秒)", + "bridgeUrl": "桥接服务地址", + "sessionStorePath": "本地会话存储目录路径", + "useNative": "是否使用原生客户端模式连接", + "host": "服务监听主机地址", + "port": "服务监听端口", + "homeserver": "Matrix homeserver 地址", + "userId": "账号 ID", + "deviceId": "设备 ID", + "joinOnInvite": "收到邀请时是否自动加入房间", + "clientId": "应用客户端 ID,用于平台鉴权", + "corpId": "企业 ID", + "agentId": "企业应用 Agent ID", + "webhookUrl": "Webhook 完整地址", + "webhookHost": "Webhook 监听主机", + "webhookPort": "Webhook 监听端口", + "webhookPath": "Webhook 路径", + "replyTimeout": "回复超时时间(秒)", + "maxSteps": "最大步骤数", + "welcomeMessage": "新会话欢迎语内容", + "allowTokenQuery": "是否允许 URL Query 方式传递 Token", + "pingInterval": "连接心跳间隔(秒)", + "readTimeout": "读取超时时间(秒)", + "writeTimeout": "写入超时时间(秒)", + "maxConnections": "最大并发连接数", + "server": "IRC 服务器地址", + "tls": "是否启用 TLS 连接", + "nick": "机器人昵称", + "user": "IRC 用户名", + "realName": "显示名称", + "channels": "要加入的 IRC 频道列表", + "requestCaps": "连接时请求的 IRC 扩展能力列表", + "maxBase64FileSizeMiB": "本地文件转为 base64 上传的最大体积,单位 MiB;0 表示不限制,仅影响本地文件,不影响 URL 直传", + "genericField": "用于配置{{field}}" } }, "validation": {