fix(web): harden channel config updates and resolve frontend lint issues

- validate channel PUT/PATCH updates before saving and return structured validation errors
- require `enabled` in toggle requests to avoid silent false defaults
- support editing `allow_origins` in the generic channel form and parse string/array inputs on backend
- replace channel form `any` usage with `ChannelConfig` (`Record<string, unknown>`) and add safe value helpers
- add i18n strings for allow-origins fields and apply related frontend formatting cleanups
This commit is contained in:
wenjie 2026-03-06 20:00:07 +08:00
parent b7469d4ce9
commit ea5401cf0a
17 changed files with 302 additions and 126 deletions

View file

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strings"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
) )
@ -105,6 +106,10 @@ func (h *Handler) handleUpdateChannel(w http.ResponseWriter, r *http.Request) {
} }
applyChannelUpdate(name, &cfg.Channels, incoming) applyChannelUpdate(name, &cfg.Channels, incoming)
if errs := validateConfig(cfg); len(errs) > 0 {
writeValidationErrors(w, errs)
return
}
if err := config.SaveConfig(h.configPath, cfg); err != nil { if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
@ -133,12 +138,16 @@ func (h *Handler) handleToggleChannel(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close() defer r.Body.Close()
var req struct { var req struct {
Enabled bool `json:"enabled"` Enabled *bool `json:"enabled"`
} }
if err = json.Unmarshal(body, &req); err != nil { if err = json.Unmarshal(body, &req); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return return
} }
if req.Enabled == nil {
http.Error(w, "Missing required field: enabled", http.StatusBadRequest)
return
}
cfg, err := config.LoadConfig(h.configPath) cfg, err := config.LoadConfig(h.configPath)
if err != nil { if err != nil {
@ -146,7 +155,11 @@ func (h *Handler) handleToggleChannel(w http.ResponseWriter, r *http.Request) {
return return
} }
setChannelEnabled(name, &cfg.Channels, req.Enabled) setChannelEnabled(name, &cfg.Channels, *req.Enabled)
if errs := validateConfig(cfg); len(errs) > 0 {
writeValidationErrors(w, errs)
return
}
if err := config.SaveConfig(h.configPath, cfg); err != nil { if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
@ -166,6 +179,15 @@ func isValidChannel(name string) bool {
return false return false
} }
func writeValidationErrors(w http.ResponseWriter, errs []string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
"status": "validation_error",
"errors": errs,
})
}
// extractChannelInfo returns enabled, configured status and masked config for a channel. // extractChannelInfo returns enabled, configured status and masked config for a channel.
func extractChannelInfo(name string, ch *config.ChannelsConfig) (bool, bool, map[string]any) { func extractChannelInfo(name string, ch *config.ChannelsConfig) (bool, bool, map[string]any) {
var enabled, configured bool var enabled, configured bool
@ -407,6 +429,50 @@ func applyChannelUpdate(name string, ch *config.ChannelsConfig, incoming map[str
} }
return nil return nil
} }
getStringArray := func(key string) ([]string, bool) {
v, ok := incoming[key]
if !ok {
return nil, false
}
switch arr := v.(type) {
case []any:
result := make([]string, 0, len(arr))
for _, item := range arr {
if s, ok := item.(string); ok {
s = strings.TrimSpace(s)
if s != "" {
result = append(result, s)
}
}
}
return result, true
case []string:
result := make([]string, 0, len(arr))
for _, s := range arr {
s = strings.TrimSpace(s)
if s != "" {
result = append(result, s)
}
}
return result, true
case string:
if strings.TrimSpace(arr) == "" {
return []string{}, true
}
parts := strings.Split(arr, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
result = append(result, part)
}
}
return result, true
default:
return nil, false
}
}
getGroupTrigger := func() config.GroupTriggerConfig { getGroupTrigger := func() config.GroupTriggerConfig {
if v, ok := incoming["group_trigger"]; ok { if v, ok := incoming["group_trigger"]; ok {
if m, ok := v.(map[string]any); ok { if m, ok := v.(map[string]any); ok {
@ -583,16 +649,8 @@ func applyChannelUpdate(name string, ch *config.ChannelsConfig, incoming map[str
c.Enabled = getBool("enabled") c.Enabled = getBool("enabled")
c.Token = preserveSecret(getString("token"), c.Token) c.Token = preserveSecret(getString("token"), c.Token)
c.AllowTokenQuery = getBool("allow_token_query") c.AllowTokenQuery = getBool("allow_token_query")
if v, ok := incoming["allow_origins"]; ok { if origins, ok := getStringArray("allow_origins"); ok {
if arr, ok := v.([]any); ok { c.AllowOrigins = origins
origins := make([]string, 0, len(arr))
for _, item := range arr {
if s, ok := item.(string); ok {
origins = append(origins, s)
}
}
c.AllowOrigins = origins
}
} }
c.PingInterval = getInt("ping_interval") c.PingInterval = getInt("ping_interval")
c.ReadTimeout = getInt("read_timeout") c.ReadTimeout = getInt("read_timeout")

View file

View file

@ -1,11 +1,13 @@
// API client for channel management. // API client for channel management.
export type ChannelConfig = Record<string, unknown>
export interface ChannelInfo { export interface ChannelInfo {
name: string name: string
display_name: string display_name: string
enabled: boolean enabled: boolean
configured: boolean configured: boolean
config: Record<string, any> config: ChannelConfig
} }
interface ChannelsListResponse { interface ChannelsListResponse {
@ -32,7 +34,7 @@ export async function getChannels(): Promise<ChannelsListResponse> {
export async function updateChannel( export async function updateChannel(
name: string, name: string,
config: Record<string, any>, config: ChannelConfig,
): Promise<ChannelActionResponse> { ): Promise<ChannelActionResponse> {
return request<ChannelActionResponse>(`/api/channels/${name}`, { return request<ChannelActionResponse>(`/api/channels/${name}`, {
method: "PUT", method: "PUT",

View file

@ -46,9 +46,7 @@ const navGroups = [
{ {
label: "navigation.channels_group", label: "navigation.channels_group",
defaultOpen: true, defaultOpen: true,
items: [ items: [{ title: "navigation.channels", url: "/channels", icon: IconPlug }],
{ title: "navigation.channels", url: "/channels", icon: IconPlug },
],
}, },
{ {
label: "navigation.services", label: "navigation.services",

View file

@ -29,7 +29,7 @@ export function ChannelCard({
{channel.display_name} {channel.display_name}
</span> </span>
{channel.configured ? ( {channel.configured ? (
<span className="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 rounded-full px-2 py-0.5 text-[10px] font-medium"> <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.status.configured")} {t("channels.status.configured")}
</span> </span>
) : ( ) : (
@ -54,9 +54,7 @@ export function ChannelCard({
onClick={() => onEdit(channel)} onClick={() => onEdit(channel)}
> >
<IconSettings className="size-4" /> <IconSettings className="size-4" />
<span className="sr-only"> <span className="sr-only">{t("channels.action.configure")}</span>
{t("channels.action.configure")}
</span>
</Button> </Button>
</div> </div>
</div> </div>

View file

@ -1,18 +1,28 @@
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Input } from "@/components/ui/input" import type { ChannelConfig } from "@/api/channels"
import { import {
AdvancedSection, AdvancedSection,
Field, Field,
KeyInput, KeyInput,
} from "@/components/models/shared-form" } from "@/components/models/shared-form"
import { Input } from "@/components/ui/input"
interface DiscordFormProps { interface DiscordFormProps {
config: Record<string, any> config: ChannelConfig
onChange: (key: string, value: any) => void onChange: (key: string, value: unknown) => void
isEdit: boolean isEdit: boolean
} }
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === "string")
}
export function DiscordForm({ config, onChange, isEdit }: DiscordFormProps) { export function DiscordForm({ config, onChange, isEdit }: DiscordFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
@ -21,16 +31,16 @@ export function DiscordForm({ config, onChange, isEdit }: DiscordFormProps) {
<Field <Field
label={t("channels.field.token")} label={t("channels.field.token")}
hint={ hint={
isEdit && config.token isEdit && asString(config.token)
? t("channels.field.secretHintSet") ? t("channels.field.secretHintSet")
: undefined : undefined
} }
> >
<KeyInput <KeyInput
value={config._token ?? ""} value={asString(config._token)}
onChange={(v) => onChange("_token", v)} onChange={(v) => onChange("_token", v)}
placeholder={ placeholder={
isEdit && config.token isEdit && asString(config.token)
? t("channels.field.secretPlaceholderSet") ? t("channels.field.secretPlaceholderSet")
: t("channels.field.tokenPlaceholder") : t("channels.field.tokenPlaceholder")
} }
@ -43,7 +53,7 @@ export function DiscordForm({ config, onChange, isEdit }: DiscordFormProps) {
hint={t("channels.field.proxyHint")} hint={t("channels.field.proxyHint")}
> >
<Input <Input
value={config.proxy ?? ""} value={asString(config.proxy)}
onChange={(e) => onChange("proxy", e.target.value)} onChange={(e) => onChange("proxy", e.target.value)}
placeholder="http://127.0.0.1:7890" placeholder="http://127.0.0.1:7890"
/> />
@ -53,7 +63,7 @@ export function DiscordForm({ config, onChange, isEdit }: DiscordFormProps) {
hint={t("channels.field.allowFromHint")} hint={t("channels.field.allowFromHint")}
> >
<Input <Input
value={(config.allow_from ?? []).join(", ")} value={asStringArray(config.allow_from).join(", ")}
onChange={(e) => onChange={(e) =>
onChange( onChange(
"allow_from", "allow_from",

View file

@ -1,18 +1,28 @@
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Input } from "@/components/ui/input" import type { ChannelConfig } from "@/api/channels"
import { import {
AdvancedSection, AdvancedSection,
Field, Field,
KeyInput, KeyInput,
} from "@/components/models/shared-form" } from "@/components/models/shared-form"
import { Input } from "@/components/ui/input"
interface FeishuFormProps { interface FeishuFormProps {
config: Record<string, any> config: ChannelConfig
onChange: (key: string, value: any) => void onChange: (key: string, value: unknown) => void
isEdit: boolean isEdit: boolean
} }
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === "string")
}
export function FeishuForm({ config, onChange, isEdit }: FeishuFormProps) { export function FeishuForm({ config, onChange, isEdit }: FeishuFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
@ -20,7 +30,7 @@ export function FeishuForm({ config, onChange, isEdit }: FeishuFormProps) {
<div className="space-y-5"> <div className="space-y-5">
<Field label={t("channels.field.appId")}> <Field label={t("channels.field.appId")}>
<Input <Input
value={config.app_id ?? ""} value={asString(config.app_id)}
onChange={(e) => onChange("app_id", e.target.value)} onChange={(e) => onChange("app_id", e.target.value)}
placeholder="cli_xxxx" placeholder="cli_xxxx"
/> />
@ -29,16 +39,16 @@ export function FeishuForm({ config, onChange, isEdit }: FeishuFormProps) {
<Field <Field
label={t("channels.field.appSecret")} label={t("channels.field.appSecret")}
hint={ hint={
isEdit && config.app_secret isEdit && asString(config.app_secret)
? t("channels.field.secretHintSet") ? t("channels.field.secretHintSet")
: undefined : undefined
} }
> >
<KeyInput <KeyInput
value={config._app_secret ?? ""} value={asString(config._app_secret)}
onChange={(v) => onChange("_app_secret", v)} onChange={(v) => onChange("_app_secret", v)}
placeholder={ placeholder={
isEdit && config.app_secret isEdit && asString(config.app_secret)
? t("channels.field.secretPlaceholderSet") ? t("channels.field.secretPlaceholderSet")
: t("channels.field.secretPlaceholder") : t("channels.field.secretPlaceholder")
} }
@ -48,10 +58,10 @@ export function FeishuForm({ config, onChange, isEdit }: FeishuFormProps) {
<AdvancedSection> <AdvancedSection>
<Field label={t("channels.field.verificationToken")}> <Field label={t("channels.field.verificationToken")}>
<KeyInput <KeyInput
value={config._verification_token ?? ""} value={asString(config._verification_token)}
onChange={(v) => onChange("_verification_token", v)} onChange={(v) => onChange("_verification_token", v)}
placeholder={ placeholder={
isEdit && config.verification_token isEdit && asString(config.verification_token)
? t("channels.field.secretPlaceholderSet") ? t("channels.field.secretPlaceholderSet")
: t("channels.field.secretPlaceholder") : t("channels.field.secretPlaceholder")
} }
@ -59,10 +69,10 @@ export function FeishuForm({ config, onChange, isEdit }: FeishuFormProps) {
</Field> </Field>
<Field label={t("channels.field.encryptKey")}> <Field label={t("channels.field.encryptKey")}>
<KeyInput <KeyInput
value={config._encrypt_key ?? ""} value={asString(config._encrypt_key)}
onChange={(v) => onChange("_encrypt_key", v)} onChange={(v) => onChange("_encrypt_key", v)}
placeholder={ placeholder={
isEdit && config.encrypt_key isEdit && asString(config.encrypt_key)
? t("channels.field.secretPlaceholderSet") ? t("channels.field.secretPlaceholderSet")
: t("channels.field.secretPlaceholder") : t("channels.field.secretPlaceholder")
} }
@ -73,7 +83,7 @@ export function FeishuForm({ config, onChange, isEdit }: FeishuFormProps) {
hint={t("channels.field.allowFromHint")} hint={t("channels.field.allowFromHint")}
> >
<Input <Input
value={(config.allow_from ?? []).join(", ")} value={asStringArray(config.allow_from).join(", ")}
onChange={(e) => onChange={(e) =>
onChange( onChange(
"allow_from", "allow_from",

View file

@ -1,12 +1,14 @@
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Input } from "@/components/ui/input" import type { ChannelConfig } from "@/api/channels"
import { Field, KeyInput } from "@/components/models/shared-form" import { Field, KeyInput } from "@/components/models/shared-form"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
interface GenericFormProps { interface GenericFormProps {
channelName: string channelName: string
config: Record<string, any> config: ChannelConfig
onChange: (key: string, value: any) => void onChange: (key: string, value: unknown) => void
isEdit: boolean isEdit: boolean
} }
@ -35,6 +37,7 @@ const OBJECT_FIELDS = new Set([
"typing", "typing",
"placeholder", "placeholder",
"allow_from", "allow_from",
"allow_origins",
]) ])
function formatLabel(key: string): string { function formatLabel(key: string): string {
@ -44,11 +47,16 @@ function formatLabel(key: string): string {
.join(" ") .join(" ")
} }
export function GenericForm({ function asString(value: unknown): string {
config, return typeof value === "string" ? value : ""
onChange, }
isEdit,
}: GenericFormProps) { function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === "string")
}
export function GenericForm({ config, onChange, isEdit }: GenericFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
const fields = Object.keys(config).filter( const fields = Object.keys(config).filter(
@ -71,10 +79,10 @@ export function GenericForm({
} }
> >
<KeyInput <KeyInput
value={config[editKey] ?? ""} value={asString(config[editKey])}
onChange={(v) => onChange(editKey, v)} onChange={(v) => onChange(editKey, v)}
placeholder={ placeholder={
isEdit && config[key] isEdit && Boolean(config[key])
? t("channels.field.secretPlaceholderSet") ? t("channels.field.secretPlaceholderSet")
: "" : ""
} }
@ -85,7 +93,17 @@ export function GenericForm({
const value = config[key] const value = config[key]
if (typeof value === "boolean") { if (typeof value === "boolean") {
return null // Booleans are less common in generic; skip for now return (
<Field key={key} label={formatLabel(key)}>
<div className="border-input flex h-9 items-center justify-end rounded-md border px-2.5">
<Switch
checked={value}
onCheckedChange={(checked) => onChange(key, checked)}
aria-label={formatLabel(key)}
/>
</div>
</Field>
)
} }
return ( return (
@ -113,7 +131,7 @@ export function GenericForm({
hint={t("channels.field.allowFromHint")} hint={t("channels.field.allowFromHint")}
> >
<Input <Input
value={(config.allow_from ?? []).join(", ")} value={asStringArray(config.allow_from).join(", ")}
onChange={(e) => onChange={(e) =>
onChange( onChange(
"allow_from", "allow_from",
@ -127,6 +145,33 @@ export function GenericForm({
/> />
</Field> </Field>
)} )}
{config.allow_origins !== undefined && (
<Field
label={t("channels.field.allowOrigins", "Allow Origins")}
hint={t(
"channels.field.allowOriginsHint",
"Comma-separated list of allowed origins. Leave empty to allow all.",
)}
>
<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",
"e.g. https://example.com, http://localhost:5173",
)}
/>
</Field>
)}
</div> </div>
) )
} }

View file

@ -1,18 +1,28 @@
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Input } from "@/components/ui/input" import type { ChannelConfig } from "@/api/channels"
import { import {
AdvancedSection, AdvancedSection,
Field, Field,
KeyInput, KeyInput,
} from "@/components/models/shared-form" } from "@/components/models/shared-form"
import { Input } from "@/components/ui/input"
interface SlackFormProps { interface SlackFormProps {
config: Record<string, any> config: ChannelConfig
onChange: (key: string, value: any) => void onChange: (key: string, value: unknown) => void
isEdit: boolean isEdit: boolean
} }
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === "string")
}
export function SlackForm({ config, onChange, isEdit }: SlackFormProps) { export function SlackForm({ config, onChange, isEdit }: SlackFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
@ -21,16 +31,16 @@ export function SlackForm({ config, onChange, isEdit }: SlackFormProps) {
<Field <Field
label={t("channels.field.botToken")} label={t("channels.field.botToken")}
hint={ hint={
isEdit && config.bot_token isEdit && asString(config.bot_token)
? t("channels.field.secretHintSet") ? t("channels.field.secretHintSet")
: undefined : undefined
} }
> >
<KeyInput <KeyInput
value={config._bot_token ?? ""} value={asString(config._bot_token)}
onChange={(v) => onChange("_bot_token", v)} onChange={(v) => onChange("_bot_token", v)}
placeholder={ placeholder={
isEdit && config.bot_token isEdit && asString(config.bot_token)
? t("channels.field.secretPlaceholderSet") ? t("channels.field.secretPlaceholderSet")
: "xoxb-xxxx" : "xoxb-xxxx"
} }
@ -40,16 +50,16 @@ export function SlackForm({ config, onChange, isEdit }: SlackFormProps) {
<Field <Field
label={t("channels.field.appToken")} label={t("channels.field.appToken")}
hint={ hint={
isEdit && config.app_token isEdit && asString(config.app_token)
? t("channels.field.secretHintSet") ? t("channels.field.secretHintSet")
: undefined : undefined
} }
> >
<KeyInput <KeyInput
value={config._app_token ?? ""} value={asString(config._app_token)}
onChange={(v) => onChange("_app_token", v)} onChange={(v) => onChange("_app_token", v)}
placeholder={ placeholder={
isEdit && config.app_token isEdit && asString(config.app_token)
? t("channels.field.secretPlaceholderSet") ? t("channels.field.secretPlaceholderSet")
: "xapp-xxxx" : "xapp-xxxx"
} }
@ -62,7 +72,7 @@ export function SlackForm({ config, onChange, isEdit }: SlackFormProps) {
hint={t("channels.field.allowFromHint")} hint={t("channels.field.allowFromHint")}
> >
<Input <Input
value={(config.allow_from ?? []).join(", ")} value={asStringArray(config.allow_from).join(", ")}
onChange={(e) => onChange={(e) =>
onChange( onChange(
"allow_from", "allow_from",

View file

@ -1,18 +1,28 @@
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Input } from "@/components/ui/input" import type { ChannelConfig } from "@/api/channels"
import { import {
AdvancedSection, AdvancedSection,
Field, Field,
KeyInput, KeyInput,
} from "@/components/models/shared-form" } from "@/components/models/shared-form"
import { Input } from "@/components/ui/input"
interface TelegramFormProps { interface TelegramFormProps {
config: Record<string, any> config: ChannelConfig
onChange: (key: string, value: any) => void onChange: (key: string, value: unknown) => void
isEdit: boolean isEdit: boolean
} }
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === "string")
}
export function TelegramForm({ config, onChange, isEdit }: TelegramFormProps) { export function TelegramForm({ config, onChange, isEdit }: TelegramFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
@ -21,16 +31,16 @@ export function TelegramForm({ config, onChange, isEdit }: TelegramFormProps) {
<Field <Field
label={t("channels.field.token")} label={t("channels.field.token")}
hint={ hint={
isEdit && config.token isEdit && asString(config.token)
? t("channels.field.secretHintSet") ? t("channels.field.secretHintSet")
: undefined : undefined
} }
> >
<KeyInput <KeyInput
value={config._token ?? ""} value={asString(config._token)}
onChange={(v) => onChange("_token", v)} onChange={(v) => onChange("_token", v)}
placeholder={ placeholder={
isEdit && config.token isEdit && asString(config.token)
? t("channels.field.secretPlaceholderSet") ? t("channels.field.secretPlaceholderSet")
: t("channels.field.tokenPlaceholder") : t("channels.field.tokenPlaceholder")
} }
@ -40,7 +50,7 @@ export function TelegramForm({ config, onChange, isEdit }: TelegramFormProps) {
<AdvancedSection> <AdvancedSection>
<Field label={t("channels.field.baseUrl")}> <Field label={t("channels.field.baseUrl")}>
<Input <Input
value={config.base_url ?? ""} value={asString(config.base_url)}
onChange={(e) => onChange("base_url", e.target.value)} onChange={(e) => onChange("base_url", e.target.value)}
placeholder="https://api.telegram.org" placeholder="https://api.telegram.org"
/> />
@ -50,7 +60,7 @@ export function TelegramForm({ config, onChange, isEdit }: TelegramFormProps) {
hint={t("channels.field.proxyHint")} hint={t("channels.field.proxyHint")}
> >
<Input <Input
value={config.proxy ?? ""} value={asString(config.proxy)}
onChange={(e) => onChange("proxy", e.target.value)} onChange={(e) => onChange("proxy", e.target.value)}
placeholder="http://127.0.0.1:7890" placeholder="http://127.0.0.1:7890"
/> />
@ -60,7 +70,7 @@ export function TelegramForm({ config, onChange, isEdit }: TelegramFormProps) {
hint={t("channels.field.allowFromHint")} hint={t("channels.field.allowFromHint")}
> >
<Input <Input
value={(config.allow_from ?? []).join(", ")} value={asStringArray(config.allow_from).join(", ")}
onChange={(e) => onChange={(e) =>
onChange( onChange(
"allow_from", "allow_from",

View file

@ -30,9 +30,7 @@ export function ChannelsPage() {
setChannels(sorted) setChannels(sorted)
setFetchError("") setFetchError("")
} catch (e) { } catch (e) {
setFetchError( setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
e instanceof Error ? e.message : t("channels.loadError"),
)
} finally { } finally {
setLoading(false) setLoading(false)
} }

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import type { ChannelInfo } from "@/api/channels" import type { ChannelConfig, ChannelInfo } from "@/api/channels"
import { updateChannel } from "@/api/channels" import { updateChannel } from "@/api/channels"
import { DiscordForm } from "@/components/channels/channel-forms/discord-form" import { DiscordForm } from "@/components/channels/channel-forms/discord-form"
import { FeishuForm } from "@/components/channels/channel-forms/feishu-form" import { FeishuForm } from "@/components/channels/channel-forms/feishu-form"
@ -42,8 +42,8 @@ const SECRET_FIELD_MAP: Record<string, string> = {
verification_token: "_verification_token", verification_token: "_verification_token",
} }
function buildEditConfig(config: Record<string, any>): Record<string, any> { function buildEditConfig(config: ChannelConfig): ChannelConfig {
const edit: Record<string, any> = { ...config } const edit: ChannelConfig = { ...config }
// Initialize edit buffer keys for secrets as empty (user fills new values) // Initialize edit buffer keys for secrets as empty (user fills new values)
for (const secretKey of Object.keys(SECRET_FIELD_MAP)) { for (const secretKey of Object.keys(SECRET_FIELD_MAP)) {
if (secretKey in config) { if (secretKey in config) {
@ -55,9 +55,9 @@ function buildEditConfig(config: Record<string, any>): Record<string, any> {
function buildSavePayload( function buildSavePayload(
channel: ChannelInfo, channel: ChannelInfo,
editConfig: Record<string, any>, editConfig: ChannelConfig,
): Record<string, any> { ): ChannelConfig {
const payload: Record<string, any> = { enabled: channel.enabled } const payload: ChannelConfig = { enabled: channel.enabled }
for (const [key, value] of Object.entries(editConfig)) { for (const [key, value] of Object.entries(editConfig)) {
// Skip the edit-buffer underscore keys — we use them to populate real keys // Skip the edit-buffer underscore keys — we use them to populate real keys
@ -81,7 +81,7 @@ export function EditChannelSheet({
onSaved, onSaved,
}: EditChannelSheetProps) { }: EditChannelSheetProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [editConfig, setEditConfig] = useState<Record<string, any>>({}) const [editConfig, setEditConfig] = useState<ChannelConfig>({})
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [serverError, setServerError] = useState("") const [serverError, setServerError] = useState("")
@ -92,7 +92,7 @@ export function EditChannelSheet({
} }
}, [channel]) }, [channel])
const handleChange = useCallback((key: string, value: any) => { const handleChange = useCallback((key: string, value: unknown) => {
setEditConfig((prev) => ({ ...prev, [key]: value })) setEditConfig((prev) => ({ ...prev, [key]: value }))
}, []) }, [])
@ -171,9 +171,7 @@ export function EditChannelSheet({
name: channel?.display_name ?? "", name: channel?.display_name ?? "",
})} })}
</SheetTitle> </SheetTitle>
<SheetDescription> <SheetDescription>{t("channels.edit.description")}</SheetDescription>
{t("channels.edit.description")}
</SheetDescription>
</SheetHeader> </SheetHeader>
<div className="flex-1 overflow-y-auto px-4 py-4">{renderForm()}</div> <div className="flex-1 overflow-y-auto px-4 py-4">{renderForm()}</div>

View file

@ -247,6 +247,9 @@
"allowFrom": "Allow From", "allowFrom": "Allow From",
"allowFromHint": "Comma-separated list of allowed user/group IDs. Leave empty to allow all.", "allowFromHint": "Comma-separated list of allowed user/group IDs. Leave empty to allow all.",
"allowFromPlaceholder": "e.g. 123456, 789012", "allowFromPlaceholder": "e.g. 123456, 789012",
"allowOrigins": "Allow Origins",
"allowOriginsHint": "Comma-separated list of allowed origins. Leave empty to allow all.",
"allowOriginsPlaceholder": "e.g. https://example.com, http://localhost:5173",
"secretPlaceholder": "Enter secret", "secretPlaceholder": "Enter secret",
"secretPlaceholderSet": "Leave blank to keep existing", "secretPlaceholderSet": "Leave blank to keep existing",
"secretHintSet": "A value is already set. Leave blank to keep it unchanged." "secretHintSet": "A value is already set. Leave blank to keep it unchanged."

View file

@ -247,6 +247,9 @@
"allowFrom": "允许来源", "allowFrom": "允许来源",
"allowFromHint": "用逗号分隔的用户/群组 ID 列表,留空表示允许所有。", "allowFromHint": "用逗号分隔的用户/群组 ID 列表,留空表示允许所有。",
"allowFromPlaceholder": "例如 123456, 789012", "allowFromPlaceholder": "例如 123456, 789012",
"allowOrigins": "允许来源域名",
"allowOriginsHint": "用逗号分隔允许的 Origin留空表示允许所有。",
"allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173",
"secretPlaceholder": "输入密钥", "secretPlaceholder": "输入密钥",
"secretPlaceholderSet": "留空保持原有值不变", "secretPlaceholderSet": "留空保持原有值不变",
"secretHintSet": "已设置密钥,留空表示不修改。" "secretHintSet": "已设置密钥,留空表示不修改。"

View file

@ -1,7 +1,4 @@
import { import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query"
import { RouterProvider, createRouter } from "@tanstack/react-router" import { RouterProvider, createRouter } from "@tanstack/react-router"
import { StrictMode } from "react" import { StrictMode } from "react"
import ReactDOM from "react-dom/client" import ReactDOM from "react-dom/client"

View file

@ -1,18 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { toast } from "sonner" import { toast } from "sonner"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Textarea } from "@/components/ui/textarea"
import { ScrollArea } from "@/components/ui/scroll-area"
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -24,8 +16,16 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { Button } from "@/components/ui/button"
import { useState } from "react" import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Textarea } from "@/components/ui/textarea"
export const Route = createFileRoute("/config")({ export const Route = createFileRoute("/config")({
component: ConfigPage, component: ConfigPage,
@ -72,7 +72,9 @@ function RawJsonPanel() {
} }
}, },
onSuccess: () => { onSuccess: () => {
toast.success(t("pages.config.save_success", "Configuration saved successfully.")) toast.success(
t("pages.config.save_success", "Configuration saved successfully."),
)
// Update last saved config and reset dirty state // Update last saved config and reset dirty state
try { try {
const savedConfig = JSON.parse(editorValue) const savedConfig = JSON.parse(editorValue)
@ -94,7 +96,10 @@ function RawJsonPanel() {
const [isDirty, setIsDirty] = useState(false) const [isDirty, setIsDirty] = useState(false)
// Store the last saved config to detect changes // Store the last saved config to detect changes
const [lastSavedConfig, setLastSavedConfig] = useState<Record<string, unknown> | null>(null) const [lastSavedConfig, setLastSavedConfig] = useState<Record<
string,
unknown
> | null>(null)
// Initialize editor value when config is first loaded // Initialize editor value when config is first loaded
const getInitialEditorValue = () => { const getInitialEditorValue = () => {
@ -112,7 +117,12 @@ function RawJsonPanel() {
JSON.parse(editorValue) JSON.parse(editorValue)
mutation.mutate(editorValue) mutation.mutate(editorValue)
} catch (error) { } catch (error) {
toast.error(t("pages.config.invalid_json", error instanceof Error ? error.message : "Invalid JSON format.")) toast.error(
t(
"pages.config.invalid_json",
error instanceof Error ? error.message : "Invalid JSON format.",
),
)
} }
} }
@ -120,9 +130,16 @@ function RawJsonPanel() {
try { try {
const formatted = JSON.stringify(JSON.parse(editorValue), null, 2) const formatted = JSON.stringify(JSON.parse(editorValue), null, 2)
setEditorValue(formatted) setEditorValue(formatted)
toast.success(t("pages.config.format_success", "JSON formatted successfully.")) toast.success(
t("pages.config.format_success", "JSON formatted successfully."),
)
} catch (error) { } catch (error) {
toast.error(t("pages.config.format_error", error instanceof Error ? error.message : "Invalid JSON format.")) toast.error(
t(
"pages.config.format_error",
error instanceof Error ? error.message : "Invalid JSON format.",
),
)
} }
} }
@ -137,7 +154,12 @@ function RawJsonPanel() {
setEditorValue(JSON.stringify(config, null, 2)) setEditorValue(JSON.stringify(config, null, 2))
} }
setIsDirty(false) setIsDirty(false)
toast.info(t("pages.config.reset_success", "Changes have been reset to the last saved state.")) toast.info(
t(
"pages.config.reset_success",
"Changes have been reset to the last saved state.",
),
)
setShowResetDialog(false) setShowResetDialog(false)
} }
@ -161,12 +183,12 @@ function RawJsonPanel() {
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{isDirty && ( {isDirty && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-2 text-sm text-yellow-700"> <div className="rounded-lg border border-yellow-200 bg-yellow-50 p-2 text-sm text-yellow-700">
{t("pages.config.unsaved_changes", "You have unsaved changes.")} {t("pages.config.unsaved_changes", "You have unsaved changes.")}
</div> </div>
)} )}
<div className="bg-muted/30 relative rounded-lg border"> <div className="bg-muted/30 relative rounded-lg border">
<ScrollArea className="h-[calc(100vh-20rem)] min-h-[200px]"> <ScrollArea className="h-[calc(100vh-20rem)] min-h-[200px]">
<Textarea <Textarea
value={displayValue} value={displayValue}
@ -174,7 +196,7 @@ function RawJsonPanel() {
setEditorValue(e.target.value) setEditorValue(e.target.value)
setIsDirty(true) setIsDirty(true)
}} }}
className="font-mono text-sm min-h-[200px] resize-none border-0 bg-transparent px-4 py-3 shadow-none focus-visible:ring-0" className="min-h-[200px] resize-none border-0 bg-transparent px-4 py-3 font-mono text-sm shadow-none focus-visible:ring-0"
placeholder={t( placeholder={t(
"pages.config.json_placeholder", "pages.config.json_placeholder",
"Enter valid JSON configuration...", "Enter valid JSON configuration...",
@ -183,10 +205,17 @@ function RawJsonPanel() {
</ScrollArea> </ScrollArea>
</div> </div>
<div className="flex justify-end space-x-2"> <div className="flex justify-end space-x-2">
<Button variant="outline" onClick={handleFormat} disabled={mutation.isPending}> <Button
variant="outline"
onClick={handleFormat}
disabled={mutation.isPending}
>
{t("pages.config.format", "Format")} {t("pages.config.format", "Format")}
</Button> </Button>
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}> <AlertDialog
open={showResetDialog}
onOpenChange={setShowResetDialog}
>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button
variant="outline" variant="outline"
@ -198,13 +227,20 @@ function RawJsonPanel() {
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{t("pages.config.reset_confirm_title", "Reset Changes")}</AlertDialogTitle> <AlertDialogTitle>
{t("pages.config.reset_confirm_title", "Reset Changes")}
</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{t("pages.config.reset_confirm_desc", "Are you sure you want to reset your unsaved changes back to the last saved state?")} {t(
"pages.config.reset_confirm_desc",
"Are you sure you want to reset your unsaved changes back to the last saved state?",
)}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel", "Cancel")}</AlertDialogCancel> <AlertDialogCancel>
{t("common.cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={confirmReset}> <AlertDialogAction onClick={confirmReset}>
{t("common.confirm", "Confirm")} {t("common.confirm", "Confirm")}
</AlertDialogAction> </AlertDialogAction>