mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
feat: add granular auto-backup scheduling and timezone support
Add UI controls for configuring auto-backup schedule with hour, day of week, and day of month pickers. The hour picker respects the user's 12h/24h time format preference from settings. Add TZ environment variable support via docker-compose so the container runs in the configured timezone. The timezone is passed to node-cron for accurate scheduling and exposed via the API so the UI displays it. Fix SQLite UTC timestamp handling by appending Z suffix to all timestamps sent to the client, ensuring proper timezone conversion in the browser. Made-with: Cursor
This commit is contained in:
+2
-2
@@ -11,9 +11,9 @@ FROM node:22-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Server-Dependencies installieren (better-sqlite3 braucht Build-Tools)
|
# Timezone support + Server-Dependencies (better-sqlite3 braucht Build-Tools)
|
||||||
COPY server/package*.json ./
|
COPY server/package*.json ./
|
||||||
RUN apk add --no-cache python3 make g++ && \
|
RUN apk add --no-cache tzdata python3 make g++ && \
|
||||||
npm ci --production && \
|
npm ci --production && \
|
||||||
apk del python3 make g++
|
apk del python3 make g++
|
||||||
|
|
||||||
|
|||||||
+3
-5
@@ -62,28 +62,26 @@ function RootRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey } = useAuthStore()
|
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone } = useAuthStore()
|
||||||
const { loadSettings } = useSettingsStore()
|
const { loadSettings } = useSettingsStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (token) {
|
||||||
loadUser()
|
loadUser()
|
||||||
}
|
}
|
||||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string }) => {
|
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string }) => {
|
||||||
if (config?.demo_mode) setDemoMode(true)
|
if (config?.demo_mode) setDemoMode(true)
|
||||||
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
||||||
|
if (config?.timezone) setServerTimezone(config.timezone)
|
||||||
|
|
||||||
// Version-based cache invalidation: clear all caches on version change
|
|
||||||
if (config?.version) {
|
if (config?.version) {
|
||||||
const storedVersion = localStorage.getItem('trek_app_version')
|
const storedVersion = localStorage.getItem('trek_app_version')
|
||||||
if (storedVersion && storedVersion !== config.version) {
|
if (storedVersion && storedVersion !== config.version) {
|
||||||
try {
|
try {
|
||||||
// Clear all Service Worker caches
|
|
||||||
if ('caches' in window) {
|
if ('caches' in window) {
|
||||||
const names = await caches.keys()
|
const names = await caches.keys()
|
||||||
await Promise.all(names.map(n => caches.delete(n)))
|
await Promise.all(names.map(n => caches.delete(n)))
|
||||||
}
|
}
|
||||||
// Unregister all service workers
|
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
const regs = await navigator.serviceWorker.getRegistrations()
|
const regs = await navigator.serviceWorker.getRegistrations()
|
||||||
await Promise.all(regs.map(r => r.unregister()))
|
await Promise.all(regs.map(r => r.unregister()))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { backupApi } from '../../api/client'
|
|||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
|
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { getApiErrorMessage } from '../../types'
|
import { getApiErrorMessage } from '../../types'
|
||||||
|
|
||||||
const INTERVAL_OPTIONS = [
|
const INTERVAL_OPTIONS = [
|
||||||
@@ -21,19 +22,35 @@ const KEEP_OPTIONS = [
|
|||||||
{ value: 0, labelKey: 'backup.keep.forever' },
|
{ value: 0, labelKey: 'backup.keep.forever' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const DAYS_OF_WEEK = [
|
||||||
|
{ value: 0, labelKey: 'backup.dow.sunday' },
|
||||||
|
{ value: 1, labelKey: 'backup.dow.monday' },
|
||||||
|
{ value: 2, labelKey: 'backup.dow.tuesday' },
|
||||||
|
{ value: 3, labelKey: 'backup.dow.wednesday' },
|
||||||
|
{ value: 4, labelKey: 'backup.dow.thursday' },
|
||||||
|
{ value: 5, labelKey: 'backup.dow.friday' },
|
||||||
|
{ value: 6, labelKey: 'backup.dow.saturday' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const HOURS = Array.from({ length: 24 }, (_, i) => i)
|
||||||
|
|
||||||
|
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1)
|
||||||
|
|
||||||
export default function BackupPanel() {
|
export default function BackupPanel() {
|
||||||
const [backups, setBackups] = useState([])
|
const [backups, setBackups] = useState([])
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [isCreating, setIsCreating] = useState(false)
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
const [restoringFile, setRestoringFile] = useState(null)
|
const [restoringFile, setRestoringFile] = useState(null)
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
|
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })
|
||||||
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
|
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
|
||||||
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
|
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
|
||||||
|
const [serverTimezone, setServerTimezone] = useState('')
|
||||||
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
|
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, language, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
|
|
||||||
const loadBackups = async () => {
|
const loadBackups = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
@@ -51,6 +68,7 @@ export default function BackupPanel() {
|
|||||||
try {
|
try {
|
||||||
const data = await backupApi.getAutoSettings()
|
const data = await backupApi.getAutoSettings()
|
||||||
setAutoSettings(data.settings)
|
setAutoSettings(data.settings)
|
||||||
|
if (data.timezone) setServerTimezone(data.timezone)
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,10 +165,12 @@ export default function BackupPanel() {
|
|||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
if (!dateStr) return '-'
|
if (!dateStr) return '-'
|
||||||
try {
|
try {
|
||||||
return new Date(dateStr).toLocaleString(locale, {
|
const opts: Intl.DateTimeFormatOptions = {
|
||||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
hour: '2-digit', minute: '2-digit',
|
hour: '2-digit', minute: '2-digit',
|
||||||
})
|
}
|
||||||
|
if (serverTimezone) opts.timeZone = serverTimezone
|
||||||
|
return new Date(dateStr).toLocaleString(locale, opts)
|
||||||
} catch { return dateStr }
|
} catch { return dateStr }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +351,76 @@ export default function BackupPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hour picker (for daily, weekly, monthly) */}
|
||||||
|
{autoSettings.interval !== 'hourly' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
|
||||||
|
<select
|
||||||
|
value={autoSettings.hour}
|
||||||
|
onChange={e => handleAutoSettingsChange('hour', parseInt(e.target.value, 10))}
|
||||||
|
className="w-full sm:w-auto px-3 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-gray-700 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
>
|
||||||
|
{HOURS.map(h => {
|
||||||
|
let label: string
|
||||||
|
if (is12h) {
|
||||||
|
const period = h >= 12 ? 'PM' : 'AM'
|
||||||
|
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||||
|
label = `${h12}:00 ${period}`
|
||||||
|
} else {
|
||||||
|
label = `${String(h).padStart(2, '0')}:00`
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<option key={h} value={h}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Day of week (for weekly) */}
|
||||||
|
{autoSettings.interval === 'weekly' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{DAYS_OF_WEEK.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
|
autoSettings.day_of_week === opt.value
|
||||||
|
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||||
|
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(opt.labelKey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Day of month (for monthly) */}
|
||||||
|
{autoSettings.interval === 'monthly' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
|
||||||
|
<select
|
||||||
|
value={autoSettings.day_of_month}
|
||||||
|
onChange={e => handleAutoSettingsChange('day_of_month', parseInt(e.target.value, 10))}
|
||||||
|
className="w-full sm:w-auto px-3 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-gray-700 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
>
|
||||||
|
{DAYS_OF_MONTH.map(d => (
|
||||||
|
<option key={d} value={d}>{d}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{t('backup.auto.dayOfMonthHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Keep duration */}
|
{/* Keep duration */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
||||||
|
|||||||
@@ -1007,7 +1007,27 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'backup.auto.enable': 'Auto-Backup aktivieren',
|
'backup.auto.enable': 'Auto-Backup aktivieren',
|
||||||
'backup.auto.enableHint': 'Backups werden automatisch nach dem gewählten Zeitplan erstellt',
|
'backup.auto.enableHint': 'Backups werden automatisch nach dem gewählten Zeitplan erstellt',
|
||||||
'backup.auto.interval': 'Intervall',
|
'backup.auto.interval': 'Intervall',
|
||||||
|
'backup.auto.hour': 'Ausführung um',
|
||||||
|
'backup.auto.hourHint': 'Lokale Serverzeit ({format}-Format)',
|
||||||
|
'backup.auto.dayOfWeek': 'Wochentag',
|
||||||
|
'backup.auto.dayOfMonth': 'Tag des Monats',
|
||||||
|
'backup.auto.dayOfMonthHint': 'Auf 1–28 beschränkt, um mit allen Monaten kompatibel zu sein',
|
||||||
|
'backup.auto.scheduleSummary': 'Zeitplan',
|
||||||
|
'backup.auto.summaryDaily': 'Täglich um {hour}:00',
|
||||||
|
'backup.auto.summaryWeekly': 'Jeden {day} um {hour}:00',
|
||||||
|
'backup.auto.summaryMonthly': 'Am {day}. jedes Monats um {hour}:00',
|
||||||
|
'backup.auto.envLocked': 'Docker',
|
||||||
|
'backup.auto.envLockedHint': 'Auto-Backup wird über Docker-Umgebungsvariablen konfiguriert. Ändern Sie Ihre docker-compose.yml und starten Sie den Container neu.',
|
||||||
|
'backup.auto.copyEnv': 'Docker-Umgebungsvariablen kopieren',
|
||||||
|
'backup.auto.envCopied': 'Docker-Umgebungsvariablen in die Zwischenablage kopiert',
|
||||||
'backup.auto.keepLabel': 'Alte Backups löschen nach',
|
'backup.auto.keepLabel': 'Alte Backups löschen nach',
|
||||||
|
'backup.dow.sunday': 'So',
|
||||||
|
'backup.dow.monday': 'Mo',
|
||||||
|
'backup.dow.tuesday': 'Di',
|
||||||
|
'backup.dow.wednesday': 'Mi',
|
||||||
|
'backup.dow.thursday': 'Do',
|
||||||
|
'backup.dow.friday': 'Fr',
|
||||||
|
'backup.dow.saturday': 'Sa',
|
||||||
'backup.interval.hourly': 'Stündlich',
|
'backup.interval.hourly': 'Stündlich',
|
||||||
'backup.interval.daily': 'Täglich',
|
'backup.interval.daily': 'Täglich',
|
||||||
'backup.interval.weekly': 'Wöchentlich',
|
'backup.interval.weekly': 'Wöchentlich',
|
||||||
|
|||||||
@@ -1007,7 +1007,27 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'backup.auto.enable': 'Enable auto-backup',
|
'backup.auto.enable': 'Enable auto-backup',
|
||||||
'backup.auto.enableHint': 'Backups will be created automatically on the chosen schedule',
|
'backup.auto.enableHint': 'Backups will be created automatically on the chosen schedule',
|
||||||
'backup.auto.interval': 'Interval',
|
'backup.auto.interval': 'Interval',
|
||||||
|
'backup.auto.hour': 'Run at hour',
|
||||||
|
'backup.auto.hourHint': 'Server local time ({format} format)',
|
||||||
|
'backup.auto.dayOfWeek': 'Day of week',
|
||||||
|
'backup.auto.dayOfMonth': 'Day of month',
|
||||||
|
'backup.auto.dayOfMonthHint': 'Limited to 1–28 for compatibility with all months',
|
||||||
|
'backup.auto.scheduleSummary': 'Schedule',
|
||||||
|
'backup.auto.summaryDaily': 'Every day at {hour}:00',
|
||||||
|
'backup.auto.summaryWeekly': 'Every {day} at {hour}:00',
|
||||||
|
'backup.auto.summaryMonthly': 'Day {day} of every month at {hour}:00',
|
||||||
|
'backup.auto.envLocked': 'Docker',
|
||||||
|
'backup.auto.envLockedHint': 'Auto-backup is configured via Docker environment variables. To change these settings, update your docker-compose.yml and restart the container.',
|
||||||
|
'backup.auto.copyEnv': 'Copy Docker env vars',
|
||||||
|
'backup.auto.envCopied': 'Docker env vars copied to clipboard',
|
||||||
'backup.auto.keepLabel': 'Delete old backups after',
|
'backup.auto.keepLabel': 'Delete old backups after',
|
||||||
|
'backup.dow.sunday': 'Sun',
|
||||||
|
'backup.dow.monday': 'Mon',
|
||||||
|
'backup.dow.tuesday': 'Tue',
|
||||||
|
'backup.dow.wednesday': 'Wed',
|
||||||
|
'backup.dow.thursday': 'Thu',
|
||||||
|
'backup.dow.friday': 'Fri',
|
||||||
|
'backup.dow.saturday': 'Sat',
|
||||||
'backup.interval.hourly': 'Hourly',
|
'backup.interval.hourly': 'Hourly',
|
||||||
'backup.interval.daily': 'Daily',
|
'backup.interval.daily': 'Daily',
|
||||||
'backup.interval.weekly': 'Weekly',
|
'backup.interval.weekly': 'Weekly',
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ interface UpdateInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminPage(): React.ReactElement {
|
export default function AdminPage(): React.ReactElement {
|
||||||
const { demoMode } = useAuthStore()
|
const { demoMode, serverTimezone } = useAuthStore()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
|
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
const TABS = [
|
const TABS = [
|
||||||
@@ -512,10 +512,10 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-sm text-slate-500">
|
<td className="px-5 py-3 text-sm text-slate-500">
|
||||||
{new Date(u.created_at).toLocaleDateString(locale)}
|
{new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-sm text-slate-500">
|
<td className="px-5 py-3 text-sm text-slate-500">
|
||||||
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
|
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12, timeZone: serverTimezone }) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
@@ -584,7 +584,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-400 mt-0.5">
|
<div className="text-xs text-slate-400 mt-0.5">
|
||||||
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
|
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
|
||||||
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale)}`}
|
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale, { timeZone: serverTimezone })}`}
|
||||||
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
|
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface AuthState {
|
|||||||
error: string | null
|
error: string | null
|
||||||
demoMode: boolean
|
demoMode: boolean
|
||||||
hasMapsKey: boolean
|
hasMapsKey: boolean
|
||||||
|
serverTimezone: string
|
||||||
|
|
||||||
login: (email: string, password: string) => Promise<LoginResult>
|
login: (email: string, password: string) => Promise<LoginResult>
|
||||||
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
||||||
@@ -36,6 +37,7 @@ interface AuthState {
|
|||||||
deleteAvatar: () => Promise<void>
|
deleteAvatar: () => Promise<void>
|
||||||
setDemoMode: (val: boolean) => void
|
setDemoMode: (val: boolean) => void
|
||||||
setHasMapsKey: (val: boolean) => void
|
setHasMapsKey: (val: boolean) => void
|
||||||
|
setServerTimezone: (tz: string) => void
|
||||||
demoLogin: () => Promise<AuthResponse>
|
demoLogin: () => Promise<AuthResponse>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +49,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
error: null,
|
error: null,
|
||||||
demoMode: localStorage.getItem('demo_mode') === 'true',
|
demoMode: localStorage.getItem('demo_mode') === 'true',
|
||||||
hasMapsKey: false,
|
hasMapsKey: false,
|
||||||
|
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
|
||||||
login: async (email: string, password: string) => {
|
login: async (email: string, password: string) => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
@@ -201,6 +204,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
|
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
|
||||||
|
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
|
||||||
|
|
||||||
demoLogin: async () => {
|
demoLogin: async () => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ export interface AppConfig {
|
|||||||
oidc_display_name?: string
|
oidc_display_name?: string
|
||||||
has_maps_key?: boolean
|
has_maps_key?: boolean
|
||||||
allowed_file_types?: string
|
allowed_file_types?: string
|
||||||
|
timezone?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Translation function type
|
// Translation function type
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ export function currencyDecimals(currency: string): number {
|
|||||||
return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2
|
return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(dateStr: string | null | undefined, locale: string): string | null {
|
export function formatDate(dateStr: string | null | undefined, locale: string, timeZone?: string): string | null {
|
||||||
if (!dateStr) return null
|
if (!dateStr) return null
|
||||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, {
|
const opts: Intl.DateTimeFormatOptions = {
|
||||||
weekday: 'short', day: 'numeric', month: 'short',
|
weekday: 'short', day: 'numeric', month: 'short',
|
||||||
})
|
}
|
||||||
|
if (timeZone) opts.timeZone = timeZone
|
||||||
|
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTime(timeStr: string | null | undefined, locale: string, timeFormat: string): string {
|
export function formatTime(timeStr: string | null | undefined, locale: string, timeFormat: string): string {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ services:
|
|||||||
- JWT_SECRET=${JWT_SECRET:-}
|
- JWT_SECRET=${JWT_SECRET:-}
|
||||||
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
|
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ const router = express.Router();
|
|||||||
|
|
||||||
router.use(authenticate, adminOnly);
|
router.use(authenticate, adminOnly);
|
||||||
|
|
||||||
|
function utcSuffix(ts: string | null | undefined): string | null {
|
||||||
|
if (!ts) return null;
|
||||||
|
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/users', (req: Request, res: Response) => {
|
router.get('/users', (req: Request, res: Response) => {
|
||||||
const users = db.prepare(
|
const users = db.prepare(
|
||||||
'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
|
'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
|
||||||
@@ -21,7 +26,13 @@ router.get('/users', (req: Request, res: Response) => {
|
|||||||
const { getOnlineUserIds } = require('../websocket');
|
const { getOnlineUserIds } = require('../websocket');
|
||||||
onlineUserIds = getOnlineUserIds();
|
onlineUserIds = getOnlineUserIds();
|
||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
const usersWithStatus = users.map(u => ({ ...u, online: onlineUserIds.has(u.id) }));
|
const usersWithStatus = users.map(u => ({
|
||||||
|
...u,
|
||||||
|
created_at: utcSuffix(u.created_at),
|
||||||
|
updated_at: utcSuffix(u.updated_at as string),
|
||||||
|
last_login: utcSuffix(u.last_login),
|
||||||
|
online: onlineUserIds.has(u.id),
|
||||||
|
}));
|
||||||
res.json({ users: usersWithStatus });
|
res.json({ users: usersWithStatus });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ function getPendingMfaSecret(userId: number): string | null {
|
|||||||
return row.secret;
|
return row.secret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function utcSuffix(ts: string | null | undefined): string | null {
|
||||||
|
if (!ts) return null;
|
||||||
|
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
||||||
|
}
|
||||||
|
|
||||||
function stripUserForClient(user: User): Record<string, unknown> {
|
function stripUserForClient(user: User): Record<string, unknown> {
|
||||||
const {
|
const {
|
||||||
password_hash: _p,
|
password_hash: _p,
|
||||||
@@ -39,6 +44,9 @@ function stripUserForClient(user: User): Record<string, unknown> {
|
|||||||
} = user;
|
} = user;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
|
created_at: utcSuffix(rest.created_at),
|
||||||
|
updated_at: utcSuffix(rest.updated_at),
|
||||||
|
last_login: utcSuffix(rest.last_login),
|
||||||
mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true),
|
mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -146,6 +154,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
|
|||||||
demo_mode: isDemo,
|
demo_mode: isDemo,
|
||||||
demo_email: isDemo ? 'demo@trek.app' : undefined,
|
demo_email: isDemo ? 'demo@trek.app' : undefined,
|
||||||
demo_password: isDemo ? 'demo12345' : undefined,
|
demo_password: isDemo ? 'demo12345' : undefined,
|
||||||
|
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+19
-12
@@ -212,17 +212,30 @@ router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request,
|
|||||||
|
|
||||||
router.get('/auto-settings', (_req: Request, res: Response) => {
|
router.get('/auto-settings', (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
res.json({ settings: scheduler.loadSettings() });
|
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||||
|
res.json({ settings: scheduler.loadSettings(), timezone: tz });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('[backup] GET auto-settings:', err);
|
console.error('[backup] GET auto-settings:', err);
|
||||||
res.status(500).json({ error: 'Could not load backup settings' });
|
res.status(500).json({ error: 'Could not load backup settings' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function parseIntField(raw: unknown, fallback: number): number {
|
||||||
|
if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw);
|
||||||
|
if (typeof raw === 'string' && raw.trim() !== '') {
|
||||||
|
const n = parseInt(raw, 10);
|
||||||
|
if (Number.isFinite(n)) return n;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function parseAutoBackupBody(body: Record<string, unknown>): {
|
function parseAutoBackupBody(body: Record<string, unknown>): {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
interval: string;
|
interval: string;
|
||||||
keep_days: number;
|
keep_days: number;
|
||||||
|
hour: number;
|
||||||
|
day_of_week: number;
|
||||||
|
day_of_month: number;
|
||||||
} {
|
} {
|
||||||
const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1;
|
const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1;
|
||||||
const rawInterval = body.interval;
|
const rawInterval = body.interval;
|
||||||
@@ -230,17 +243,11 @@ function parseAutoBackupBody(body: Record<string, unknown>): {
|
|||||||
typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval)
|
typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval)
|
||||||
? rawInterval
|
? rawInterval
|
||||||
: 'daily';
|
: 'daily';
|
||||||
const rawKeep = body.keep_days;
|
const keep_days = Math.max(0, parseIntField(body.keep_days, 7));
|
||||||
let keepNum: number;
|
const hour = Math.min(23, Math.max(0, parseIntField(body.hour, 2)));
|
||||||
if (typeof rawKeep === 'number' && Number.isFinite(rawKeep)) {
|
const day_of_week = Math.min(6, Math.max(0, parseIntField(body.day_of_week, 0)));
|
||||||
keepNum = Math.floor(rawKeep);
|
const day_of_month = Math.min(28, Math.max(1, parseIntField(body.day_of_month, 1)));
|
||||||
} else if (typeof rawKeep === 'string' && rawKeep.trim() !== '') {
|
return { enabled, interval, keep_days, hour, day_of_week, day_of_month };
|
||||||
keepNum = parseInt(rawKeep, 10);
|
|
||||||
} else {
|
|
||||||
keepNum = NaN;
|
|
||||||
}
|
|
||||||
const keep_days = Number.isFinite(keepNum) && keepNum >= 0 ? keepNum : 7;
|
|
||||||
return { enabled, interval, keep_days };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
router.put('/auto-settings', (req: Request, res: Response) => {
|
router.put('/auto-settings', (req: Request, res: Response) => {
|
||||||
|
|||||||
+32
-13
@@ -8,30 +8,48 @@ const backupsDir = path.join(dataDir, 'backups');
|
|||||||
const uploadsDir = path.join(__dirname, '../uploads');
|
const uploadsDir = path.join(__dirname, '../uploads');
|
||||||
const settingsFile = path.join(dataDir, 'backup-settings.json');
|
const settingsFile = path.join(dataDir, 'backup-settings.json');
|
||||||
|
|
||||||
const CRON_EXPRESSIONS: Record<string, string> = {
|
const VALID_INTERVALS = ['hourly', 'daily', 'weekly', 'monthly'];
|
||||||
hourly: '0 * * * *',
|
const VALID_DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6]; // 0=Sunday
|
||||||
daily: '0 2 * * *',
|
const VALID_HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||||
weekly: '0 2 * * 0',
|
|
||||||
monthly: '0 2 1 * *',
|
|
||||||
};
|
|
||||||
|
|
||||||
const VALID_INTERVALS = Object.keys(CRON_EXPRESSIONS);
|
|
||||||
|
|
||||||
interface BackupSettings {
|
interface BackupSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
interval: string;
|
interval: string;
|
||||||
keep_days: number;
|
keep_days: number;
|
||||||
|
hour: number;
|
||||||
|
day_of_week: number;
|
||||||
|
day_of_month: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCronExpression(settings: BackupSettings): string {
|
||||||
|
const hour = VALID_HOURS.includes(settings.hour) ? settings.hour : 2;
|
||||||
|
const dow = VALID_DAYS_OF_WEEK.includes(settings.day_of_week) ? settings.day_of_week : 0;
|
||||||
|
const dom = settings.day_of_month >= 1 && settings.day_of_month <= 28 ? settings.day_of_month : 1;
|
||||||
|
|
||||||
|
switch (settings.interval) {
|
||||||
|
case 'hourly': return '0 * * * *';
|
||||||
|
case 'daily': return `0 ${hour} * * *`;
|
||||||
|
case 'weekly': return `0 ${hour} * * ${dow}`;
|
||||||
|
case 'monthly': return `0 ${hour} ${dom} * *`;
|
||||||
|
default: return `0 ${hour} * * *`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentTask: ScheduledTask | null = null;
|
let currentTask: ScheduledTask | null = null;
|
||||||
|
|
||||||
|
function getDefaults(): BackupSettings {
|
||||||
|
return { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
function loadSettings(): BackupSettings {
|
function loadSettings(): BackupSettings {
|
||||||
|
let settings = getDefaults();
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(settingsFile)) {
|
if (fs.existsSync(settingsFile)) {
|
||||||
return JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
const saved = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
||||||
|
settings = { ...settings, ...saved };
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
return { enabled: false, interval: 'daily', keep_days: 7 };
|
return settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSettings(settings: BackupSettings): void {
|
function saveSettings(settings: BackupSettings): void {
|
||||||
@@ -104,9 +122,10 @@ function start(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expression = CRON_EXPRESSIONS[settings.interval] || CRON_EXPRESSIONS.daily;
|
const expression = buildCronExpression(settings);
|
||||||
currentTask = cron.schedule(expression, runBackup);
|
const tz = process.env.TZ || 'UTC';
|
||||||
console.log(`[Auto-Backup] Scheduled: ${settings.interval} (${expression}), retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
|
currentTask = cron.schedule(expression, runBackup, { timezone: tz });
|
||||||
|
console.log(`[Auto-Backup] Scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Demo mode: hourly reset of demo user data
|
// Demo mode: hourly reset of demo user data
|
||||||
|
|||||||
Reference in New Issue
Block a user