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"
|
"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")
|
||||||
|
|
|
||||||
0
web/backend/dist/.gitkeep
vendored
0
web/backend/dist/.gitkeep
vendored
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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": "已设置密钥,留空表示不修改。"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue