diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 48e6889d..374e9b17 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -299,6 +299,8 @@ export const adminApi = { apiClient.post('/admin/dev/test-notification', data).then(r => r.data), getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data), updateNotificationPreferences: (prefs: Record>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data), + getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data), + updateDefaultUserSettings: (settings: Record) => apiClient.put('/admin/default-user-settings', settings).then(r => r.data), } export const addonsApi = { diff --git a/client/src/components/Admin/DefaultUserSettingsTab.tsx b/client/src/components/Admin/DefaultUserSettingsTab.tsx new file mode 100644 index 00000000..2f0a3865 --- /dev/null +++ b/client/src/components/Admin/DefaultUserSettingsTab.tsx @@ -0,0 +1,290 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { Settings2 } from 'lucide-react' +import { adminApi } from '../../api/client' +import { useTranslation } from '../../i18n' +import { useToast } from '../shared/Toast' +import Section from '../Settings/Section' +import CustomSelect from '../shared/CustomSelect' +import { MapView } from '../Map/MapView' +import type { Place } from '../../types' + +const MAP_PRESETS = [ + { name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }, + { name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' }, + { name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' }, + { name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' }, + { name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' }, +] + +type Defaults = { + temperature_unit?: string + dark_mode?: string | boolean + time_format?: string + route_calculation?: boolean + blur_booking_codes?: boolean + map_tile_url?: string +} + +function OptionRow({ + label, + hint, + children, +}: { + label: React.ReactNode + hint?: string + children: React.ReactNode +}) { + return ( +
+ + {hint &&

{hint}

} +
{children}
+
+ ) +} + +function OptionButton({ + active, + onClick, + children, +}: { + active: boolean + onClick: () => void + children: React.ReactNode +}) { + return ( + + ) +} + +export default function DefaultUserSettingsTab(): React.ReactElement { + const { t } = useTranslation() + const toast = useToast() + const [defaults, setDefaults] = useState({}) + const [loaded, setLoaded] = useState(false) + const [mapTileUrl, setMapTileUrl] = useState('') + + useEffect(() => { + adminApi.getDefaultUserSettings().then((data: Defaults) => { + setDefaults(data) + setMapTileUrl(data.map_tile_url || '') + setLoaded(true) + }).catch(() => setLoaded(true)) + }, []) + + const save = async (patch: Partial) => { + try { + const updated = await adminApi.updateDefaultUserSettings(patch as Record) + setDefaults(updated) + toast.success(t('admin.defaultSettings.saved')) + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : t('common.error')) + } + } + + const reset = async (key: keyof Defaults) => { + try { + const updated = await adminApi.updateDefaultUserSettings({ [key]: null }) + setDefaults(updated) + if (key === 'map_tile_url') setMapTileUrl('') + toast.success(t('admin.defaultSettings.reset')) + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : t('common.error')) + } + } + + const isSet = (key: keyof Defaults) => defaults[key] !== undefined + + const ResetButton = ({ field }: { field: keyof Defaults }) => + isSet(field) ? ( + + ) : null + + const mapPreviewPlaces = useMemo((): Place[] => [{ + id: 1, + trip_id: 1, + name: 'Preview center', + description: null, + notes: null, + lat: 48.8566, + lng: 2.3522, + address: null, + category_id: null, + icon: null, + price: null, + currency: null, + image_url: null, + google_place_id: null, + osm_id: null, + route_geometry: null, + place_time: null, + end_time: null, + duration_minutes: null, + transport_mode: null, + website: null, + phone: null, + created_at: Date(), + }], []) + + if (!loaded) { + return

Loading…

+ } + + const darkMode = defaults.dark_mode + + return ( +
+

+ {t('admin.defaultSettings.description')} +

+ + {/* Color Mode */} + {t('settings.colorMode')} }> + {([ + { value: 'light', label: t('settings.light') }, + { value: 'dark', label: t('settings.dark') }, + { value: 'auto', label: t('settings.auto') }, + ] as const).map(opt => ( + save({ dark_mode: opt.value })} + > + {opt.label} + + ))} + + + {/* Temperature */} + {t('settings.temperature')} }> + {([ + { value: 'celsius', label: '°C Celsius' }, + { value: 'fahrenheit', label: '°F Fahrenheit' }, + ] as const).map(opt => ( + save({ temperature_unit: opt.value })} + > + {opt.label} + + ))} + + + {/* Time Format */} + {t('settings.timeFormat')} }> + {([ + { value: '24h', label: '24h (14:30)' }, + { value: '12h', label: '12h (2:30 PM)' }, + ] as const).map(opt => ( + save({ time_format: opt.value })} + > + {opt.label} + + ))} + + + {/* Route Calculation */} + {t('settings.routeCalculation')} }> + {([ + { value: true, label: t('settings.on') || 'On' }, + { value: false, label: t('settings.off') || 'Off' }, + ] as const).map(opt => ( + save({ route_calculation: opt.value })} + > + {opt.label} + + ))} + + + {/* Blur Booking Codes */} + {t('settings.blurBookingCodes')} }> + {([ + { value: true, label: t('settings.on') || 'On' }, + { value: false, label: t('settings.off') || 'Off' }, + ] as const).map(opt => ( + save({ blur_booking_codes: opt.value })} + > + {opt.label} + + ))} + + + {/* Map Tile URL */} +
+ + { if (value) { setMapTileUrl(value); save({ map_tile_url: value }) } }} + placeholder={t('settings.mapTemplatePlaceholder.select')} + options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))} + size="sm" + style={{ marginBottom: 8 }} + /> + ) => setMapTileUrl(e.target.value)} + onBlur={() => save({ map_tile_url: mapTileUrl })} + placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +

{t('settings.mapDefaultHint')}

+
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {React.createElement(MapView as any, { + places: mapPreviewPlaces, + dayPlaces: [], + route: null, + routeSegments: null, + selectedPlaceId: null, + onMarkerClick: null, + onMapClick: null, + onMapContextMenu: null, + center: [48.8566, 2.3522], + zoom: 10, + tileUrl: mapTileUrl, + fitKey: null, + dayOrderMap: [], + leftWidth: 0, + rightWidth: 0, + hasInspector: false, + })} +
+
+
+ ) +} diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 82e28bfa..43853a42 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -464,6 +464,12 @@ const ar: Record = { 'admin.tabs.audit': 'تدقيق', 'admin.tabs.settings': 'الإعدادات', 'admin.tabs.config': 'التخصيص', + 'admin.tabs.defaults': 'الإعدادات الافتراضية', + 'admin.defaultSettings.title': 'إعدادات المستخدم الافتراضية', + 'admin.defaultSettings.description': 'تعيين الإعدادات الافتراضية على مستوى النظام. سيرى المستخدمون الذين لم يغيروا إعدادًا هذه القيم. تحظى تغييراتهم دائمًا بالأولوية.', + 'admin.defaultSettings.saved': 'تم حفظ الإعداد الافتراضي', + 'admin.defaultSettings.reset': 'إعادة التعيين إلى الإعداد الافتراضي المدمج', + 'admin.defaultSettings.resetToBuiltIn': 'إعادة تعيين', 'admin.tabs.templates': 'قوالب التعبئة', 'admin.tabs.addons': 'الإضافات', 'admin.tabs.mcpTokens': 'وصول MCP', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 7e914f25..45bc26dd 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -549,6 +549,12 @@ const br: Record = { 'admin.bagTracking.title': 'Rastreamento de malas', 'admin.bagTracking.subtitle': 'Ativar peso e atribuição de mala para itens da lista', 'admin.tabs.config': 'Personalização', + 'admin.tabs.defaults': 'Padrões do usuário', + 'admin.defaultSettings.title': 'Configurações padrão do usuário', + 'admin.defaultSettings.description': 'Defina padrões para toda a instância. Usuários que não alteraram uma configuração verão esses valores. As próprias alterações deles sempre têm prioridade.', + 'admin.defaultSettings.saved': 'Padrão salvo', + 'admin.defaultSettings.reset': 'Redefinir para o padrão integrado', + 'admin.defaultSettings.resetToBuiltIn': 'redefinir', 'admin.tabs.templates': 'Modelos de mala', 'admin.packingTemplates.title': 'Modelos de mala', 'admin.packingTemplates.subtitle': 'Crie listas de mala reutilizáveis para suas viagens', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 97ac79ac..5bd513ad 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -549,6 +549,12 @@ const cs: Record = { 'admin.bagTracking.title': 'Sledování zavazadel', 'admin.bagTracking.subtitle': 'Povolit váhu a přiřazení k zavazadlům u položek balení', 'admin.tabs.config': 'Personalizace', + 'admin.tabs.defaults': 'Výchozí nastavení uživatele', + 'admin.defaultSettings.title': 'Výchozí nastavení uživatele', + 'admin.defaultSettings.description': 'Nastavte výchozí hodnoty pro celou instanci. Uživatelé, kteří nezměnili nastavení, uvidí tyto hodnoty. Jejich vlastní změny mají vždy přednost.', + 'admin.defaultSettings.saved': 'Výchozí nastavení uloženo', + 'admin.defaultSettings.reset': 'Obnovit na vestavěnou výchozí hodnotu', + 'admin.defaultSettings.resetToBuiltIn': 'obnovit', 'admin.tabs.templates': 'Šablony seznamů', 'admin.packingTemplates.title': 'Šablony pro balení', 'admin.packingTemplates.subtitle': 'Vytvářejte opakovaně použitelné seznamy pro své cesty', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 84668eb0..f3901bdc 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -553,6 +553,12 @@ const de: Record = { 'admin.bagTracking.title': 'Gepäck-Tracking', 'admin.bagTracking.subtitle': 'Gewicht und Gepäckstück-Zuordnung für Packlisteneinträge aktivieren', 'admin.tabs.config': 'Personalisierung', + 'admin.tabs.defaults': 'Benutzer-Standards', + 'admin.defaultSettings.title': 'Standard-Benutzereinstellungen', + 'admin.defaultSettings.description': 'Instanzweite Standards festlegen. Benutzer, die eine Einstellung nicht geändert haben, sehen diese Werte. Eigene Änderungen haben immer Vorrang.', + 'admin.defaultSettings.saved': 'Standard gespeichert', + 'admin.defaultSettings.reset': 'Auf eingebauten Standard zurücksetzen', + 'admin.defaultSettings.resetToBuiltIn': 'zurücksetzen', 'admin.tabs.templates': 'Packvorlagen', 'admin.packingTemplates.title': 'Packvorlagen', 'admin.packingTemplates.subtitle': 'Wiederverwendbare Packlisten für deine Reisen erstellen', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 3dcae414..521c85c8 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -609,6 +609,12 @@ const en: Record = { 'admin.bagTracking.title': 'Bag Tracking', 'admin.bagTracking.subtitle': 'Enable weight and bag assignment for packing items', 'admin.tabs.config': 'Personalization', + 'admin.tabs.defaults': 'User Defaults', + 'admin.defaultSettings.title': 'Default User Settings', + 'admin.defaultSettings.description': 'Set instance-wide defaults. Users who have not changed a setting will see these values. Their own changes always take priority.', + 'admin.defaultSettings.saved': 'Default saved', + 'admin.defaultSettings.reset': 'Reset to built-in default', + 'admin.defaultSettings.resetToBuiltIn': 'reset', 'admin.tabs.templates': 'Packing Templates', 'admin.packingTemplates.title': 'Packing Templates', 'admin.packingTemplates.subtitle': 'Create reusable packing lists for your trips', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index d3e193c6..799c2b56 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -544,6 +544,12 @@ const es: Record = { 'admin.bagTracking.title': 'Seguimiento de equipaje', 'admin.bagTracking.subtitle': 'Activar peso y asignación de equipaje para artículos de la lista', 'admin.tabs.config': 'Personalización', + 'admin.tabs.defaults': 'Valores predeterminados', + 'admin.defaultSettings.title': 'Configuración predeterminada de usuarios', + 'admin.defaultSettings.description': 'Establece valores predeterminados para toda la instancia. Los usuarios que no hayan cambiado una opción verán estos valores. Sus propios cambios siempre tienen prioridad.', + 'admin.defaultSettings.saved': 'Predeterminado guardado', + 'admin.defaultSettings.reset': 'Restaurar al valor predeterminado integrado', + 'admin.defaultSettings.resetToBuiltIn': 'restaurar', 'admin.tabs.templates': 'Plantillas de equipaje', 'admin.packingTemplates.title': 'Plantillas de equipaje', 'admin.packingTemplates.subtitle': 'Crear listas de equipaje reutilizables para tus viajes', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index da78b5b2..effdaa61 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -548,6 +548,12 @@ const fr: Record = { 'admin.bagTracking.title': 'Suivi des bagages', 'admin.bagTracking.subtitle': 'Activer le poids et l\'attribution de bagages pour les articles', 'admin.tabs.config': 'Personnalisation', + 'admin.tabs.defaults': 'Valeurs par défaut', + 'admin.defaultSettings.title': 'Paramètres utilisateur par défaut', + 'admin.defaultSettings.description': "Définissez des valeurs par défaut pour toute l'instance. Les utilisateurs n'ayant pas modifié un paramètre verront ces valeurs. Leurs propres modifications ont toujours la priorité.", + 'admin.defaultSettings.saved': 'Valeur par défaut enregistrée', + 'admin.defaultSettings.reset': 'Réinitialiser à la valeur par défaut intégrée', + 'admin.defaultSettings.resetToBuiltIn': 'réinitialiser', 'admin.tabs.templates': 'Modèles de bagages', 'admin.packingTemplates.title': 'Modèles de bagages', 'admin.packingTemplates.subtitle': 'Créer des listes de bagages réutilisables pour vos voyages', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 4b03d7a7..7fab6db9 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -549,6 +549,12 @@ const hu: Record = { 'admin.bagTracking.title': 'Poggyászkövetés', 'admin.bagTracking.subtitle': 'Súly- és táskahozzárendelés engedélyezése csomagolási tételeknél', 'admin.tabs.config': 'Személyre szabás', + 'admin.tabs.defaults': 'Alapértelmezett beállítások', + 'admin.defaultSettings.title': 'Alapértelmezett felhasználói beállítások', + 'admin.defaultSettings.description': 'Állítson be alapértelmezett értékeket az egész példányra. Azok a felhasználók, akik nem módosítottak egy beállítást, ezeket az értékeket fogják látni. A saját módosításaik mindig elsőbbséget élveznek.', + 'admin.defaultSettings.saved': 'Alapértelmezett mentve', + 'admin.defaultSettings.reset': 'Visszaállítás a beépített alapértelmezésre', + 'admin.defaultSettings.resetToBuiltIn': 'visszaállítás', 'admin.tabs.templates': 'Csomagolási sablonok', 'admin.packingTemplates.title': 'Csomagolási sablonok', 'admin.packingTemplates.subtitle': 'Újrafelhasználható csomagolási listák létrehozása utazásaidhoz', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index a6d4f08f..0c602de9 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -609,6 +609,12 @@ const id: Record = { 'admin.bagTracking.title': 'Pelacak Tas', 'admin.bagTracking.subtitle': 'Aktifkan berat dan penugasan tas untuk item packing', 'admin.tabs.config': 'Personalisasi', + 'admin.tabs.defaults': 'Pengaturan Default Pengguna', + 'admin.defaultSettings.title': 'Pengaturan Default Pengguna', + 'admin.defaultSettings.description': 'Tetapkan nilai default untuk seluruh instance. Pengguna yang belum mengubah pengaturan akan melihat nilai-nilai ini. Perubahan mereka sendiri selalu diprioritaskan.', + 'admin.defaultSettings.saved': 'Default disimpan', + 'admin.defaultSettings.reset': 'Atur ulang ke default bawaan', + 'admin.defaultSettings.resetToBuiltIn': 'atur ulang', 'admin.tabs.templates': 'Template Packing', 'admin.packingTemplates.title': 'Template Packing', 'admin.packingTemplates.subtitle': 'Buat daftar packing yang bisa digunakan ulang untuk perjalananmu', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index b1340b1a..299a2fc7 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -548,6 +548,12 @@ const it: Record = { 'admin.bagTracking.title': 'Tracciamento valigia', 'admin.bagTracking.subtitle': 'Abilita il peso e l\'assegnazione della valigia per gli elementi della lista valigia', 'admin.tabs.config': 'Personalizzazione', + 'admin.tabs.defaults': 'Impostazioni predefinite', + 'admin.defaultSettings.title': 'Impostazioni predefinite utente', + 'admin.defaultSettings.description': "Imposta i valori predefiniti per l'intera istanza. Gli utenti che non hanno modificato un'impostazione vedranno questi valori. Le loro modifiche hanno sempre la priorità.", + 'admin.defaultSettings.saved': 'Predefinito salvato', + 'admin.defaultSettings.reset': 'Ripristina il predefinito integrato', + 'admin.defaultSettings.resetToBuiltIn': 'ripristina', 'admin.tabs.templates': 'Modelli lista valigia', 'admin.packingTemplates.title': 'Modelli lista valigia', 'admin.packingTemplates.subtitle': 'Crea liste valigia riutilizzabili per i tuoi viaggi', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index ca216397..3cb2c582 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -549,6 +549,12 @@ const nl: Record = { 'admin.bagTracking.title': 'Bagagetracking', 'admin.bagTracking.subtitle': 'Gewicht en bagagetoewijzing inschakelen voor paklijstitems', 'admin.tabs.config': 'Personalisatie', + 'admin.tabs.defaults': 'Standaardinstellingen', + 'admin.defaultSettings.title': 'Standaard gebruikersinstellingen', + 'admin.defaultSettings.description': 'Stel instantiebrede standaardwaarden in. Gebruikers die een instelling niet hebben gewijzigd, zien deze waarden. Hun eigen wijzigingen hebben altijd voorrang.', + 'admin.defaultSettings.saved': 'Standaard opgeslagen', + 'admin.defaultSettings.reset': 'Terugzetten naar ingebouwde standaard', + 'admin.defaultSettings.resetToBuiltIn': 'terugzetten', 'admin.tabs.templates': 'Paksjablonen', 'admin.packingTemplates.title': 'Paksjablonen', 'admin.packingTemplates.subtitle': 'Herbruikbare paklijsten maken voor je reizen', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 3f6e96ba..8befc14e 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -521,6 +521,12 @@ const pl: Record = { 'admin.bagTracking.title': 'Kontrola bagażu', 'admin.bagTracking.subtitle': 'Włącz wagę i przypisywanie do toreb dla przedmiotów do pakowania', 'admin.tabs.config': 'Personalizacja', + 'admin.tabs.defaults': 'Domyślne ustawienia', + 'admin.defaultSettings.title': 'Domyślne ustawienia użytkownika', + 'admin.defaultSettings.description': 'Ustaw domyślne wartości dla całej instancji. Użytkownicy, którzy nie zmienili ustawienia, zobaczą te wartości. Ich własne zmiany zawsze mają pierwszeństwo.', + 'admin.defaultSettings.saved': 'Domyślne zapisane', + 'admin.defaultSettings.reset': 'Przywróć wbudowaną wartość domyślną', + 'admin.defaultSettings.resetToBuiltIn': 'przywróć', 'admin.tabs.templates': 'Szablony pakowania', 'admin.packingTemplates.title': 'Szablony pakowania', 'admin.packingTemplates.subtitle': 'Twórz szablony list pakowania do wielokrotnego użycia dla swoich podróży', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 8786fc52..61add90e 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -549,6 +549,12 @@ const ru: Record = { 'admin.bagTracking.title': 'Отслеживание багажа', 'admin.bagTracking.subtitle': 'Включить вес и привязку к багажу для вещей', 'admin.tabs.config': 'Персонализация', + 'admin.tabs.defaults': 'Настройки по умолчанию', + 'admin.defaultSettings.title': 'Настройки пользователей по умолчанию', + 'admin.defaultSettings.description': 'Задайте значения по умолчанию для всего экземпляра. Пользователи, не изменившие параметр, увидят эти значения. Их собственные изменения всегда имеют приоритет.', + 'admin.defaultSettings.saved': 'Значение по умолчанию сохранено', + 'admin.defaultSettings.reset': 'Сбросить до встроенного значения', + 'admin.defaultSettings.resetToBuiltIn': 'сбросить', 'admin.tabs.templates': 'Шаблоны упаковки', 'admin.packingTemplates.title': 'Шаблоны упаковки', 'admin.packingTemplates.subtitle': 'Создавайте многоразовые списки вещей для поездок', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 18012afe..bf47a65e 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -549,6 +549,12 @@ const zh: Record = { 'admin.bagTracking.title': '行李追踪', 'admin.bagTracking.subtitle': '为打包物品启用重量和行李分配', 'admin.tabs.config': '个性化', + 'admin.tabs.defaults': '用户默认设置', + 'admin.defaultSettings.title': '用户默认设置', + 'admin.defaultSettings.description': '设置实例范围的默认值。未更改设置的用户将看到这些值。用户自己的更改始终优先。', + 'admin.defaultSettings.saved': '默认值已保存', + 'admin.defaultSettings.reset': '重置为内置默认值', + 'admin.defaultSettings.resetToBuiltIn': '重置', 'admin.tabs.templates': '打包模板', 'admin.packingTemplates.title': '打包模板', 'admin.packingTemplates.subtitle': '创建可复用的旅行打包清单', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index b54e23f6..71dc5cde 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -605,6 +605,12 @@ const zhTw: Record = { 'admin.bagTracking.title': '行李追蹤', 'admin.bagTracking.subtitle': '為打包物品啟用重量和行李分配', 'admin.tabs.config': '配置', + 'admin.tabs.defaults': '用戶預設設定', + 'admin.defaultSettings.title': '用戶預設設定', + 'admin.defaultSettings.description': '設定整個執行個體的預設值。未更改設定的用戶將看到這些值。用戶自己的更改始終優先。', + 'admin.defaultSettings.saved': '預設值已儲存', + 'admin.defaultSettings.reset': '重設為內建預設值', + 'admin.defaultSettings.resetToBuiltIn': '重設', 'admin.tabs.templates': '打包模板', 'admin.packingTemplates.title': '打包模板', 'admin.packingTemplates.subtitle': '建立可複用的旅行打包清單', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 38a9728c..1b57ef30 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import apiClient, { adminApi, authApi, notificationsApi } from '../api/client' import DevNotificationsPanel from '../components/Admin/DevNotificationsPanel' +import DefaultUserSettingsTab from '../components/Admin/DefaultUserSettingsTab' import { useAuthStore } from '../store/authStore' import { useSettingsStore } from '../store/settingsStore' import { useAddonStore } from '../store/addonStore' @@ -169,6 +170,7 @@ export default function AdminPage(): React.ReactElement { const TABS = [ { id: 'users', label: t('admin.tabs.users') }, { id: 'config', label: t('admin.tabs.config') }, + { id: 'defaults', label: t('admin.tabs.defaults') }, { id: 'addons', label: t('admin.tabs.addons') }, { id: 'settings', label: t('admin.tabs.settings') }, { id: 'notifications', label: t('admin.tabs.notifications') }, @@ -1493,6 +1495,8 @@ export default function AdminPage(): React.ReactElement { {activeTab === 'github' && } + {activeTab === 'defaults' && } + {activeTab === 'dev-notifications' && } diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 5184d4c6..d530b5b0 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -3,6 +3,7 @@ import { authenticate, adminOnly } from '../middleware/auth'; import { AuthRequest } from '../types'; import { writeAudit, getClientIp, logInfo } from '../services/auditLog'; import * as svc from '../services/adminService'; +import { getAdminUserDefaults, setAdminUserDefaults } from '../services/settingsService'; import { invalidateMcpSessions } from '../mcp'; import { getPreferencesMatrix, setAdminPreferences } from '../services/notificationPreferencesService'; @@ -346,6 +347,31 @@ router.post('/rotate-jwt-secret', (req: Request, res: Response) => { res.json({ success: true }); }); +// ── Default User Settings ────────────────────────────────────────────────────── + +router.get('/default-user-settings', (_req: Request, res: Response) => { + res.json(getAdminUserDefaults()); +}); + +router.put('/default-user-settings', (req: Request, res: Response) => { + if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) { + return res.status(400).json({ error: 'Object body required' }); + } + try { + setAdminUserDefaults(req.body); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.default_user_settings_update', + ip: getClientIp(req), + details: req.body, + }); + res.json(getAdminUserDefaults()); + } catch (err: any) { + res.status(400).json({ error: err.message }); + } +}); + // ── Dev-only: test notification endpoints ────────────────────────────────────── if (process.env.NODE_ENV === 'development') { const { send } = require('../services/notificationService'); diff --git a/server/src/services/settingsService.ts b/server/src/services/settingsService.ts index e8b22af4..7a0c3253 100644 --- a/server/src/services/settingsService.ts +++ b/server/src/services/settingsService.ts @@ -3,21 +3,99 @@ import { maybe_encrypt_api_key } from './apiKeyCrypto'; const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token']); +export const DEFAULTABLE_USER_SETTING_KEYS = [ + 'temperature_unit', + 'dark_mode', + 'time_format', + 'route_calculation', + 'blur_booking_codes', + 'map_tile_url', +] as const; + +type DefaultableKey = typeof DEFAULTABLE_USER_SETTING_KEYS[number]; + +const VALID_VALUES: Partial> = { + temperature_unit: ['fahrenheit', 'celsius'], + time_format: ['12h', '24h'], + dark_mode: [true, false, 'light', 'dark', 'auto'], +}; + +const BOOLEAN_KEYS = new Set(['route_calculation', 'blur_booking_codes']); + +function parseValue(raw: string): unknown { + try { return JSON.parse(raw); } catch { return raw; } +} + +export function getAdminUserDefaults(): Record { + const rows = db.prepare( + "SELECT key, value FROM app_settings WHERE key LIKE 'default_user_setting_%'" + ).all() as { key: string; value: string }[]; + const defaults: Record = {}; + for (const row of rows) { + const settingKey = row.key.slice('default_user_setting_'.length); + defaults[settingKey] = parseValue(row.value); + } + return defaults; +} + +export function setAdminUserDefaults(partial: Record): void { + const upsert = db.prepare( + `INSERT INTO app_settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ); + const del = db.prepare("DELETE FROM app_settings WHERE key = ?"); + + db.exec('BEGIN'); + try { + for (const [key, value] of Object.entries(partial)) { + if (!(DEFAULTABLE_USER_SETTING_KEYS as readonly string[]).includes(key)) { + throw new Error(`Invalid setting key: ${key}`); + } + const typedKey = key as DefaultableKey; + const appKey = `default_user_setting_${key}`; + + // null/undefined means "reset to built-in default" — delete the row + if (value === null || value === undefined) { + del.run(appKey); + continue; + } + + if (BOOLEAN_KEYS.has(typedKey) && typeof value !== 'boolean') { + throw new Error(`Setting ${key} must be a boolean`); + } + const allowed = VALID_VALUES[typedKey]; + if (allowed && !allowed.includes(value)) { + throw new Error(`Invalid value for ${key}: ${value}`); + } + + upsert.run(appKey, JSON.stringify(value)); + } + db.exec('COMMIT'); + } catch (err) { + db.exec('ROLLBACK'); + throw err; + } +} + export function getUserSettings(userId: number): Record { + const adminDefaults = getAdminUserDefaults(); + const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(userId) as { key: string; value: string }[]; - const settings: Record = {}; + const userSettings: Record = {}; for (const row of rows) { if (ENCRYPTED_SETTING_KEYS.has(row.key)) { - settings[row.key] = row.value ? '••••••••' : ''; + userSettings[row.key] = row.value ? '••••••••' : ''; continue; } try { - settings[row.key] = JSON.parse(row.value); + userSettings[row.key] = JSON.parse(row.value); } catch { - settings[row.key] = row.value; + userSettings[row.key] = row.value; } } - return settings; + + // Admin defaults fill in only for keys the user hasn't explicitly set + return { ...adminDefaults, ...userSettings }; } function serializeValue(key: string, value: unknown): string {