mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 12:18:59 +00:00
fix(ui): dynamic searchable timezone picker with validation (#614)
Replace hardcoded 20-entry IANA_TIMEZONES with getAllIanaTimezones() using Intl.supportedValuesOf (~400 zones). Switch Select dropdowns to searchable Combobox in cron, heartbeat, and system config. Add defense-in-depth timezone validation: - Backend: validate in heartbeat.set handler and SetDefaultTimezone() - Frontend: isValidIanaTimezone() guard before save in all 3 dialogs Closes #614
This commit is contained in:
1 parent
9c2b4cbf0f
commit
983f6184d9
7 files changed
+124
-65
No files matched your search
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
@@ -196,6 +197,13 @@ func (m *HeartbeatMethods) handleSet(ctx context.Context, client *gateway.Client
|
||||
hb.ActiveHoursEnd = params.ActiveHoursEnd
|
||||
}
|
||||
if params.Timezone != nil {
|
||||
if *params.Timezone != "" {
|
||||
if _, err := time.LoadLocation(*params.Timezone); err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
||||
fmt.Sprintf("invalid timezone: %s", *params.Timezone)))
|
||||
return
|
||||
}
|
||||
}
|
||||
hb.Timezone = params.Timezone
|
||||
}
|
||||
if params.Channel != nil {
|
||||
|
||||
@@ -49,6 +49,12 @@ func (s *PGCronStore) SetRetryConfig(cfg cron.RetryConfig) {
|
||||
// SetDefaultTimezone sets the fallback IANA timezone for cron expressions
|
||||
// when a job does not specify its own timezone.
|
||||
func (s *PGCronStore) SetDefaultTimezone(tz string) {
|
||||
if tz != "" {
|
||||
if _, err := time.LoadLocation(tz); err != nil {
|
||||
slog.Warn("security.invalid_default_timezone", "tz", tz, "err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.defaultTZ = tz
|
||||
|
||||
@@ -51,6 +51,12 @@ func (s *SQLiteCronStore) SetRetryConfig(cfg cron.RetryConfig) {
|
||||
}
|
||||
|
||||
func (s *SQLiteCronStore) SetDefaultTimezone(tz string) {
|
||||
if tz != "" {
|
||||
if _, err := time.LoadLocation(tz); err != nil {
|
||||
slog.Warn("security.invalid_default_timezone", "tz", tz, "err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.defaultTZ = tz
|
||||
|
||||
+58
-23
@@ -83,26 +83,61 @@ export const TIMEZONE_OPTIONS = [
|
||||
{ value: "Australia/Sydney", label: "Sydney (AEST)" },
|
||||
] as const;
|
||||
|
||||
/** IANA timezones for server-side scheduling (cron, heartbeat). */
|
||||
export const IANA_TIMEZONES = [
|
||||
{ value: "UTC", label: "UTC" },
|
||||
{ value: "Asia/Ho_Chi_Minh", label: "Asia/Ho_Chi_Minh (UTC+7)" },
|
||||
{ value: "Asia/Bangkok", label: "Asia/Bangkok (UTC+7)" },
|
||||
{ value: "Asia/Shanghai", label: "Asia/Shanghai (UTC+8)" },
|
||||
{ value: "Asia/Tokyo", label: "Asia/Tokyo (UTC+9)" },
|
||||
{ value: "Asia/Seoul", label: "Asia/Seoul (UTC+9)" },
|
||||
{ value: "Asia/Singapore", label: "Asia/Singapore (UTC+8)" },
|
||||
{ value: "Asia/Kolkata", label: "Asia/Kolkata (UTC+5:30)" },
|
||||
{ value: "Asia/Dubai", label: "Asia/Dubai (UTC+4)" },
|
||||
{ value: "Europe/London", label: "Europe/London (UTC+0)" },
|
||||
{ value: "Europe/Paris", label: "Europe/Paris (UTC+1)" },
|
||||
{ value: "Europe/Berlin", label: "Europe/Berlin (UTC+1)" },
|
||||
{ value: "Europe/Moscow", label: "Europe/Moscow (UTC+3)" },
|
||||
{ value: "America/New_York", label: "America/New_York (UTC-5)" },
|
||||
{ value: "America/Chicago", label: "America/Chicago (UTC-6)" },
|
||||
{ value: "America/Denver", label: "America/Denver (UTC-7)" },
|
||||
{ value: "America/Los_Angeles", label: "America/Los_Angeles (UTC-8)" },
|
||||
{ value: "America/Sao_Paulo", label: "America/Sao_Paulo (UTC-3)" },
|
||||
{ value: "Australia/Sydney", label: "Australia/Sydney (UTC+11)" },
|
||||
{ value: "Pacific/Auckland", label: "Pacific/Auckland (UTC+13)" },
|
||||
] as const;
|
||||
/** Compute UTC offset string for a timezone, e.g. "UTC+7", "UTC-5:30" */
|
||||
function formatUtcOffset(tz: string): string {
|
||||
const parts = new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "shortOffset" }).formatToParts(new Date());
|
||||
const raw = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT";
|
||||
return raw.replace("GMT", "UTC").replace(/^UTC$/, "UTC+0");
|
||||
}
|
||||
|
||||
function parseOffsetMinutes(offset: string): number {
|
||||
const m = offset.match(/UTC([+-]?)(\d+)(?::(\d+))?/);
|
||||
if (!m) return 0;
|
||||
const sign = m[1] === "-" ? -1 : 1;
|
||||
return sign * (parseInt(m[2]!, 10) * 60 + parseInt(m[3] ?? "0", 10));
|
||||
}
|
||||
|
||||
type TzOption = { value: string; label: string };
|
||||
let _cachedTimezones: TzOption[] | undefined;
|
||||
|
||||
/** Minimal fallback when Intl.supportedValuesOf is unavailable. */
|
||||
const FALLBACK_TIMEZONES: string[] = [
|
||||
"UTC", "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
|
||||
"America/Halifax", "America/Sao_Paulo", "Europe/London", "Europe/Paris", "Europe/Berlin",
|
||||
"Europe/Moscow", "Asia/Dubai", "Asia/Kolkata", "Asia/Bangkok", "Asia/Ho_Chi_Minh",
|
||||
"Asia/Shanghai", "Asia/Tokyo", "Asia/Seoul", "Asia/Singapore", "Australia/Sydney",
|
||||
"Pacific/Auckland",
|
||||
];
|
||||
|
||||
/** All IANA timezones with dynamic UTC offsets, sorted by offset then name. */
|
||||
export function getAllIanaTimezones(): TzOption[] {
|
||||
if (_cachedTimezones) return _cachedTimezones;
|
||||
let tzNames: string[];
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
tzNames = (Intl as any).supportedValuesOf("timeZone") as string[];
|
||||
} catch {
|
||||
tzNames = FALLBACK_TIMEZONES;
|
||||
}
|
||||
// Chrome may omit "UTC" from the list
|
||||
if (!tzNames.includes("UTC")) tzNames = ["UTC", ...tzNames];
|
||||
const entries = tzNames.map((tz: string) => {
|
||||
const offset = formatUtcOffset(tz);
|
||||
return { value: tz, label: `${tz} (${offset})`, _offset: parseOffsetMinutes(offset) };
|
||||
});
|
||||
entries.sort((a: { _offset: number; value: string }, b: { _offset: number; value: string }) =>
|
||||
a._offset !== b._offset ? a._offset - b._offset : a.value.localeCompare(b.value),
|
||||
);
|
||||
_cachedTimezones = entries.map(({ value, label }: { value: string; label: string }) => ({ value, label }));
|
||||
return _cachedTimezones;
|
||||
}
|
||||
|
||||
let _cachedTzSet: Set<string> | undefined;
|
||||
|
||||
/** Check if a value is a valid IANA timezone from the dynamic list. */
|
||||
export function isValidIanaTimezone(tz: string): boolean {
|
||||
if (!_cachedTzSet) {
|
||||
_cachedTzSet = new Set(getAllIanaTimezones().map((t) => t.value));
|
||||
}
|
||||
return _cachedTzSet.has(tz);
|
||||
}
|
||||
@@ -18,7 +18,9 @@ import { useChannels } from "@/pages/channels/hooks/use-channels";
|
||||
import { useProviders } from "@/pages/providers/hooks/use-providers";
|
||||
import { useUiStore } from "@/stores/use-ui-store";
|
||||
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
|
||||
import { IANA_TIMEZONES } from "@/lib/constants";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { getAllIanaTimezones, isValidIanaTimezone } from "@/lib/constants";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import type { HeartbeatConfig, DeliveryTarget } from "@/pages/agents/hooks/use-agent-heartbeat";
|
||||
|
||||
interface HeartbeatConfigDialogProps {
|
||||
@@ -127,6 +129,10 @@ export function HeartbeatConfigDialog({
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (timezone && !isValidIanaTimezone(timezone)) {
|
||||
toast.error(t("heartbeat.invalidTimezone", "Invalid timezone"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const clampedMin = Math.max(5, intervalMin);
|
||||
await update({
|
||||
@@ -326,17 +332,13 @@ export function HeartbeatConfigDialog({
|
||||
</div>
|
||||
<div className="space-y-1 flex-1 min-w-[160px]">
|
||||
<Label className="text-xs">{t("heartbeat.timezone")}</Label>
|
||||
<Select value={timezone || "__auto__"} onValueChange={(v) => setTimezone(v === "__auto__" ? "" : v)}>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__auto__">{defaultTz}</SelectItem>
|
||||
{IANA_TIMEZONES.map((tz) => (
|
||||
<SelectItem key={tz.value} value={tz.value}>{tz.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Combobox
|
||||
value={timezone || "__auto__"}
|
||||
onChange={(v) => setTimezone(v === "__auto__" ? "" : v)}
|
||||
options={[{ value: "__auto__", label: defaultTz }, ...getAllIanaTimezones()]}
|
||||
placeholder={t("heartbeat.timezone")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("heartbeat.scheduleHint")}</p>
|
||||
|
||||
@@ -3,10 +3,11 @@ import { Save } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { InfoLabel } from "@/components/shared/info-label";
|
||||
import { IANA_TIMEZONES } from "@/lib/constants";
|
||||
import { getAllIanaTimezones, isValidIanaTimezone } from "@/lib/constants";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
|
||||
interface CronData {
|
||||
max_retries?: number;
|
||||
@@ -49,20 +50,15 @@ export function CronSection({ data, onSave, saving }: Props) {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={t("cron.defaultTimezoneTip")}>{t("cron.defaultTimezone")}</InfoLabel>
|
||||
<Select
|
||||
<Combobox
|
||||
value={draft.default_timezone || "__system__"}
|
||||
onValueChange={(v) => update({ default_timezone: v === "__system__" ? "" : v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("cron.defaultTimezonePlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__system__">{t("cron.defaultTimezonePlaceholder")}</SelectItem>
|
||||
{IANA_TIMEZONES.map((tz) => (
|
||||
<SelectItem key={tz.value} value={tz.value}>{tz.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
onChange={(v) => update({ default_timezone: v === "__system__" ? "" : v })}
|
||||
options={[
|
||||
{ value: "__system__", label: t("cron.defaultTimezonePlaceholder") },
|
||||
...getAllIanaTimezones(),
|
||||
]}
|
||||
placeholder={t("cron.defaultTimezonePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -96,7 +92,13 @@ export function CronSection({ data, onSave, saving }: Props) {
|
||||
|
||||
{dirty && (
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button size="sm" onClick={() => onSave(draft)} disabled={saving} className="gap-1.5">
|
||||
<Button size="sm" onClick={() => {
|
||||
if (draft.default_timezone && !isValidIanaTimezone(draft.default_timezone)) {
|
||||
toast.error(t("cron.invalidTimezone", "Invalid timezone"));
|
||||
return;
|
||||
}
|
||||
onSave(draft);
|
||||
}} disabled={saving} className="gap-1.5">
|
||||
<Save className="h-3.5 w-3.5" /> {saving ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -6,9 +6,10 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ConfigGroupHeader } from "@/components/shared/config-group-header";
|
||||
import { IANA_TIMEZONES } from "@/lib/constants";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { getAllIanaTimezones, isValidIanaTimezone } from "@/lib/constants";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import type { CronJob, CronJobPatch } from "../hooks/use-cron";
|
||||
|
||||
interface CronAdvancedDialogProps {
|
||||
@@ -63,6 +64,10 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
if (timezone && timezone !== "UTC" && !isValidIanaTimezone(timezone)) {
|
||||
toast.error(t("detail.invalidTimezone", "Invalid timezone"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await onUpdate(job.id, {
|
||||
@@ -104,18 +109,13 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="adv-timezone">{t("detail.timezone")}</Label>
|
||||
<Select value={timezone} onValueChange={setTimezone}>
|
||||
<SelectTrigger id="adv-timezone" className="text-base md:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{IANA_TIMEZONES.map((tz) => (
|
||||
<SelectItem key={tz.value} value={tz.value}>
|
||||
{tz.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Combobox
|
||||
value={timezone}
|
||||
onChange={setTimezone}
|
||||
options={getAllIanaTimezones()}
|
||||
placeholder={t("detail.timezone")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("detail.timezoneDesc")}</p>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in new issue
Block a user