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:
parent
b7469d4ce9
commit
ea5401cf0a
17 changed files with 302 additions and 126 deletions
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"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)
|
||||
if errs := validateConfig(cfg); len(errs) > 0 {
|
||||
writeValidationErrors(w, errs)
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||
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()
|
||||
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err = json.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Enabled == nil {
|
||||
http.Error(w, "Missing required field: enabled", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
|
|
@ -146,7 +155,11 @@ func (h *Handler) handleToggleChannel(w http.ResponseWriter, r *http.Request) {
|
|||
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 {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
|
|
@ -166,6 +179,15 @@ func isValidChannel(name string) bool {
|
|||
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.
|
||||
func extractChannelInfo(name string, ch *config.ChannelsConfig) (bool, bool, map[string]any) {
|
||||
var enabled, configured bool
|
||||
|
|
@ -407,6 +429,50 @@ func applyChannelUpdate(name string, ch *config.ChannelsConfig, incoming map[str
|
|||
}
|
||||
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 {
|
||||
if v, ok := incoming["group_trigger"]; 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.Token = preserveSecret(getString("token"), c.Token)
|
||||
c.AllowTokenQuery = getBool("allow_token_query")
|
||||
if v, ok := incoming["allow_origins"]; ok {
|
||||
if arr, ok := v.([]any); ok {
|
||||
origins := make([]string, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
origins = append(origins, s)
|
||||
}
|
||||
}
|
||||
c.AllowOrigins = origins
|
||||
}
|
||||
if origins, ok := getStringArray("allow_origins"); ok {
|
||||
c.AllowOrigins = origins
|
||||
}
|
||||
c.PingInterval = getInt("ping_interval")
|
||||
c.ReadTimeout = getInt("read_timeout")
|
||||
|
|
|
|||
0
web/backend/dist/.gitkeep
vendored
0
web/backend/dist/.gitkeep
vendored
|
|
@ -1,11 +1,13 @@
|
|||
// API client for channel management.
|
||||
|
||||
export type ChannelConfig = Record<string, unknown>
|
||||
|
||||
export interface ChannelInfo {
|
||||
name: string
|
||||
display_name: string
|
||||
enabled: boolean
|
||||
configured: boolean
|
||||
config: Record<string, any>
|
||||
config: ChannelConfig
|
||||
}
|
||||
|
||||
interface ChannelsListResponse {
|
||||
|
|
@ -32,7 +34,7 @@ export async function getChannels(): Promise<ChannelsListResponse> {
|
|||
|
||||
export async function updateChannel(
|
||||
name: string,
|
||||
config: Record<string, any>,
|
||||
config: ChannelConfig,
|
||||
): Promise<ChannelActionResponse> {
|
||||
return request<ChannelActionResponse>(`/api/channels/${name}`, {
|
||||
method: "PUT",
|
||||
|
|
|
|||
|
|
@ -24,4 +24,4 @@ export function AppLayout({ children }: { children: ReactNode }) {
|
|||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,9 +46,7 @@ const navGroups = [
|
|||
{
|
||||
label: "navigation.channels_group",
|
||||
defaultOpen: true,
|
||||
items: [
|
||||
{ title: "navigation.channels", url: "/channels", icon: IconPlug },
|
||||
],
|
||||
items: [{ title: "navigation.channels", url: "/channels", icon: IconPlug }],
|
||||
},
|
||||
{
|
||||
label: "navigation.services",
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export function ChannelCard({
|
|||
{channel.display_name}
|
||||
</span>
|
||||
{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")}
|
||||
</span>
|
||||
) : (
|
||||
|
|
@ -54,9 +54,7 @@ export function ChannelCard({
|
|||
onClick={() => onEdit(channel)}
|
||||
>
|
||||
<IconSettings className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("channels.action.configure")}
|
||||
</span>
|
||||
<span className="sr-only">{t("channels.action.configure")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,28 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Input } from "@/components/ui/input"
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import {
|
||||
AdvancedSection,
|
||||
Field,
|
||||
KeyInput,
|
||||
} from "@/components/models/shared-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface DiscordFormProps {
|
||||
config: Record<string, any>
|
||||
onChange: (key: string, value: any) => void
|
||||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
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) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
|
@ -21,16 +31,16 @@ export function DiscordForm({ config, onChange, isEdit }: DiscordFormProps) {
|
|||
<Field
|
||||
label={t("channels.field.token")}
|
||||
hint={
|
||||
isEdit && config.token
|
||||
isEdit && asString(config.token)
|
||||
? t("channels.field.secretHintSet")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<KeyInput
|
||||
value={config._token ?? ""}
|
||||
value={asString(config._token)}
|
||||
onChange={(v) => onChange("_token", v)}
|
||||
placeholder={
|
||||
isEdit && config.token
|
||||
isEdit && asString(config.token)
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: t("channels.field.tokenPlaceholder")
|
||||
}
|
||||
|
|
@ -43,7 +53,7 @@ export function DiscordForm({ config, onChange, isEdit }: DiscordFormProps) {
|
|||
hint={t("channels.field.proxyHint")}
|
||||
>
|
||||
<Input
|
||||
value={config.proxy ?? ""}
|
||||
value={asString(config.proxy)}
|
||||
onChange={(e) => onChange("proxy", e.target.value)}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
/>
|
||||
|
|
@ -53,7 +63,7 @@ export function DiscordForm({ config, onChange, isEdit }: DiscordFormProps) {
|
|||
hint={t("channels.field.allowFromHint")}
|
||||
>
|
||||
<Input
|
||||
value={(config.allow_from ?? []).join(", ")}
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
|
|
|
|||
|
|
@ -1,18 +1,28 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Input } from "@/components/ui/input"
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import {
|
||||
AdvancedSection,
|
||||
Field,
|
||||
KeyInput,
|
||||
} from "@/components/models/shared-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface FeishuFormProps {
|
||||
config: Record<string, any>
|
||||
onChange: (key: string, value: any) => void
|
||||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
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) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
|
@ -20,7 +30,7 @@ export function FeishuForm({ config, onChange, isEdit }: FeishuFormProps) {
|
|||
<div className="space-y-5">
|
||||
<Field label={t("channels.field.appId")}>
|
||||
<Input
|
||||
value={config.app_id ?? ""}
|
||||
value={asString(config.app_id)}
|
||||
onChange={(e) => onChange("app_id", e.target.value)}
|
||||
placeholder="cli_xxxx"
|
||||
/>
|
||||
|
|
@ -29,16 +39,16 @@ export function FeishuForm({ config, onChange, isEdit }: FeishuFormProps) {
|
|||
<Field
|
||||
label={t("channels.field.appSecret")}
|
||||
hint={
|
||||
isEdit && config.app_secret
|
||||
isEdit && asString(config.app_secret)
|
||||
? t("channels.field.secretHintSet")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<KeyInput
|
||||
value={config._app_secret ?? ""}
|
||||
value={asString(config._app_secret)}
|
||||
onChange={(v) => onChange("_app_secret", v)}
|
||||
placeholder={
|
||||
isEdit && config.app_secret
|
||||
isEdit && asString(config.app_secret)
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: t("channels.field.secretPlaceholder")
|
||||
}
|
||||
|
|
@ -48,10 +58,10 @@ export function FeishuForm({ config, onChange, isEdit }: FeishuFormProps) {
|
|||
<AdvancedSection>
|
||||
<Field label={t("channels.field.verificationToken")}>
|
||||
<KeyInput
|
||||
value={config._verification_token ?? ""}
|
||||
value={asString(config._verification_token)}
|
||||
onChange={(v) => onChange("_verification_token", v)}
|
||||
placeholder={
|
||||
isEdit && config.verification_token
|
||||
isEdit && asString(config.verification_token)
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: t("channels.field.secretPlaceholder")
|
||||
}
|
||||
|
|
@ -59,10 +69,10 @@ export function FeishuForm({ config, onChange, isEdit }: FeishuFormProps) {
|
|||
</Field>
|
||||
<Field label={t("channels.field.encryptKey")}>
|
||||
<KeyInput
|
||||
value={config._encrypt_key ?? ""}
|
||||
value={asString(config._encrypt_key)}
|
||||
onChange={(v) => onChange("_encrypt_key", v)}
|
||||
placeholder={
|
||||
isEdit && config.encrypt_key
|
||||
isEdit && asString(config.encrypt_key)
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: t("channels.field.secretPlaceholder")
|
||||
}
|
||||
|
|
@ -73,7 +83,7 @@ export function FeishuForm({ config, onChange, isEdit }: FeishuFormProps) {
|
|||
hint={t("channels.field.allowFromHint")}
|
||||
>
|
||||
<Input
|
||||
value={(config.allow_from ?? []).join(", ")}
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
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 { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
|
||||
interface GenericFormProps {
|
||||
channelName: string
|
||||
config: Record<string, any>
|
||||
onChange: (key: string, value: any) => void
|
||||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
isEdit: boolean
|
||||
}
|
||||
|
||||
|
|
@ -35,6 +37,7 @@ const OBJECT_FIELDS = new Set([
|
|||
"typing",
|
||||
"placeholder",
|
||||
"allow_from",
|
||||
"allow_origins",
|
||||
])
|
||||
|
||||
function formatLabel(key: string): string {
|
||||
|
|
@ -44,11 +47,16 @@ function formatLabel(key: string): string {
|
|||
.join(" ")
|
||||
}
|
||||
|
||||
export function GenericForm({
|
||||
config,
|
||||
onChange,
|
||||
isEdit,
|
||||
}: GenericFormProps) {
|
||||
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 GenericForm({ config, onChange, isEdit }: GenericFormProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const fields = Object.keys(config).filter(
|
||||
|
|
@ -71,10 +79,10 @@ export function GenericForm({
|
|||
}
|
||||
>
|
||||
<KeyInput
|
||||
value={config[editKey] ?? ""}
|
||||
value={asString(config[editKey])}
|
||||
onChange={(v) => onChange(editKey, v)}
|
||||
placeholder={
|
||||
isEdit && config[key]
|
||||
isEdit && Boolean(config[key])
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: ""
|
||||
}
|
||||
|
|
@ -85,7 +93,17 @@ export function GenericForm({
|
|||
|
||||
const value = config[key]
|
||||
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 (
|
||||
|
|
@ -113,7 +131,7 @@ export function GenericForm({
|
|||
hint={t("channels.field.allowFromHint")}
|
||||
>
|
||||
<Input
|
||||
value={(config.allow_from ?? []).join(", ")}
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
|
|
@ -127,6 +145,33 @@ export function GenericForm({
|
|||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,28 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Input } from "@/components/ui/input"
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import {
|
||||
AdvancedSection,
|
||||
Field,
|
||||
KeyInput,
|
||||
} from "@/components/models/shared-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface SlackFormProps {
|
||||
config: Record<string, any>
|
||||
onChange: (key: string, value: any) => void
|
||||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
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) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
|
@ -21,16 +31,16 @@ export function SlackForm({ config, onChange, isEdit }: SlackFormProps) {
|
|||
<Field
|
||||
label={t("channels.field.botToken")}
|
||||
hint={
|
||||
isEdit && config.bot_token
|
||||
isEdit && asString(config.bot_token)
|
||||
? t("channels.field.secretHintSet")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<KeyInput
|
||||
value={config._bot_token ?? ""}
|
||||
value={asString(config._bot_token)}
|
||||
onChange={(v) => onChange("_bot_token", v)}
|
||||
placeholder={
|
||||
isEdit && config.bot_token
|
||||
isEdit && asString(config.bot_token)
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: "xoxb-xxxx"
|
||||
}
|
||||
|
|
@ -40,16 +50,16 @@ export function SlackForm({ config, onChange, isEdit }: SlackFormProps) {
|
|||
<Field
|
||||
label={t("channels.field.appToken")}
|
||||
hint={
|
||||
isEdit && config.app_token
|
||||
isEdit && asString(config.app_token)
|
||||
? t("channels.field.secretHintSet")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<KeyInput
|
||||
value={config._app_token ?? ""}
|
||||
value={asString(config._app_token)}
|
||||
onChange={(v) => onChange("_app_token", v)}
|
||||
placeholder={
|
||||
isEdit && config.app_token
|
||||
isEdit && asString(config.app_token)
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: "xapp-xxxx"
|
||||
}
|
||||
|
|
@ -62,7 +72,7 @@ export function SlackForm({ config, onChange, isEdit }: SlackFormProps) {
|
|||
hint={t("channels.field.allowFromHint")}
|
||||
>
|
||||
<Input
|
||||
value={(config.allow_from ?? []).join(", ")}
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
|
|
|
|||
|
|
@ -1,18 +1,28 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Input } from "@/components/ui/input"
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import {
|
||||
AdvancedSection,
|
||||
Field,
|
||||
KeyInput,
|
||||
} from "@/components/models/shared-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface TelegramFormProps {
|
||||
config: Record<string, any>
|
||||
onChange: (key: string, value: any) => void
|
||||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
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) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
|
@ -21,16 +31,16 @@ export function TelegramForm({ config, onChange, isEdit }: TelegramFormProps) {
|
|||
<Field
|
||||
label={t("channels.field.token")}
|
||||
hint={
|
||||
isEdit && config.token
|
||||
isEdit && asString(config.token)
|
||||
? t("channels.field.secretHintSet")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<KeyInput
|
||||
value={config._token ?? ""}
|
||||
value={asString(config._token)}
|
||||
onChange={(v) => onChange("_token", v)}
|
||||
placeholder={
|
||||
isEdit && config.token
|
||||
isEdit && asString(config.token)
|
||||
? t("channels.field.secretPlaceholderSet")
|
||||
: t("channels.field.tokenPlaceholder")
|
||||
}
|
||||
|
|
@ -40,7 +50,7 @@ export function TelegramForm({ config, onChange, isEdit }: TelegramFormProps) {
|
|||
<AdvancedSection>
|
||||
<Field label={t("channels.field.baseUrl")}>
|
||||
<Input
|
||||
value={config.base_url ?? ""}
|
||||
value={asString(config.base_url)}
|
||||
onChange={(e) => onChange("base_url", e.target.value)}
|
||||
placeholder="https://api.telegram.org"
|
||||
/>
|
||||
|
|
@ -50,7 +60,7 @@ export function TelegramForm({ config, onChange, isEdit }: TelegramFormProps) {
|
|||
hint={t("channels.field.proxyHint")}
|
||||
>
|
||||
<Input
|
||||
value={config.proxy ?? ""}
|
||||
value={asString(config.proxy)}
|
||||
onChange={(e) => onChange("proxy", e.target.value)}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
/>
|
||||
|
|
@ -60,7 +70,7 @@ export function TelegramForm({ config, onChange, isEdit }: TelegramFormProps) {
|
|||
hint={t("channels.field.allowFromHint")}
|
||||
>
|
||||
<Input
|
||||
value={(config.allow_from ?? []).join(", ")}
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
|
|
|
|||
|
|
@ -30,9 +30,7 @@ export function ChannelsPage() {
|
|||
setChannels(sorted)
|
||||
setFetchError("")
|
||||
} catch (e) {
|
||||
setFetchError(
|
||||
e instanceof Error ? e.message : t("channels.loadError"),
|
||||
)
|
||||
setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelInfo } from "@/api/channels"
|
||||
import type { ChannelConfig, ChannelInfo } from "@/api/channels"
|
||||
import { updateChannel } from "@/api/channels"
|
||||
import { DiscordForm } from "@/components/channels/channel-forms/discord-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",
|
||||
}
|
||||
|
||||
function buildEditConfig(config: Record<string, any>): Record<string, any> {
|
||||
const edit: Record<string, any> = { ...config }
|
||||
function buildEditConfig(config: ChannelConfig): ChannelConfig {
|
||||
const edit: ChannelConfig = { ...config }
|
||||
// Initialize edit buffer keys for secrets as empty (user fills new values)
|
||||
for (const secretKey of Object.keys(SECRET_FIELD_MAP)) {
|
||||
if (secretKey in config) {
|
||||
|
|
@ -55,9 +55,9 @@ function buildEditConfig(config: Record<string, any>): Record<string, any> {
|
|||
|
||||
function buildSavePayload(
|
||||
channel: ChannelInfo,
|
||||
editConfig: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
const payload: Record<string, any> = { enabled: channel.enabled }
|
||||
editConfig: ChannelConfig,
|
||||
): ChannelConfig {
|
||||
const payload: ChannelConfig = { enabled: channel.enabled }
|
||||
|
||||
for (const [key, value] of Object.entries(editConfig)) {
|
||||
// Skip the edit-buffer underscore keys — we use them to populate real keys
|
||||
|
|
@ -81,7 +81,7 @@ export function EditChannelSheet({
|
|||
onSaved,
|
||||
}: EditChannelSheetProps) {
|
||||
const { t } = useTranslation()
|
||||
const [editConfig, setEditConfig] = useState<Record<string, any>>({})
|
||||
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [serverError, setServerError] = useState("")
|
||||
|
||||
|
|
@ -92,7 +92,7 @@ export function EditChannelSheet({
|
|||
}
|
||||
}, [channel])
|
||||
|
||||
const handleChange = useCallback((key: string, value: any) => {
|
||||
const handleChange = useCallback((key: string, value: unknown) => {
|
||||
setEditConfig((prev) => ({ ...prev, [key]: value }))
|
||||
}, [])
|
||||
|
||||
|
|
@ -171,9 +171,7 @@ export function EditChannelSheet({
|
|||
name: channel?.display_name ?? "",
|
||||
})}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t("channels.edit.description")}
|
||||
</SheetDescription>
|
||||
<SheetDescription>{t("channels.edit.description")}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">{renderForm()}</div>
|
||||
|
|
|
|||
|
|
@ -247,6 +247,9 @@
|
|||
"allowFrom": "Allow From",
|
||||
"allowFromHint": "Comma-separated list of allowed user/group IDs. Leave empty to allow all.",
|
||||
"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",
|
||||
"secretPlaceholderSet": "Leave blank to keep existing",
|
||||
"secretHintSet": "A value is already set. Leave blank to keep it unchanged."
|
||||
|
|
|
|||
|
|
@ -247,6 +247,9 @@
|
|||
"allowFrom": "允许来源",
|
||||
"allowFromHint": "用逗号分隔的用户/群组 ID 列表,留空表示允许所有。",
|
||||
"allowFromPlaceholder": "例如 123456, 789012",
|
||||
"allowOrigins": "允许来源域名",
|
||||
"allowOriginsHint": "用逗号分隔允许的 Origin,留空表示允许所有。",
|
||||
"allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173",
|
||||
"secretPlaceholder": "输入密钥",
|
||||
"secretPlaceholderSet": "留空保持原有值不变",
|
||||
"secretHintSet": "已设置密钥,留空表示不修改。"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from "@tanstack/react-query"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router"
|
||||
import { StrictMode } from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
|
|
@ -35,4 +32,4 @@ if (!rootElement.innerHTML) {
|
|||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,10 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -24,8 +16,16 @@ import {
|
|||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
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")({
|
||||
component: ConfigPage,
|
||||
|
|
@ -72,7 +72,9 @@ function RawJsonPanel() {
|
|||
}
|
||||
},
|
||||
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
|
||||
try {
|
||||
const savedConfig = JSON.parse(editorValue)
|
||||
|
|
@ -94,7 +96,10 @@ function RawJsonPanel() {
|
|||
const [isDirty, setIsDirty] = useState(false)
|
||||
|
||||
// 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
|
||||
const getInitialEditorValue = () => {
|
||||
|
|
@ -103,7 +108,7 @@ function RawJsonPanel() {
|
|||
}
|
||||
return editorValue
|
||||
}
|
||||
|
||||
|
||||
const displayValue = getInitialEditorValue()
|
||||
|
||||
const handleSave = () => {
|
||||
|
|
@ -112,7 +117,12 @@ function RawJsonPanel() {
|
|||
JSON.parse(editorValue)
|
||||
mutation.mutate(editorValue)
|
||||
} 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 {
|
||||
const formatted = JSON.stringify(JSON.parse(editorValue), null, 2)
|
||||
setEditorValue(formatted)
|
||||
toast.success(t("pages.config.format_success", "JSON formatted successfully."))
|
||||
toast.success(
|
||||
t("pages.config.format_success", "JSON formatted successfully."),
|
||||
)
|
||||
} 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))
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -161,12 +183,12 @@ function RawJsonPanel() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{isDirty && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-2 text-sm text-yellow-700">
|
||||
{t("pages.config.unsaved_changes", "You have unsaved changes.")}
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-muted/30 relative rounded-lg border">
|
||||
{isDirty && (
|
||||
<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.")}
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-muted/30 relative rounded-lg border">
|
||||
<ScrollArea className="h-[calc(100vh-20rem)] min-h-[200px]">
|
||||
<Textarea
|
||||
value={displayValue}
|
||||
|
|
@ -174,7 +196,7 @@ function RawJsonPanel() {
|
|||
setEditorValue(e.target.value)
|
||||
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(
|
||||
"pages.config.json_placeholder",
|
||||
"Enter valid JSON configuration...",
|
||||
|
|
@ -183,13 +205,20 @@ function RawJsonPanel() {
|
|||
</ScrollArea>
|
||||
</div>
|
||||
<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")}
|
||||
</Button>
|
||||
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
|
||||
<AlertDialog
|
||||
open={showResetDialog}
|
||||
onOpenChange={setShowResetDialog}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!isDirty}
|
||||
onClick={() => setShowResetDialog(true)}
|
||||
>
|
||||
|
|
@ -198,13 +227,20 @@ function RawJsonPanel() {
|
|||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("pages.config.reset_confirm_title", "Reset Changes")}</AlertDialogTitle>
|
||||
<AlertDialogTitle>
|
||||
{t("pages.config.reset_confirm_title", "Reset Changes")}
|
||||
</AlertDialogTitle>
|
||||
<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>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel", "Cancel")}</AlertDialogCancel>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmReset}>
|
||||
{t("common.confirm", "Confirm")}
|
||||
</AlertDialogAction>
|
||||
|
|
@ -222,4 +258,4 @@ function RawJsonPanel() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue