mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Merge pull request #671 from mauriceboe/feat/admin-default-user-settings
feat(admin): add admin-configurable default user settings
This commit is contained in:
@@ -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<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
||||
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
|
||||
updateDefaultUserSettings: (settings: Record<string, unknown>) => apiClient.put('/admin/default-user-settings', settings).then(r => r.data),
|
||||
}
|
||||
|
||||
export const addonsApi = {
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{label}
|
||||
</label>
|
||||
{hint && <p className="text-xs mb-2" style={{ color: 'var(--text-faint)' }}>{hint}</p>}
|
||||
<div className="flex gap-3 flex-wrap">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OptionButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [defaults, setDefaults] = useState<Defaults>({})
|
||||
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<Defaults>) => {
|
||||
try {
|
||||
const updated = await adminApi.updateDefaultUserSettings(patch as Record<string, unknown>)
|
||||
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) ? (
|
||||
<button
|
||||
onClick={() => reset(field)}
|
||||
className="text-xs ml-2"
|
||||
style={{ color: 'var(--text-faint)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||
</button>
|
||||
) : 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 <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||
}
|
||||
|
||||
const darkMode = defaults.dark_mode
|
||||
|
||||
return (
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
<p className="text-sm" style={{ color: 'var(--text-faint)', marginTop: -8 }}>
|
||||
{t('admin.defaultSettings.description')}
|
||||
</p>
|
||||
|
||||
{/* Color Mode */}
|
||||
<OptionRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
||||
{([
|
||||
{ value: 'light', label: t('settings.light') },
|
||||
{ value: 'dark', label: t('settings.dark') },
|
||||
{ value: 'auto', label: t('settings.auto') },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={darkMode === opt.value || (opt.value === 'light' && darkMode === false) || (opt.value === 'dark' && darkMode === true)}
|
||||
onClick={() => save({ dark_mode: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Temperature */}
|
||||
<OptionRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
|
||||
{([
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={defaults.temperature_unit === opt.value}
|
||||
onClick={() => save({ temperature_unit: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Time Format */}
|
||||
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
||||
{([
|
||||
{ value: '24h', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={defaults.time_format === opt.value}
|
||||
onClick={() => save({ time_format: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Route Calculation */}
|
||||
<OptionRow label={<>{t('settings.routeCalculation')} <ResetButton field="route_calculation" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={String(opt.value)}
|
||||
active={defaults.route_calculation === opt.value}
|
||||
onClick={() => save({ route_calculation: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={String(opt.value)}
|
||||
active={defaults.blur_booking_codes === opt.value}
|
||||
onClick={() => save({ blur_booking_codes: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Map Tile URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('settings.mapTemplate')}
|
||||
<ResetButton field="map_tile_url" />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => { 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 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapTileUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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"
|
||||
/>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{t('settings.mapDefaultHint')}</p>
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* 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,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -464,6 +464,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -549,6 +549,12 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -549,6 +549,12 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -553,6 +553,12 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -609,6 +609,12 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -544,6 +544,12 @@ const es: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -548,6 +548,12 @@ const fr: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -549,6 +549,12 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -609,6 +609,12 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -548,6 +548,12 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -549,6 +549,12 @@ const nl: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -521,6 +521,12 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -549,6 +549,12 @@ const ru: Record<string, string> = {
|
||||
'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': 'Создавайте многоразовые списки вещей для поездок',
|
||||
|
||||
@@ -549,6 +549,12 @@ const zh: Record<string, string> = {
|
||||
'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': '创建可复用的旅行打包清单',
|
||||
|
||||
@@ -605,6 +605,12 @@ const zhTw: Record<string, string> = {
|
||||
'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': '建立可複用的旅行打包清單',
|
||||
|
||||
@@ -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' && <GitHubPanel isPrerelease={updateInfo?.is_prerelease ?? false} />}
|
||||
|
||||
{activeTab === 'defaults' && <DefaultUserSettingsTab />}
|
||||
|
||||
{activeTab === 'dev-notifications' && <DevNotificationsPanel />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<Record<DefaultableKey, unknown[]>> = {
|
||||
temperature_unit: ['fahrenheit', 'celsius'],
|
||||
time_format: ['12h', '24h'],
|
||||
dark_mode: [true, false, 'light', 'dark', 'auto'],
|
||||
};
|
||||
|
||||
const BOOLEAN_KEYS = new Set<DefaultableKey>(['route_calculation', 'blur_booking_codes']);
|
||||
|
||||
function parseValue(raw: string): unknown {
|
||||
try { return JSON.parse(raw); } catch { return raw; }
|
||||
}
|
||||
|
||||
export function getAdminUserDefaults(): Record<string, unknown> {
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown>): 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<string, unknown> {
|
||||
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<string, unknown> = {};
|
||||
const userSettings: Record<string, unknown> = {};
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user