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:
Duc Nguyen authored and GitHub committed 2026-04-01 11:12:35 +07:00
1 parent 9c2b4cbf0f
commit 983f6184d9
7 files changed
+124 -65

No files matched your search

+8
View File
@@ -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 {
+6
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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>