mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// API client for channels navigation and channel-specific config flows.
|
||||
|
||||
import { launcherFetch } from "@/api/http"
|
||||
|
||||
export type ChannelConfig = Record<string, unknown>
|
||||
@@ -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<AppConfig> {
|
||||
return request<AppConfig>("/api/config")
|
||||
}
|
||||
|
||||
export async function getChannelConfig(
|
||||
channelName: string,
|
||||
): Promise<ChannelConfigResponse> {
|
||||
return request<ChannelConfigResponse>(
|
||||
`/api/channels/${encodeURIComponent(channelName)}/config`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function patchAppConfig(
|
||||
patch: Record<string, unknown>,
|
||||
): Promise<ConfigActionResponse> {
|
||||
|
||||
@@ -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<string, string[]> = {
|
||||
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
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
@@ -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<SupportedChannel | null>(null)
|
||||
const [baseConfig, setBaseConfig] = useState<ChannelConfig>({})
|
||||
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
|
||||
const [configuredSecrets, setConfiguredSecrets] = useState<string[]>([])
|
||||
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) {
|
||||
<TelegramForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
configuredSecrets={configuredSecrets}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
@@ -465,7 +452,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||
<DiscordForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
configuredSecrets={configuredSecrets}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
@@ -474,7 +461,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||
<SlackForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
configuredSecrets={configuredSecrets}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
@@ -483,7 +470,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||
<FeishuForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
configuredSecrets={configuredSecrets}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
@@ -510,7 +497,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||
<GenericForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
configuredSecrets={configuredSecrets}
|
||||
hiddenKeys={[...hiddenKeys, "bot_id"]}
|
||||
requiredKeys={requiredKeys}
|
||||
fieldErrors={fieldErrors}
|
||||
@@ -522,7 +509,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||
<GenericForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
configuredSecrets={configuredSecrets}
|
||||
hiddenKeys={hiddenKeys}
|
||||
requiredKeys={requiredKeys}
|
||||
fieldErrors={fieldErrors}
|
||||
@@ -536,19 +523,17 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||
<PageHeader
|
||||
title={channelDisplayName}
|
||||
titleExtra={
|
||||
channel ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{enabled ? (
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||
{t("channels.page.enabled")}
|
||||
</span>
|
||||
) : configured ? (
|
||||
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400">
|
||||
{t("channels.status.configured")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : undefined
|
||||
channel &&
|
||||
docsUrl && (
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline underline-offset-2"
|
||||
>
|
||||
{t("channels.page.docLink")}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -562,46 +547,9 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||
{fetchError}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full max-w-250 space-y-5 pt-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<p className="font-medium">
|
||||
{t("channels.edit", {
|
||||
name: channelDisplayName,
|
||||
})}
|
||||
</p>
|
||||
{channel && docsUrl && (
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline underline-offset-2"
|
||||
>
|
||||
{t("channels.page.docLink")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{channel?.name === "weixin" && (
|
||||
<div className="rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<IconAlertTriangle
|
||||
size={18}
|
||||
className="mt-0.5 shrink-0 text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||
{t("channels.weixin.warningTitle")}
|
||||
</p>
|
||||
<p className="text-sm text-amber-700/90 dark:text-amber-300/90">
|
||||
{t("channels.weixin.warningDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full max-w-4xl space-y-6 pt-5">
|
||||
{!hidesPageLevelEnableToggle && (
|
||||
<div className="border-border/60 bg-background flex items-center justify-between rounded-lg border px-4 py-3">
|
||||
<div className="bg-card text-card-foreground border-border/60 flex items-center justify-between rounded-xl border px-6 py-4 shadow-sm">
|
||||
<p className="text-sm font-medium">
|
||||
{t("channels.page.enableLabel")}
|
||||
</p>
|
||||
|
||||
@@ -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<string, string>
|
||||
}
|
||||
|
||||
@@ -35,75 +36,83 @@ function asRecord(value: unknown): Record<string, unknown> {
|
||||
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 (
|
||||
<div className="space-y-5">
|
||||
<Field
|
||||
label={t("channels.field.token")}
|
||||
required
|
||||
hint={`${t("channels.form.desc.token")}${tokenExtraHint}`}
|
||||
error={fieldErrors.token}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._token)}
|
||||
onChange={(v) => onChange("_token", v)}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.token,
|
||||
t("channels.field.tokenPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="space-y-6">
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||
<Field
|
||||
label={t("channels.field.token")}
|
||||
required
|
||||
hint={t("channels.form.desc.token")}
|
||||
error={fieldErrors.token}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._token)}
|
||||
onChange={(v) => onChange("_token", v)}
|
||||
placeholder={getSecretInputPlaceholder(
|
||||
configuredSecrets,
|
||||
"token",
|
||||
t("channels.field.secretHintSet"),
|
||||
t("channels.field.tokenPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.proxy")}
|
||||
hint={t("channels.form.desc.proxy")}
|
||||
>
|
||||
<Input
|
||||
value={asString(config.proxy)}
|
||||
onChange={(e) => onChange("proxy", e.target.value)}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||
<Field
|
||||
label={t("channels.field.proxy")}
|
||||
hint={t("channels.form.desc.proxy")}
|
||||
>
|
||||
<Input
|
||||
value={asString(config.proxy)}
|
||||
onChange={(e) => onChange("proxy", e.target.value)}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("channels.field.mentionOnly")}
|
||||
hint={t("channels.form.desc.mentionOnly")}
|
||||
checked={asBool(groupTriggerConfig.mention_only)}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange("group_trigger", {
|
||||
...groupTriggerConfig,
|
||||
mention_only: checked,
|
||||
})
|
||||
}}
|
||||
ariaLabel={t("channels.field.mentionOnly")}
|
||||
/>
|
||||
<div>
|
||||
<SwitchCardField
|
||||
label={t("channels.field.mentionOnly")}
|
||||
hint={t("channels.form.desc.mentionOnly")}
|
||||
checked={asBool(groupTriggerConfig.mention_only)}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange("group_trigger", {
|
||||
...groupTriggerConfig,
|
||||
mention_only: checked,
|
||||
})
|
||||
}}
|
||||
ariaLabel={t("channels.field.mentionOnly")}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, string>
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-5">
|
||||
<Field
|
||||
label={t("channels.field.appId")}
|
||||
required
|
||||
hint={t("channels.form.desc.appId")}
|
||||
error={fieldErrors.app_id}
|
||||
>
|
||||
<Input
|
||||
value={asString(config.app_id)}
|
||||
onChange={(e) => onChange("app_id", e.target.value)}
|
||||
placeholder="cli_xxxx"
|
||||
/>
|
||||
</Field>
|
||||
<div className="space-y-6">
|
||||
<Card className="py-3 shadow-sm">
|
||||
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||
<Field
|
||||
label={t("channels.field.appId")}
|
||||
required
|
||||
hint={t("channels.form.desc.appId")}
|
||||
error={fieldErrors.app_id}
|
||||
>
|
||||
<Input
|
||||
value={asString(config.app_id)}
|
||||
onChange={(e) => onChange("app_id", e.target.value)}
|
||||
placeholder="cli_xxxx"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.appSecret")}
|
||||
required
|
||||
hint={`${t("channels.form.desc.appSecret")}${appSecretExtraHint}`}
|
||||
error={fieldErrors.app_secret}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._app_secret)}
|
||||
onChange={(v) => onChange("_app_secret", v)}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.app_secret,
|
||||
t("channels.field.secretPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.appSecret")}
|
||||
required
|
||||
hint={t("channels.form.desc.appSecret")}
|
||||
error={fieldErrors.app_secret}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._app_secret)}
|
||||
onChange={(v) => onChange("_app_secret", v)}
|
||||
placeholder={getSecretInputPlaceholder(
|
||||
configuredSecrets,
|
||||
"app_secret",
|
||||
t("channels.field.secretHintSet"),
|
||||
t("channels.field.secretPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.verificationToken")}
|
||||
hint={`${t("channels.form.desc.verificationToken")}${verificationExtraHint}`}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._verification_token)}
|
||||
onChange={(v) => onChange("_verification_token", v)}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.verification_token,
|
||||
t("channels.field.secretPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.encryptKey")}
|
||||
hint={`${t("channels.form.desc.encryptKey")}${encryptExtraHint}`}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._encrypt_key)}
|
||||
onChange={(v) => onChange("_encrypt_key", v)}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.encrypt_key,
|
||||
t("channels.field.secretPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<SwitchCardField
|
||||
label={t("channels.field.isLark")}
|
||||
hint={t("channels.form.desc.isLark")}
|
||||
checked={asBool(config.is_lark)}
|
||||
onCheckedChange={(checked) => onChange("is_lark", checked)}
|
||||
/>
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
<Card className="py-3 shadow-sm">
|
||||
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||
<Field
|
||||
label={t("channels.field.verificationToken")}
|
||||
hint={t("channels.form.desc.verificationToken")}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._verification_token)}
|
||||
onChange={(v) => onChange("_verification_token", v)}
|
||||
placeholder={getSecretInputPlaceholder(
|
||||
configuredSecrets,
|
||||
"verification_token",
|
||||
t("channels.field.secretHintSet"),
|
||||
t("channels.field.secretPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.encryptKey")}
|
||||
hint={t("channels.form.desc.encryptKey")}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._encrypt_key)}
|
||||
onChange={(v) => onChange("_encrypt_key", v)}
|
||||
placeholder={getSecretInputPlaceholder(
|
||||
configuredSecrets,
|
||||
"encrypt_key",
|
||||
t("channels.field.secretHintSet"),
|
||||
t("channels.field.secretPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<SwitchCardField
|
||||
label={t("channels.field.isLark")}
|
||||
hint={t("channels.form.desc.isLark")}
|
||||
checked={asBool(config.is_lark)}
|
||||
onCheckedChange={(checked) => onChange("is_lark", checked)}
|
||||
ariaLabel={t("channels.field.isLark")}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, string>
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="space-y-5">
|
||||
{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 (
|
||||
<Field
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
required={isRequired}
|
||||
hint={`${buildHint(key)}${extraHint}`}
|
||||
error={fieldErrors[key]}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config[editKey])}
|
||||
onChange={(v) => onChange(editKey, v)}
|
||||
placeholder={maskedSecretPlaceholder(config[key])}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
const value = config[key]
|
||||
if (typeof value === "boolean") {
|
||||
return (
|
||||
<SwitchCardField
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
hint={buildHint(key)}
|
||||
error={fieldErrors[key]}
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => onChange(key, checked)}
|
||||
ariaLabel={formatLabel(key)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<Field
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
required={isRequired}
|
||||
hint={buildHint(key)}
|
||||
error={fieldErrors[key]}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(value).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
key,
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Field
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
required={isRequired}
|
||||
hint={buildHint(key)}
|
||||
error={fieldErrors[key]}
|
||||
>
|
||||
<Input
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => {
|
||||
// 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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 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 (
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
required={isRequired}
|
||||
hint={buildHint(key)}
|
||||
error={fieldErrors[key]}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config[editKey])}
|
||||
onChange={(v) => onChange(editKey, v)}
|
||||
placeholder={getSecretInputPlaceholder(
|
||||
configuredSecrets,
|
||||
key,
|
||||
t("channels.field.secretHintSet"),
|
||||
t("channels.field.secretPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
const value = config[key]
|
||||
if (typeof value === "boolean") {
|
||||
return (
|
||||
<SwitchCardField
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
hint={buildHint(key)}
|
||||
error={fieldErrors[key]}
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => onChange(key, checked)}
|
||||
ariaLabel={formatLabel(key)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<Field
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
required={isRequired}
|
||||
hint={buildHint(key)}
|
||||
error={fieldErrors[key]}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
value={asStringArray(value).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
key,
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
)
|
||||
}
|
||||
|
||||
{config.allow_origins !== undefined &&
|
||||
!hiddenFieldSet.has("allow_origins") && (
|
||||
<Field
|
||||
label={t("channels.field.allowOrigins")}
|
||||
hint={t("channels.form.desc.allowOrigins")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_origins).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_origins",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowOriginsPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{config.allow_token_query !== undefined &&
|
||||
!hiddenFieldSet.has("allow_token_query") && (
|
||||
<SwitchCardField
|
||||
label={formatLabel("allow_token_query")}
|
||||
hint={buildHint("allow_token_query")}
|
||||
checked={asBool(config.allow_token_query)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("allow_token_query", checked)
|
||||
return (
|
||||
<Field
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
required={isRequired}
|
||||
hint={buildHint(key)}
|
||||
error={fieldErrors[key]}
|
||||
>
|
||||
<Input
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => {
|
||||
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") && (
|
||||
<>
|
||||
<SwitchCardField
|
||||
label={t("channels.field.groupTriggerMentionOnly")}
|
||||
hint={t("channels.form.desc.groupTriggerMentionOnly")}
|
||||
checked={asBool(groupTriggerConfig.mention_only)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("group_trigger", {
|
||||
...groupTriggerConfig,
|
||||
mention_only: checked,
|
||||
})
|
||||
}
|
||||
ariaLabel={t("channels.field.groupTriggerMentionOnly")}
|
||||
/>
|
||||
<Field
|
||||
label={t("channels.field.groupTriggerPrefixes")}
|
||||
hint={t("channels.form.desc.groupTriggerPrefixes")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(groupTriggerConfig.prefixes).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange("group_trigger", {
|
||||
...groupTriggerConfig,
|
||||
prefixes: e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
placeholder={t("channels.field.groupTriggerPrefixes")}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{config.typing !== undefined && !hiddenFieldSet.has("typing") && (
|
||||
<SwitchCardField
|
||||
label={t("channels.field.typingEnabled")}
|
||||
hint={t("channels.form.desc.typingEnabled")}
|
||||
checked={asBool(typingConfig.enabled)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("typing", { ...typingConfig, enabled: checked })
|
||||
}
|
||||
ariaLabel={t("channels.field.typingEnabled")}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{basicFields.length > 0 && (
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||
{basicFields.map(renderField)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{config.placeholder !== undefined &&
|
||||
!hiddenFieldSet.has("placeholder") && (
|
||||
<SwitchCardField
|
||||
label={t("channels.field.placeholderEnabled")}
|
||||
hint={t("channels.form.desc.placeholderEnabled")}
|
||||
checked={placeholderEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("placeholder", {
|
||||
...placeholderConfig,
|
||||
enabled: checked,
|
||||
})
|
||||
}
|
||||
ariaLabel={t("channels.field.placeholderEnabled")}
|
||||
>
|
||||
{placeholderEnabled && (
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
value={asString(placeholderConfig.text)}
|
||||
onChange={(e) =>
|
||||
onChange("placeholder", {
|
||||
...placeholderConfig,
|
||||
text: e.target.value,
|
||||
})
|
||||
{hasAdvancedContent && (
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||
{advancedFields.map(renderField)}
|
||||
|
||||
{config.allow_from !== undefined &&
|
||||
!hiddenFieldSet.has("allow_from") && (
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{config.allow_origins !== undefined &&
|
||||
!hiddenFieldSet.has("allow_origins") && (
|
||||
<Field
|
||||
label={t("channels.field.allowOrigins")}
|
||||
hint={t("channels.form.desc.allowOrigins")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_origins).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_origins",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowOriginsPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{config.allow_token_query !== undefined &&
|
||||
!hiddenFieldSet.has("allow_token_query") && (
|
||||
<div>
|
||||
<SwitchCardField
|
||||
label={formatLabel("allow_token_query")}
|
||||
hint={buildHint("allow_token_query")}
|
||||
checked={asBool(config.allow_token_query)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("allow_token_query", checked)
|
||||
}
|
||||
ariaLabel={formatLabel("allow_token_query")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.group_trigger !== undefined &&
|
||||
!hiddenFieldSet.has("group_trigger") && (
|
||||
<>
|
||||
<div>
|
||||
<SwitchCardField
|
||||
label={t("channels.field.groupTriggerMentionOnly")}
|
||||
hint={t("channels.form.desc.groupTriggerMentionOnly")}
|
||||
checked={asBool(groupTriggerConfig.mention_only)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("group_trigger", {
|
||||
...groupTriggerConfig,
|
||||
mention_only: checked,
|
||||
})
|
||||
}
|
||||
ariaLabel={t("channels.field.groupTriggerMentionOnly")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.groupTriggerPrefixes")}
|
||||
hint={t("channels.form.desc.groupTriggerPrefixes")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(groupTriggerConfig.prefixes).join(
|
||||
", ",
|
||||
)}
|
||||
onChange={(e) =>
|
||||
onChange("group_trigger", {
|
||||
...groupTriggerConfig,
|
||||
prefixes: e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
placeholder={t("channels.field.groupTriggerPrefixes")}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{config.typing !== undefined && !hiddenFieldSet.has("typing") && (
|
||||
<div>
|
||||
<SwitchCardField
|
||||
label={t("channels.field.typingEnabled")}
|
||||
hint={t("channels.form.desc.typingEnabled")}
|
||||
checked={asBool(typingConfig.enabled)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("typing", { ...typingConfig, enabled: checked })
|
||||
}
|
||||
placeholder={t("channels.field.placeholderText")}
|
||||
aria-label={t("channels.field.placeholderText")}
|
||||
ariaLabel={t("channels.field.typingEnabled")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SwitchCardField>
|
||||
)}
|
||||
|
||||
{config.placeholder !== undefined &&
|
||||
!hiddenFieldSet.has("placeholder") && (
|
||||
<div>
|
||||
<SwitchCardField
|
||||
label={t("channels.field.placeholderEnabled")}
|
||||
hint={t("channels.form.desc.placeholderEnabled")}
|
||||
checked={placeholderEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("placeholder", {
|
||||
...placeholderConfig,
|
||||
enabled: checked,
|
||||
})
|
||||
}
|
||||
ariaLabel={t("channels.field.placeholderEnabled")}
|
||||
>
|
||||
{placeholderEnabled && (
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
value={asString(placeholderConfig.text)}
|
||||
onChange={(e) =>
|
||||
onChange("placeholder", {
|
||||
...placeholderConfig,
|
||||
text: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t("channels.field.placeholderText")}
|
||||
aria-label={t("channels.field.placeholderText")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SwitchCardField>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, string>
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-5">
|
||||
<Field
|
||||
label={t("channels.field.botToken")}
|
||||
required
|
||||
hint={`${t("channels.form.desc.botToken")}${botTokenExtraHint}`}
|
||||
error={fieldErrors.bot_token}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._bot_token)}
|
||||
onChange={(v) => onChange("_bot_token", v)}
|
||||
placeholder={maskedSecretPlaceholder(config.bot_token, "xoxb-xxxx")}
|
||||
/>
|
||||
</Field>
|
||||
<div className="space-y-6">
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||
<Field
|
||||
label={t("channels.field.botToken")}
|
||||
required
|
||||
hint={t("channels.form.desc.botToken")}
|
||||
error={fieldErrors.bot_token}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._bot_token)}
|
||||
onChange={(v) => onChange("_bot_token", v)}
|
||||
placeholder={getSecretInputPlaceholder(
|
||||
configuredSecrets,
|
||||
"bot_token",
|
||||
t("channels.field.secretHintSet"),
|
||||
"xoxb-xxxx",
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.appToken")}
|
||||
hint={`${t("channels.form.desc.appToken")}${appTokenExtraHint}`}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._app_token)}
|
||||
onChange={(v) => onChange("_app_token", v)}
|
||||
placeholder={maskedSecretPlaceholder(config.app_token, "xapp-xxxx")}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.appToken")}
|
||||
hint={t("channels.form.desc.appToken")}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._app_token)}
|
||||
onChange={(v) => onChange("_app_token", v)}
|
||||
placeholder={getSecretInputPlaceholder(
|
||||
configuredSecrets,
|
||||
"app_token",
|
||||
t("channels.field.secretHintSet"),
|
||||
"xapp-xxxx",
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, string>
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-5">
|
||||
<Field
|
||||
label={t("channels.field.token")}
|
||||
required
|
||||
hint={`${t("channels.form.desc.token")}${tokenExtraHint}`}
|
||||
error={fieldErrors.token}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._token)}
|
||||
onChange={(v) => onChange("_token", v)}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.token,
|
||||
t("channels.field.tokenPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="space-y-6">
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||
<Field
|
||||
label={t("channels.field.token")}
|
||||
required
|
||||
hint={t("channels.form.desc.token")}
|
||||
error={fieldErrors.token}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._token)}
|
||||
onChange={(v) => onChange("_token", v)}
|
||||
placeholder={getSecretInputPlaceholder(
|
||||
configuredSecrets,
|
||||
"token",
|
||||
t("channels.field.secretHintSet"),
|
||||
t("channels.field.tokenPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.baseUrl")}
|
||||
hint={t("channels.form.desc.baseUrl")}
|
||||
>
|
||||
<Input
|
||||
value={asString(config.base_url)}
|
||||
onChange={(e) => onChange("base_url", e.target.value)}
|
||||
placeholder="https://api.telegram.org"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.proxy")}
|
||||
hint={t("channels.form.desc.proxy")}
|
||||
>
|
||||
<Input
|
||||
value={asString(config.proxy)}
|
||||
onChange={(e) => onChange("proxy", e.target.value)}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("channels.field.typingEnabled")}
|
||||
hint={t("channels.form.desc.typingEnabled")}
|
||||
checked={asBool(typingConfig.enabled)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("typing", { ...typingConfig, enabled: checked })
|
||||
}
|
||||
ariaLabel={t("channels.field.typingEnabled")}
|
||||
/>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("channels.field.placeholderEnabled")}
|
||||
hint={t("channels.form.desc.placeholderEnabled")}
|
||||
checked={placeholderEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("placeholder", {
|
||||
...placeholderConfig,
|
||||
enabled: checked,
|
||||
})
|
||||
}
|
||||
ariaLabel={t("channels.field.placeholderEnabled")}
|
||||
>
|
||||
{placeholderEnabled && (
|
||||
<div className="space-y-1">
|
||||
<Field
|
||||
label={t("channels.field.baseUrl")}
|
||||
hint={t("channels.form.desc.baseUrl")}
|
||||
>
|
||||
<Input
|
||||
value={asString(placeholderConfig.text)}
|
||||
value={asString(config.base_url)}
|
||||
onChange={(e) => onChange("base_url", e.target.value)}
|
||||
placeholder="https://api.telegram.org"
|
||||
/>
|
||||
</Field>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||
<Field
|
||||
label={t("channels.field.proxy")}
|
||||
hint={t("channels.form.desc.proxy")}
|
||||
>
|
||||
<Input
|
||||
value={asString(config.proxy)}
|
||||
onChange={(e) => onChange("proxy", e.target.value)}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
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")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<SwitchCardField
|
||||
label={t("channels.field.typingEnabled")}
|
||||
hint={t("channels.form.desc.typingEnabled")}
|
||||
checked={asBool(typingConfig.enabled)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("typing", { ...typingConfig, enabled: checked })
|
||||
}
|
||||
ariaLabel={t("channels.field.typingEnabled")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SwitchCardField>
|
||||
|
||||
<div>
|
||||
<SwitchCardField
|
||||
label={t("channels.field.placeholderEnabled")}
|
||||
hint={t("channels.form.desc.placeholderEnabled")}
|
||||
checked={placeholderEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("placeholder", {
|
||||
...placeholderConfig,
|
||||
enabled: checked,
|
||||
})
|
||||
}
|
||||
ariaLabel={t("channels.field.placeholderEnabled")}
|
||||
>
|
||||
{placeholderEnabled && (
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
value={asString(placeholderConfig.text)}
|
||||
onChange={(e) =>
|
||||
onChange("placeholder", {
|
||||
...placeholderConfig,
|
||||
text: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t("channels.field.placeholderText")}
|
||||
aria-label={t("channels.field.placeholderText")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SwitchCardField>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-5">
|
||||
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("channels.page.enableLabel")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{isBound
|
||||
? t("channels.wecom.enableDesc")
|
||||
: t("channels.wecom.enableBindFirst")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="bg-card text-card-foreground border-border/60 flex items-center justify-between rounded-xl border px-6 py-4 shadow-sm">
|
||||
<p className="text-sm font-medium">{t("channels.page.enableLabel")}</p>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={!isBound || toggleSaving}
|
||||
onCheckedChange={(checked) => void handleEnabledChange(checked)}
|
||||
/>
|
||||
{toggleError && (
|
||||
<p className="text-destructive max-w-60 text-right text-xs leading-normal">
|
||||
{toggleError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{toggleError && (
|
||||
<p className="text-destructive mt-2 text-sm">{toggleError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-border/60 bg-muted/30 rounded-xl border">
|
||||
<div className="border-border/60 border-b px-4 py-3">
|
||||
<p className="text-sm font-medium">{t("channels.wecom.bindTitle")}</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{t("channels.wecom.bindDesc")}
|
||||
</p>
|
||||
</div>
|
||||
{renderBindSection()}
|
||||
</div>
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="border-border/60 border-b px-6">
|
||||
<CardTitle className="text-foreground text-sm font-medium">
|
||||
{t("channels.wecom.bindTitle")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("channels.wecom.bindDesc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">{renderBindSection()}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-5">
|
||||
{/* QR Bind Section */}
|
||||
<div className="border-border/60 bg-muted/30 rounded-xl border">
|
||||
<div className="border-border/60 border-b px-4 py-3">
|
||||
<p className="text-sm font-medium">
|
||||
<div className="space-y-6">
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="border-border/60 border-b px-6">
|
||||
<CardTitle className="text-foreground text-sm font-medium">
|
||||
{t("channels.weixin.bindTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{t("channels.weixin.bindDesc")}
|
||||
</p>
|
||||
</div>
|
||||
{renderBindSection()}
|
||||
</div>
|
||||
</CardTitle>
|
||||
<CardDescription>{t("channels.weixin.bindDesc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">{renderBindSection()}</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* allow_from */}
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* proxy */}
|
||||
<Field
|
||||
label={t("channels.field.proxy")}
|
||||
hint={t("channels.form.desc.proxy")}
|
||||
>
|
||||
<Input
|
||||
value={asString(config.proxy)}
|
||||
onChange={(e) => onChange("proxy", e.target.value)}
|
||||
placeholder="http://localhost:7890"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.proxy")}
|
||||
hint={t("channels.form.desc.proxy")}
|
||||
>
|
||||
<Input
|
||||
value={asString(config.proxy)}
|
||||
onChange={(e) => onChange("proxy", e.target.value)}
|
||||
placeholder="http://localhost:7890"
|
||||
/>
|
||||
</Field>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,23 +34,28 @@ export function Field({
|
||||
}: FieldProps) {
|
||||
if (layout === "setting-row") {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[minmax(0,1fr)_minmax(240px,320px)] md:items-center md:gap-6">
|
||||
<div className="max-w-full space-y-1 md:max-w-[clamp(18rem,42vw,28rem)]">
|
||||
<FieldLabel>
|
||||
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[280px_minmax(0,1fr)] md:items-center md:gap-8">
|
||||
<div className="w-full min-w-0">
|
||||
<FieldLabel className="leading-relaxed break-words whitespace-normal">
|
||||
{label}
|
||||
{required && <span className="text-destructive ml-1">*</span>}
|
||||
</FieldLabel>
|
||||
{hint && (
|
||||
<FieldDescription className="text-xs leading-normal break-words">
|
||||
<FieldDescription className="mt-1 text-xs leading-relaxed break-words whitespace-normal">
|
||||
{hint}
|
||||
</FieldDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("w-full md:justify-self-center", controlClassName)}>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full md:max-w-[28rem] md:justify-self-end",
|
||||
controlClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{error && (
|
||||
<FieldDescription className="text-destructive text-xs leading-normal md:col-start-2">
|
||||
<FieldDescription className="text-destructive text-xs leading-normal md:col-start-2 md:justify-self-end">
|
||||
{error}
|
||||
</FieldDescription>
|
||||
)}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[minmax(0,1fr)_auto] md:items-center md:gap-6">
|
||||
<div className="max-w-full min-w-0 md:max-w-[clamp(18rem,42vw,28rem)]">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[280px_minmax(0,1fr)] md:items-center md:gap-8">
|
||||
<div className="w-full min-w-0">
|
||||
<p className="text-sm leading-relaxed font-medium break-words whitespace-normal">
|
||||
{label}
|
||||
</p>
|
||||
{hint && (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs leading-normal break-words">
|
||||
<p className="text-muted-foreground mt-1 text-xs leading-relaxed break-words whitespace-normal">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center md:justify-self-center">
|
||||
<div className="flex items-center md:justify-self-end">
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
@@ -157,9 +166,13 @@ export function SwitchCardField({
|
||||
aria-label={ariaLabel ?? label}
|
||||
/>
|
||||
</div>
|
||||
{children && <div className="md:col-start-2">{children}</div>}
|
||||
{children && (
|
||||
<div className="mt-1 flex w-full justify-end md:col-start-2">
|
||||
<div className="w-full md:max-w-[28rem]">{children}</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-destructive text-xs leading-normal md:col-start-2">
|
||||
<p className="text-destructive text-xs leading-normal md:col-start-2 md:justify-self-end">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
@@ -168,7 +181,11 @@ export function SwitchCardField({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
|
||||
<div
|
||||
className={cn(
|
||||
transparent ? "py-1" : "border-border/60 rounded-lg border px-4 py-3",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
@@ -185,7 +202,7 @@ export function SwitchCardField({
|
||||
aria-label={ariaLabel ?? label}
|
||||
/>
|
||||
</div>
|
||||
{children && <div className="mt-3">{children}</div>}
|
||||
{children && <div className="mt-4">{children}</div>}
|
||||
{error && (
|
||||
<p className="text-destructive mt-2 text-xs leading-normal">{error}</p>
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user