v2.1.0 — Real-time collaboration, performance & security overhaul

Real-Time Collaboration (WebSocket):
- WebSocket server with JWT auth and trip-based rooms
- Live sync for all CRUD operations (places, assignments, days, notes, budget, packing, reservations, files)
- Socket-based exclusion to prevent duplicate updates
- Auto-reconnect with exponential backoff
- Assignment move sync between days

Performance:
- 16 database indexes on all foreign key columns
- N+1 query fix in places, assignments and days endpoints
- Marker clustering (react-leaflet-cluster) with configurable radius
- List virtualization (react-window) for places sidebar
- useMemo for filtered places
- SQLite WAL mode + busy_timeout for concurrent writes
- Weather API: server-side cache (1h forecast, 15min current) + client sessionStorage
- Google Places photos: persisted to DB after first fetch
- Google Details: 3-tier cache (memory → sessionStorage → API)

Security:
- CORS auto-configuration (production: same-origin, dev: open)
- API keys removed from /auth/me response
- Admin-only endpoint for reading API keys
- Path traversal prevention in cover image deletion
- JWT secret persisted to file (survives restarts)
- Avatar upload file extension whitelist
- API key fallback: normal users use admin's key without exposure
- Case-insensitive email login

Dark Mode:
- Fixed hardcoded colors across PackingList, Budget, ReservationModal, ReservationsPanel
- Mobile map buttons and sidebar sheets respect dark mode
- Cluster markers always dark

UI/UX:
- Redesigned login page with animated planes, stars and feature cards
- Admin: create user functionality with CustomSelect
- Mobile: day-picker popup for assigning places to days
- Mobile: touch-friendly reorder buttons (32px targets)
- Mobile: responsive text (shorter labels on small screens)
- Packing list: index-based category colors
- i18n: translated date picker placeholder, fixed German labels
- Default map tile: CartoDB Light
This commit is contained in:
Maurice
2026-03-19 12:44:22 +01:00
parent f000943489
commit 74f19f3312
44 changed files with 1714 additions and 363 deletions
+103 -9
View File
@@ -8,7 +8,8 @@ import Modal from '../components/shared/Modal'
import { useToast } from '../components/shared/Toast'
import CategoryManager from '../components/Admin/CategoryManager'
import BackupPanel from '../components/Admin/BackupPanel'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2 } from 'lucide-react'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus } from 'lucide-react'
import CustomSelect from '../components/shared/CustomSelect'
export default function AdminPage() {
const { t } = useTranslation()
@@ -25,6 +26,8 @@ export default function AdminPage() {
const [isLoading, setIsLoading] = useState(true)
const [editingUser, setEditingUser] = useState(null)
const [editForm, setEditForm] = useState({ username: '', email: '', role: 'user', password: '' })
const [showCreateUser, setShowCreateUser] = useState(false)
const [createForm, setCreateForm] = useState({ username: '', email: '', password: '', role: 'user' })
// Registration toggle
const [allowRegistration, setAllowRegistration] = useState(true)
@@ -135,6 +138,22 @@ export default function AdminPage() {
}
}
const handleCreateUser = async () => {
if (!createForm.username.trim() || !createForm.email.trim() || !createForm.password.trim()) {
toast.error(t('admin.toast.fieldsRequired'))
return
}
try {
const data = await adminApi.createUser(createForm)
setUsers(prev => [data.user, ...prev])
setShowCreateUser(false)
setCreateForm({ username: '', email: '', password: '', role: 'user' })
toast.success(t('admin.toast.userCreated'))
} catch (err) {
toast.error(err.response?.data?.error || t('admin.toast.createError'))
}
}
const handleEditUser = (user) => {
setEditingUser(user)
setEditForm({ username: user.username, email: user.email, role: user.role, password: '' })
@@ -232,8 +251,15 @@ export default function AdminPage() {
{/* Tab content */}
{activeTab === 'users' && (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="p-5 border-b border-slate-100">
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
<h2 className="font-semibold text-slate-900">{t('admin.tabs.users')} ({users.length})</h2>
<button
onClick={() => setShowCreateUser(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors"
>
<UserPlus className="w-4 h-4" />
{t('admin.createUser')}
</button>
</div>
{isLoading ? (
@@ -464,6 +490,74 @@ export default function AdminPage() {
</div>
</div>
{/* Create user modal */}
<Modal
isOpen={showCreateUser}
onClose={() => setShowCreateUser(false)}
title={t('admin.createUser')}
size="sm"
footer={
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowCreateUser(false)}
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50"
>
{t('common.cancel')}
</button>
<button
onClick={handleCreateUser}
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 text-white rounded-lg"
>
{t('admin.createUser')}
</button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')} *</label>
<input
type="text"
value={createForm.username}
onChange={e => setCreateForm(f => ({ ...f, username: e.target.value }))}
placeholder={t('settings.username')}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.email')} *</label>
<input
type="email"
value={createForm.email}
onChange={e => setCreateForm(f => ({ ...f, email: e.target.value }))}
placeholder={t('common.email')}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.password')} *</label>
<input
type="password"
value={createForm.password}
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
placeholder={t('common.password')}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
<CustomSelect
value={createForm.role}
onChange={value => setCreateForm(f => ({ ...f, role: value }))}
options={[
{ value: 'user', label: t('settings.roleUser') },
{ value: 'admin', label: t('settings.roleAdmin') },
]}
/>
</div>
</div>
</Modal>
{/* Edit user modal */}
<Modal
isOpen={!!editingUser}
@@ -519,14 +613,14 @@ export default function AdminPage() {
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
<select
<CustomSelect
value={editForm.role}
onChange={e => setEditForm(f => ({ ...f, role: e.target.value }))}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white text-sm"
>
<option value="user">{t('settings.roleUser')}</option>
<option value="admin">{t('settings.roleAdmin')}</option>
</select>
onChange={value => setEditForm(f => ({ ...f, role: value }))}
options={[
{ value: 'user', label: t('settings.roleUser') },
{ value: 'admin', label: t('settings.roleAdmin') },
]}
/>
</div>
</div>
)}