diff --git a/Dockerfile b/Dockerfile index b45bd59..262571c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,9 +11,9 @@ FROM node:22-alpine WORKDIR /app -# Server-Dependencies installieren (better-sqlite3 braucht Build-Tools) +# Timezone support + Server-Dependencies (better-sqlite3 braucht Build-Tools) COPY server/package*.json ./ -RUN apk add --no-cache python3 make g++ && \ +RUN apk add --no-cache tzdata python3 make g++ && \ npm ci --production && \ apk del python3 make g++ diff --git a/client/src/App.tsx b/client/src/App.tsx index 8d2c96b..8eba608 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -62,28 +62,26 @@ function RootRedirect() { } export default function App() { - const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey } = useAuthStore() + const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone } = useAuthStore() const { loadSettings } = useSettingsStore() useEffect(() => { if (token) { 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?.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) { const storedVersion = localStorage.getItem('trek_app_version') if (storedVersion && storedVersion !== config.version) { try { - // Clear all Service Worker caches if ('caches' in window) { const names = await caches.keys() await Promise.all(names.map(n => caches.delete(n))) } - // Unregister all service workers if ('serviceWorker' in navigator) { const regs = await navigator.serviceWorker.getRegistrations() await Promise.all(regs.map(r => r.unregister())) diff --git a/client/src/components/Admin/BackupPanel.tsx b/client/src/components/Admin/BackupPanel.tsx index 89af898..fb62ed9 100644 --- a/client/src/components/Admin/BackupPanel.tsx +++ b/client/src/components/Admin/BackupPanel.tsx @@ -3,6 +3,7 @@ import { backupApi } from '../../api/client' import { useToast } from '../shared/Toast' import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react' import { useTranslation } from '../../i18n' +import { useSettingsStore } from '../../store/settingsStore' import { getApiErrorMessage } from '../../types' const INTERVAL_OPTIONS = [ @@ -21,19 +22,35 @@ const KEEP_OPTIONS = [ { 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() { const [backups, setBackups] = useState([]) const [isLoading, setIsLoading] = useState(false) const [isCreating, setIsCreating] = useState(false) const [restoringFile, setRestoringFile] = useState(null) 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 [autoSettingsDirty, setAutoSettingsDirty] = useState(false) + const [serverTimezone, setServerTimezone] = useState('') const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? } const fileInputRef = useRef(null) const toast = useToast() const { t, language, locale } = useTranslation() + const is12h = useSettingsStore(s => s.settings.time_format) === '12h' const loadBackups = async () => { setIsLoading(true) @@ -51,6 +68,7 @@ export default function BackupPanel() { try { const data = await backupApi.getAutoSettings() setAutoSettings(data.settings) + if (data.timezone) setServerTimezone(data.timezone) } catch {} } @@ -147,10 +165,12 @@ export default function BackupPanel() { const formatDate = (dateStr) => { if (!dateStr) return '-' try { - return new Date(dateStr).toLocaleString(locale, { + const opts: Intl.DateTimeFormatOptions = { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', - }) + } + if (serverTimezone) opts.timeZone = serverTimezone + return new Date(dateStr).toLocaleString(locale, opts) } catch { return dateStr } } @@ -331,6 +351,76 @@ export default function BackupPanel() { + {/* Hour picker (for daily, weekly, monthly) */} + {autoSettings.interval !== 'hourly' && ( +
+ + +

+ {t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''} +

+
+ )} + + {/* Day of week (for weekly) */} + {autoSettings.interval === 'weekly' && ( +
+ +
+ {DAYS_OF_WEEK.map(opt => ( + + ))} +
+
+ )} + + {/* Day of month (for monthly) */} + {autoSettings.interval === 'monthly' && ( +
+ + +

{t('backup.auto.dayOfMonthHint')}

+
+ )} + {/* Keep duration */}
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index b09273e..037079a 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1007,7 +1007,27 @@ const de: Record = { 'backup.auto.enable': 'Auto-Backup aktivieren', 'backup.auto.enableHint': 'Backups werden automatisch nach dem gewählten Zeitplan erstellt', '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.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.daily': 'Täglich', 'backup.interval.weekly': 'Wöchentlich', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index b85b6dc..41a15ca 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1007,7 +1007,27 @@ const en: Record = { 'backup.auto.enable': 'Enable auto-backup', 'backup.auto.enableHint': 'Backups will be created automatically on the chosen schedule', '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.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.daily': 'Daily', 'backup.interval.weekly': 'Weekly', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index b63306f..8ca84fc 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -52,7 +52,7 @@ interface UpdateInfo { } export default function AdminPage(): React.ReactElement { - const { demoMode } = useAuthStore() + const { demoMode, serverTimezone } = useAuthStore() const { t, locale } = useTranslation() const hour12 = useSettingsStore(s => s.settings.time_format) === '12h' const TABS = [ @@ -512,10 +512,10 @@ export default function AdminPage(): React.ReactElement { - {new Date(u.created_at).toLocaleDateString(locale)} + {new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })} - {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 }) : '—'}
@@ -584,7 +584,7 @@ export default function AdminPage(): React.ReactElement {
{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}`}
diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 1ce5211..8387c4e 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -23,6 +23,7 @@ interface AuthState { error: string | null demoMode: boolean hasMapsKey: boolean + serverTimezone: string login: (email: string, password: string) => Promise completeMfaLogin: (mfaToken: string, code: string) => Promise @@ -36,6 +37,7 @@ interface AuthState { deleteAvatar: () => Promise setDemoMode: (val: boolean) => void setHasMapsKey: (val: boolean) => void + setServerTimezone: (tz: string) => void demoLogin: () => Promise } @@ -47,6 +49,7 @@ export const useAuthStore = create((set, get) => ({ error: null, demoMode: localStorage.getItem('demo_mode') === 'true', hasMapsKey: false, + serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, login: async (email: string, password: string) => { set({ isLoading: true, error: null }) @@ -201,6 +204,7 @@ export const useAuthStore = create((set, get) => ({ }, setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }), + setServerTimezone: (tz: string) => set({ serverTimezone: tz }), demoLogin: async () => { set({ isLoading: true, error: null }) diff --git a/client/src/types.ts b/client/src/types.ts index e5913ab..7dd58f1 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -279,6 +279,7 @@ export interface AppConfig { oidc_display_name?: string has_maps_key?: boolean allowed_file_types?: string + timezone?: string } // Translation function type diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts index 4fd3409..7e7ebc0 100644 --- a/client/src/utils/formatters.ts +++ b/client/src/utils/formatters.ts @@ -6,11 +6,13 @@ export function currencyDecimals(currency: string): number { 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 - return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { + const opts: Intl.DateTimeFormatOptions = { 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 { diff --git a/docker-compose.yml b/docker-compose.yml index 1acc607..91a97e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - JWT_SECRET=${JWT_SECRET:-} # - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins - PORT=3000 + - TZ=${TZ:-UTC} volumes: - ./data:/app/data - ./uploads:/app/uploads diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index c7aaf2a..eea850f 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -12,6 +12,11 @@ const router = express.Router(); 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) => { const users = db.prepare( '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'); onlineUserIds = getOnlineUserIds(); } 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 }); }); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index b5518ef..de58aeb 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -28,6 +28,11 @@ function getPendingMfaSecret(userId: number): string | null { 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 { const { password_hash: _p, @@ -39,6 +44,9 @@ function stripUserForClient(user: User): Record { } = user; return { ...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), }; } @@ -146,6 +154,7 @@ router.get('/app-config', (_req: Request, res: Response) => { demo_mode: isDemo, demo_email: isDemo ? 'demo@trek.app' : undefined, demo_password: isDemo ? 'demo12345' : undefined, + timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', }); }); diff --git a/server/src/routes/backup.ts b/server/src/routes/backup.ts index e18190d..832b355 100644 --- a/server/src/routes/backup.ts +++ b/server/src/routes/backup.ts @@ -212,17 +212,30 @@ router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request, router.get('/auto-settings', (_req: Request, res: Response) => { 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) { console.error('[backup] GET auto-settings:', err); 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): { enabled: boolean; interval: string; 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 rawInterval = body.interval; @@ -230,17 +243,11 @@ function parseAutoBackupBody(body: Record): { typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval) ? rawInterval : 'daily'; - const rawKeep = body.keep_days; - let keepNum: number; - if (typeof rawKeep === 'number' && Number.isFinite(rawKeep)) { - keepNum = Math.floor(rawKeep); - } else if (typeof rawKeep === 'string' && rawKeep.trim() !== '') { - keepNum = parseInt(rawKeep, 10); - } else { - keepNum = NaN; - } - const keep_days = Number.isFinite(keepNum) && keepNum >= 0 ? keepNum : 7; - return { enabled, interval, keep_days }; + const keep_days = Math.max(0, parseIntField(body.keep_days, 7)); + const hour = Math.min(23, Math.max(0, parseIntField(body.hour, 2))); + const day_of_week = Math.min(6, Math.max(0, parseIntField(body.day_of_week, 0))); + const day_of_month = Math.min(28, Math.max(1, parseIntField(body.day_of_month, 1))); + return { enabled, interval, keep_days, hour, day_of_week, day_of_month }; } router.put('/auto-settings', (req: Request, res: Response) => { diff --git a/server/src/scheduler.ts b/server/src/scheduler.ts index 2a8a75d..a3272c2 100644 --- a/server/src/scheduler.ts +++ b/server/src/scheduler.ts @@ -8,30 +8,48 @@ const backupsDir = path.join(dataDir, 'backups'); const uploadsDir = path.join(__dirname, '../uploads'); const settingsFile = path.join(dataDir, 'backup-settings.json'); -const CRON_EXPRESSIONS: Record = { - hourly: '0 * * * *', - daily: '0 2 * * *', - weekly: '0 2 * * 0', - monthly: '0 2 1 * *', -}; - -const VALID_INTERVALS = Object.keys(CRON_EXPRESSIONS); +const VALID_INTERVALS = ['hourly', 'daily', 'weekly', 'monthly']; +const VALID_DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6]; // 0=Sunday +const VALID_HOURS = Array.from({ length: 24 }, (_, i) => i); interface BackupSettings { enabled: boolean; interval: string; 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; +function getDefaults(): BackupSettings { + return { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 }; +} + function loadSettings(): BackupSettings { + let settings = getDefaults(); try { if (fs.existsSync(settingsFile)) { - return JSON.parse(fs.readFileSync(settingsFile, 'utf8')); + const saved = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); + settings = { ...settings, ...saved }; } } catch (e) {} - return { enabled: false, interval: 'daily', keep_days: 7 }; + return settings; } function saveSettings(settings: BackupSettings): void { @@ -104,9 +122,10 @@ function start(): void { return; } - const expression = CRON_EXPRESSIONS[settings.interval] || CRON_EXPRESSIONS.daily; - currentTask = cron.schedule(expression, runBackup); - console.log(`[Auto-Backup] Scheduled: ${settings.interval} (${expression}), retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`); + const expression = buildCronExpression(settings); + const tz = process.env.TZ || 'UTC'; + 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