From 7d3b37a2a3feb0e4b43eabd94ddaf884a2097180 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=A9rnyi=20M=C3=A1rk?=
Date: Tue, 31 Mar 2026 20:30:12 +0200
Subject: [PATCH 01/14] feat: add configurable permissions system with admin
panel
Adds a full permissions management feature allowing admins to control
who can perform actions across the app (trip CRUD, files, places,
budget, packing, reservations, collab, members, share links).
- New server/src/services/permissions.ts: 16 configurable actions,
in-memory cache, checkPermission() helper, backwards-compatible
defaults matching upstream behaviour
- GET/PUT /admin/permissions endpoints; permissions loaded into
app-config response so clients have them on startup
- checkPermission() applied to all mutating route handlers across
10 server route files; getTripOwnerId() helper eliminates repeated
inline DB queries; trips.ts and files.ts now reuse canAccessTrip()
result to avoid redundant DB round-trips
- New client/src/store/permissionsStore.ts: Zustand store +
useCanDo() hook; TripOwnerContext type accepts both Trip and
DashboardTrip shapes without casting at call sites
- New client/src/components/Admin/PermissionsPanel.tsx: categorised
UI with per-action dropdowns, customised badge, save/reset
- AdminPage, DashboardPage, FileManager, PlacesSidebar,
TripMembersModal gated via useCanDo(); no prop drilling
- 46 perm.* translation keys added to all 12 language files
---
client/src/App.tsx | 9 +-
client/src/api/client.ts | 2 +
.../src/components/Admin/PermissionsPanel.tsx | 169 ++++++++++++++++++
client/src/components/Files/FileManager.tsx | 8 +-
.../src/components/Planner/PlacesSidebar.tsx | 18 +-
.../src/components/Trips/TripMembersModal.tsx | 20 ++-
client/src/i18n/translations/ar.ts | 49 +++++
client/src/i18n/translations/br.ts | 49 +++++
client/src/i18n/translations/cs.ts | 49 +++++
client/src/i18n/translations/de.ts | 49 +++++
client/src/i18n/translations/en.ts | 49 +++++
client/src/i18n/translations/es.ts | 49 +++++
client/src/i18n/translations/fr.ts | 49 +++++
client/src/i18n/translations/hu.ts | 49 +++++
client/src/i18n/translations/it.ts | 49 +++++
client/src/i18n/translations/nl.ts | 49 +++++
client/src/i18n/translations/ru.ts | 49 +++++
client/src/i18n/translations/zh.ts | 49 +++++
client/src/pages/AdminPage.tsx | 4 +
client/src/pages/DashboardPage.tsx | 80 +++++----
client/src/store/permissionsStore.ts | 52 ++++++
server/src/db/database.ts | 7 +-
server/src/routes/admin.ts | 30 ++++
server/src/routes/assignments.ts | 25 +++
server/src/routes/auth.ts | 2 +
server/src/routes/budget.ts | 28 ++-
server/src/routes/collab.ts | 51 +++++-
server/src/routes/dayNotes.ts | 18 +-
server/src/routes/days.ts | 25 +++
server/src/routes/files.ts | 16 +-
server/src/routes/packing.ts | 50 +++++-
server/src/routes/places.ts | 23 ++-
server/src/routes/reservations.ts | 23 ++-
server/src/routes/share.ts | 11 +-
server/src/routes/trips.ts | 64 +++++--
server/src/services/permissions.ts | 145 +++++++++++++++
36 files changed, 1384 insertions(+), 84 deletions(-)
create mode 100644 client/src/components/Admin/PermissionsPanel.tsx
create mode 100644 client/src/store/permissionsStore.ts
create mode 100644 server/src/services/permissions.ts
diff --git a/client/src/App.tsx b/client/src/App.tsx
index c1b1be6..fa31363 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -14,6 +14,7 @@ import SharedTripPage from './pages/SharedTripPage'
import { ToastContainer } from './components/shared/Toast'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi } from './api/client'
+import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
interface ProtectedRouteProps {
children: ReactNode
@@ -21,7 +22,10 @@ interface ProtectedRouteProps {
}
function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) {
- const { isAuthenticated, user, isLoading, appRequireMfa } = useAuthStore()
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
+ const user = useAuthStore((s) => s.user)
+ const isLoading = useAuthStore((s) => s.isLoading)
+ const appRequireMfa = useAuthStore((s) => s.appRequireMfa)
const { t } = useTranslation()
const location = useLocation()
@@ -78,12 +82,13 @@ export default function App() {
if (token) {
loadUser()
}
- authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean }) => {
+ authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record }) => {
if (config?.demo_mode) setDemoMode(true)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
+ if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
if (config?.version) {
const storedVersion = localStorage.getItem('trek_app_version')
diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index 0fd46f4..9c414d0 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -184,6 +184,8 @@ export const adminApi = {
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
+ getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data),
+ updatePermissions: (permissions: Record) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
}
export const addonsApi = {
diff --git a/client/src/components/Admin/PermissionsPanel.tsx b/client/src/components/Admin/PermissionsPanel.tsx
new file mode 100644
index 0000000..7a7eaef
--- /dev/null
+++ b/client/src/components/Admin/PermissionsPanel.tsx
@@ -0,0 +1,169 @@
+import React, { useEffect, useState, useMemo } from 'react'
+import { adminApi } from '../../api/client'
+import { useTranslation } from '../../i18n'
+import { usePermissionsStore, PermissionLevel } from '../../store/permissionsStore'
+import { useToast } from '../shared/Toast'
+import { Save, Loader2, RotateCcw } from 'lucide-react'
+import CustomSelect from '../shared/CustomSelect'
+
+interface PermissionEntry {
+ key: string
+ level: PermissionLevel
+ defaultLevel: PermissionLevel
+ allowedLevels: PermissionLevel[]
+}
+
+const LEVEL_LABELS: Record = {
+ admin: 'perm.level.admin',
+ trip_owner: 'perm.level.tripOwner',
+ trip_member: 'perm.level.tripMember',
+ everybody: 'perm.level.everybody',
+}
+
+const CATEGORIES = [
+ { id: 'trip', keys: ['trip_create', 'trip_edit', 'trip_delete', 'trip_archive', 'trip_cover_upload'] },
+ { id: 'members', keys: ['member_manage'] },
+ { id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
+ { id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
+ { id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
+]
+
+export default function PermissionsPanel(): React.ReactElement {
+ const { t } = useTranslation()
+ const toast = useToast()
+ const [entries, setEntries] = useState([])
+ const [values, setValues] = useState>({})
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [dirty, setDirty] = useState(false)
+
+ useEffect(() => {
+ loadPermissions()
+ }, [])
+
+ const loadPermissions = async () => {
+ setLoading(true)
+ try {
+ const data = await adminApi.getPermissions()
+ setEntries(data.permissions)
+ const vals: Record = {}
+ for (const p of data.permissions) vals[p.key] = p.level
+ setValues(vals)
+ setDirty(false)
+ } catch {
+ toast.error(t('common.error'))
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleChange = (key: string, level: PermissionLevel) => {
+ setValues(prev => ({ ...prev, [key]: level }))
+ setDirty(true)
+ }
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ const data = await adminApi.updatePermissions(values)
+ if (data.permissions) {
+ usePermissionsStore.getState().setPermissions(data.permissions)
+ }
+ setDirty(false)
+ toast.success(t('perm.saved'))
+ } catch {
+ toast.error(t('common.error'))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleReset = () => {
+ const defaults: Record = {}
+ for (const p of entries) defaults[p.key] = p.defaultLevel
+ setValues(defaults)
+ setDirty(true)
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ const entryMap = useMemo(() => new Map(entries.map(e => [e.key, e])), [entries])
+
+ return (
+
+
+
+
+
{t('perm.title')}
+
{t('perm.subtitle')}
+
+
+
+
+
+
+
+
+ {CATEGORIES.map(cat => (
+
+
+ {t(`perm.cat.${cat.id}`)}
+
+
+ {cat.keys.map(key => {
+ const entry = entryMap.get(key)
+ if (!entry) return null
+ const currentLevel = values[key] || entry.defaultLevel
+ const isDefault = currentLevel === entry.defaultLevel
+ return (
+
+
+
{t(`perm.action.${key}`)}
+
{t(`perm.actionHint.${key}`)}
+
+
+ {!isDefault && (
+
+ {t('perm.customized')}
+
+ )}
+ handleChange(key, val as PermissionLevel)}
+ options={entry.allowedLevels.map(l => ({
+ value: l,
+ label: t(LEVEL_LABELS[l] || l),
+ }))}
+ />
+
+
+ )
+ })}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx
index 920dedd..d0e85bb 100644
--- a/client/src/components/Files/FileManager.tsx
+++ b/client/src/components/Files/FileManager.tsx
@@ -6,6 +6,8 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { filesApi } from '../../api/client'
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
+import { useCanDo } from '../../store/permissionsStore'
+import { useTripStore } from '../../store/tripStore'
function authUrl(url: string): string {
const token = localStorage.getItem('auth_token')
@@ -159,6 +161,8 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
const [trashFiles, setTrashFiles] = useState([])
const [loadingTrash, setLoadingTrash] = useState(false)
const toast = useToast()
+ const can = useCanDo()
+ const trip = useTripStore((s) => s.trip)
const { t, locale } = useTranslation()
const loadTrash = useCallback(async () => {
@@ -710,7 +714,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
) : (
<>
{/* Upload zone */}
-
>
)}
-
+ }
{/* Filter tabs */}
diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx
index 94d742b..8f57c4c 100644
--- a/client/src/components/Planner/PlacesSidebar.tsx
+++ b/client/src/components/Planner/PlacesSidebar.tsx
@@ -11,6 +11,7 @@ import CustomSelect from '../shared/CustomSelect'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import { placesApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
+import { useCanDo } from '../../store/permissionsStore'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
interface PlacesSidebarProps {
@@ -38,7 +39,10 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
const toast = useToast()
const ctxMenu = useContextMenu()
const gpxInputRef = useRef
(null)
- const tripStore = useTripStore()
+ const trip = useTripStore((s) => s.trip)
+ const loadTrip = useTripStore((s) => s.loadTrip)
+ const can = useCanDo()
+ const canEditPlaces = can('place_edit', trip)
const handleGpxImport = async (e: React.ChangeEvent) => {
const file = e.target.files?.[0]
@@ -46,7 +50,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
e.target.value = ''
try {
const result = await placesApi.importGpx(tripId, file)
- await tripStore.loadTrip(tripId)
+ await loadTrip(tripId)
toast.success(t('places.gpxImported', { count: result.count }))
} catch (err: any) {
toast.error(err?.response?.data?.error || t('places.gpxError'))
@@ -88,7 +92,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
{/* Kopfbereich */}
-
+ }
+ {canEditPlaces && <>
+ >}
{/* Filter-Tabs */}
@@ -252,12 +258,12 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
}
}}
onContextMenu={e => ctxMenu.open(e, [
- onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
+ canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
{ divider: true },
- onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
+ canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])}
style={{
display: 'flex', alignItems: 'center', gap: 10,
diff --git a/client/src/components/Trips/TripMembersModal.tsx b/client/src/components/Trips/TripMembersModal.tsx
index f0deb44..4b40be0 100644
--- a/client/src/components/Trips/TripMembersModal.tsx
+++ b/client/src/components/Trips/TripMembersModal.tsx
@@ -3,6 +3,8 @@ import Modal from '../shared/Modal'
import { tripsApi, authApi, shareApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useAuthStore } from '../../store/authStore'
+import { useCanDo } from '../../store/permissionsStore'
+import { useTripStore } from '../../store/tripStore'
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { getApiErrorMessage } from '../../types'
@@ -32,7 +34,7 @@ function Avatar({ username, avatarUrl, size = 32 }: AvatarProps) {
)
}
-function ShareLinkSection({ tripId, t }: { tripId: number; t: any }) {
+function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record) => string }) {
const [shareToken, setShareToken] = useState(null)
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState(false)
@@ -172,6 +174,10 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
const toast = useToast()
const { user } = useAuthStore()
const { t } = useTranslation()
+ const can = useCanDo()
+ const trip = useTripStore((s) => s.trip)
+ const canManageMembers = can('member_manage', trip)
+ const canManageShare = can('share_manage', trip)
useEffect(() => {
if (isOpen && tripId) {
@@ -260,7 +266,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
{/* Add member dropdown */}
-
+ {canManageMembers &&
@@ -293,10 +299,10 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
{adding ? '…' : t('members.invite')}
- {availableUsers.length === 0 && allUsers.length > 0 && (
+ {availableUsers.length === 0 && allUsers.length > 0 && canManageMembers && (
{t('members.allHaveAccess')}
)}
-
+
}
{/* Members list */}
@@ -317,7 +323,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
{allMembers.map(member => {
const isSelf = member.id === user?.id
- const canRemove = isCurrentOwner ? member.role !== 'owner' : isSelf
+ const canRemove = isSelf || (canManageMembers && (isCurrentOwner ? member.role !== 'owner' : false))
return (
{/* Right column: Share Link */}
-
}
diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts
index 068cae2..391d448 100644
--- a/client/src/i18n/translations/ar.ts
+++ b/client/src/i18n/translations/ar.ts
@@ -1423,6 +1423,55 @@ const ar: Record
= {
'collab.polls.options': 'الخيارات',
'collab.polls.delete': 'حذف',
'collab.polls.closedSection': 'مغلق',
+
+ // Permissions
+ 'admin.tabs.permissions': 'الصلاحيات',
+ 'perm.title': 'إعدادات الصلاحيات',
+ 'perm.subtitle': 'التحكم في من يمكنه تنفيذ الإجراءات عبر التطبيق',
+ 'perm.saved': 'تم حفظ إعدادات الصلاحيات',
+ 'perm.resetDefaults': 'إعادة التعيين إلى الافتراضي',
+ 'perm.customized': 'مخصص',
+ 'perm.level.admin': 'المسؤول فقط',
+ 'perm.level.tripOwner': 'مالك الرحلة',
+ 'perm.level.tripMember': 'أعضاء الرحلة',
+ 'perm.level.everybody': 'الجميع',
+ 'perm.cat.trip': 'إدارة الرحلات',
+ 'perm.cat.members': 'إدارة الأعضاء',
+ 'perm.cat.files': 'الملفات',
+ 'perm.cat.content': 'المحتوى والجدول الزمني',
+ 'perm.cat.extras': 'الميزانية والتعبئة والتعاون',
+ 'perm.action.trip_create': 'إنشاء رحلات',
+ 'perm.action.trip_edit': 'تعديل تفاصيل الرحلة',
+ 'perm.action.trip_delete': 'حذف الرحلات',
+ 'perm.action.trip_archive': 'أرشفة / إلغاء أرشفة الرحلات',
+ 'perm.action.trip_cover_upload': 'رفع صورة الغلاف',
+ 'perm.action.member_manage': 'إضافة / إزالة الأعضاء',
+ 'perm.action.file_upload': 'رفع الملفات',
+ 'perm.action.file_edit': 'تعديل بيانات الملف',
+ 'perm.action.file_delete': 'حذف الملفات',
+ 'perm.action.place_edit': 'إضافة / تعديل / حذف الأماكن',
+ 'perm.action.day_edit': 'تعديل الأيام والملاحظات والتعيينات',
+ 'perm.action.reservation_edit': 'إدارة الحجوزات',
+ 'perm.action.budget_edit': 'إدارة الميزانية',
+ 'perm.action.packing_edit': 'إدارة قوائم التعبئة',
+ 'perm.action.collab_edit': 'التعاون (ملاحظات، استطلاعات، دردشة)',
+ 'perm.action.share_manage': 'إدارة روابط المشاركة',
+ 'perm.actionHint.trip_create': 'من يمكنه إنشاء رحلات جديدة',
+ 'perm.actionHint.trip_edit': 'من يمكنه تغيير اسم الرحلة والتواريخ والوصف والعملة',
+ 'perm.actionHint.trip_delete': 'من يمكنه حذف رحلة نهائياً',
+ 'perm.actionHint.trip_archive': 'من يمكنه أرشفة أو إلغاء أرشفة رحلة',
+ 'perm.actionHint.trip_cover_upload': 'من يمكنه رفع أو تغيير صورة الغلاف',
+ 'perm.actionHint.member_manage': 'من يمكنه دعوة أو إزالة أعضاء الرحلة',
+ 'perm.actionHint.file_upload': 'من يمكنه رفع ملفات إلى رحلة',
+ 'perm.actionHint.file_edit': 'من يمكنه تعديل أوصاف الملفات والروابط',
+ 'perm.actionHint.file_delete': 'من يمكنه نقل الملفات إلى سلة المهملات أو حذفها نهائياً',
+ 'perm.actionHint.place_edit': 'من يمكنه إضافة أو تعديل أو حذف الأماكن',
+ 'perm.actionHint.day_edit': 'من يمكنه تعديل الأيام وملاحظات الأيام وتعيينات الأماكن',
+ 'perm.actionHint.reservation_edit': 'من يمكنه إنشاء أو تعديل أو حذف الحجوزات',
+ 'perm.actionHint.budget_edit': 'من يمكنه إنشاء أو تعديل أو حذف عناصر الميزانية',
+ 'perm.actionHint.packing_edit': 'من يمكنه إدارة عناصر التعبئة والحقائب',
+ 'perm.actionHint.collab_edit': 'من يمكنه إنشاء ملاحظات واستطلاعات وإرسال رسائل',
+ 'perm.actionHint.share_manage': 'من يمكنه إنشاء أو حذف روابط المشاركة العامة',
}
export default ar
diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts
index ef333d9..203d618 100644
--- a/client/src/i18n/translations/br.ts
+++ b/client/src/i18n/translations/br.ts
@@ -1402,6 +1402,55 @@ const br: Record = {
'memories.confirmShareTitle': 'Compartilhar com membros da viagem?',
'memories.confirmShareHint': '{count} fotos serão visíveis para todos os membros desta viagem. Você pode tornar fotos individuais privadas depois.',
'memories.confirmShareButton': 'Compartilhar fotos',
+
+ // Permissions
+ 'admin.tabs.permissions': 'Permissões',
+ 'perm.title': 'Configurações de Permissões',
+ 'perm.subtitle': 'Controle quem pode realizar ações no aplicativo',
+ 'perm.saved': 'Configurações de permissões salvas',
+ 'perm.resetDefaults': 'Restaurar padrões',
+ 'perm.customized': 'personalizado',
+ 'perm.level.admin': 'Apenas administrador',
+ 'perm.level.tripOwner': 'Dono da viagem',
+ 'perm.level.tripMember': 'Membros da viagem',
+ 'perm.level.everybody': 'Todos',
+ 'perm.cat.trip': 'Gerenciamento de Viagens',
+ 'perm.cat.members': 'Gerenciamento de Membros',
+ 'perm.cat.files': 'Arquivos',
+ 'perm.cat.content': 'Conteúdo e Cronograma',
+ 'perm.cat.extras': 'Orçamento, Bagagem e Colaboração',
+ 'perm.action.trip_create': 'Criar viagens',
+ 'perm.action.trip_edit': 'Editar detalhes da viagem',
+ 'perm.action.trip_delete': 'Excluir viagens',
+ 'perm.action.trip_archive': 'Arquivar / desarquivar viagens',
+ 'perm.action.trip_cover_upload': 'Enviar imagem de capa',
+ 'perm.action.member_manage': 'Adicionar / remover membros',
+ 'perm.action.file_upload': 'Enviar arquivos',
+ 'perm.action.file_edit': 'Editar metadados do arquivo',
+ 'perm.action.file_delete': 'Excluir arquivos',
+ 'perm.action.place_edit': 'Adicionar / editar / excluir lugares',
+ 'perm.action.day_edit': 'Editar dias, notas e atribuições',
+ 'perm.action.reservation_edit': 'Gerenciar reservas',
+ 'perm.action.budget_edit': 'Gerenciar orçamento',
+ 'perm.action.packing_edit': 'Gerenciar listas de bagagem',
+ 'perm.action.collab_edit': 'Colaboração (notas, enquetes, chat)',
+ 'perm.action.share_manage': 'Gerenciar links de compartilhamento',
+ 'perm.actionHint.trip_create': 'Quem pode criar novas viagens',
+ 'perm.actionHint.trip_edit': 'Quem pode alterar nome, datas, descrição e moeda da viagem',
+ 'perm.actionHint.trip_delete': 'Quem pode excluir permanentemente uma viagem',
+ 'perm.actionHint.trip_archive': 'Quem pode arquivar ou desarquivar uma viagem',
+ 'perm.actionHint.trip_cover_upload': 'Quem pode enviar ou alterar a imagem de capa',
+ 'perm.actionHint.member_manage': 'Quem pode convidar ou remover membros da viagem',
+ 'perm.actionHint.file_upload': 'Quem pode enviar arquivos para uma viagem',
+ 'perm.actionHint.file_edit': 'Quem pode editar descrições e links dos arquivos',
+ 'perm.actionHint.file_delete': 'Quem pode mover arquivos para a lixeira ou excluí-los permanentemente',
+ 'perm.actionHint.place_edit': 'Quem pode adicionar, editar ou excluir lugares',
+ 'perm.actionHint.day_edit': 'Quem pode editar dias, notas dos dias e atribuições de lugares',
+ 'perm.actionHint.reservation_edit': 'Quem pode criar, editar ou excluir reservas',
+ 'perm.actionHint.budget_edit': 'Quem pode criar, editar ou excluir itens do orçamento',
+ 'perm.actionHint.packing_edit': 'Quem pode gerenciar itens de bagagem e malas',
+ 'perm.actionHint.collab_edit': 'Quem pode criar notas, enquetes e enviar mensagens',
+ 'perm.actionHint.share_manage': 'Quem pode criar ou excluir links de compartilhamento públicos',
}
export default br
diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts
index 1bbdea4..d34d383 100644
--- a/client/src/i18n/translations/cs.ts
+++ b/client/src/i18n/translations/cs.ts
@@ -1423,6 +1423,55 @@ const cs: Record = {
'collab.polls.options': 'Možnosti',
'collab.polls.delete': 'Smazat',
'collab.polls.closedSection': 'Uzavřené',
+
+ // Permissions
+ 'admin.tabs.permissions': 'Oprávnění',
+ 'perm.title': 'Nastavení oprávnění',
+ 'perm.subtitle': 'Určete, kdo může provádět akce v aplikaci',
+ 'perm.saved': 'Nastavení oprávnění uloženo',
+ 'perm.resetDefaults': 'Obnovit výchozí',
+ 'perm.customized': 'upraveno',
+ 'perm.level.admin': 'Pouze administrátor',
+ 'perm.level.tripOwner': 'Vlastník výletu',
+ 'perm.level.tripMember': 'Členové výletu',
+ 'perm.level.everybody': 'Všichni',
+ 'perm.cat.trip': 'Správa výletů',
+ 'perm.cat.members': 'Správa členů',
+ 'perm.cat.files': 'Soubory',
+ 'perm.cat.content': 'Obsah a plán',
+ 'perm.cat.extras': 'Rozpočet, balení a spolupráce',
+ 'perm.action.trip_create': 'Vytvářet výlety',
+ 'perm.action.trip_edit': 'Upravit detaily výletu',
+ 'perm.action.trip_delete': 'Smazat výlety',
+ 'perm.action.trip_archive': 'Archivovat / odarchivovat výlety',
+ 'perm.action.trip_cover_upload': 'Nahrát titulní obrázek',
+ 'perm.action.member_manage': 'Přidat / odebrat členy',
+ 'perm.action.file_upload': 'Nahrát soubory',
+ 'perm.action.file_edit': 'Upravit metadata souborů',
+ 'perm.action.file_delete': 'Smazat soubory',
+ 'perm.action.place_edit': 'Přidat / upravit / smazat místa',
+ 'perm.action.day_edit': 'Upravit dny, poznámky a přiřazení',
+ 'perm.action.reservation_edit': 'Spravovat rezervace',
+ 'perm.action.budget_edit': 'Spravovat rozpočet',
+ 'perm.action.packing_edit': 'Spravovat seznamy balení',
+ 'perm.action.collab_edit': 'Spolupráce (poznámky, hlasování, chat)',
+ 'perm.action.share_manage': 'Spravovat odkazy ke sdílení',
+ 'perm.actionHint.trip_create': 'Kdo může vytvářet nové výlety',
+ 'perm.actionHint.trip_edit': 'Kdo může měnit název, data, popis a měnu výletu',
+ 'perm.actionHint.trip_delete': 'Kdo může trvale smazat výlet',
+ 'perm.actionHint.trip_archive': 'Kdo může archivovat nebo odarchivovat výlet',
+ 'perm.actionHint.trip_cover_upload': 'Kdo může nahrát nebo změnit titulní obrázek',
+ 'perm.actionHint.member_manage': 'Kdo může pozvat nebo odebrat členy výletu',
+ 'perm.actionHint.file_upload': 'Kdo může nahrávat soubory k výletu',
+ 'perm.actionHint.file_edit': 'Kdo může upravovat popisy a odkazy souborů',
+ 'perm.actionHint.file_delete': 'Kdo může přesunout soubory do koše nebo je trvale smazat',
+ 'perm.actionHint.place_edit': 'Kdo může přidávat, upravovat nebo mazat místa',
+ 'perm.actionHint.day_edit': 'Kdo může upravovat dny, poznámky ke dnům a přiřazení míst',
+ 'perm.actionHint.reservation_edit': 'Kdo může vytvářet, upravovat nebo mazat rezervace',
+ 'perm.actionHint.budget_edit': 'Kdo může vytvářet, upravovat nebo mazat položky rozpočtu',
+ 'perm.actionHint.packing_edit': 'Kdo může spravovat položky balení a tašky',
+ 'perm.actionHint.collab_edit': 'Kdo může vytvářet poznámky, hlasování a posílat zprávy',
+ 'perm.actionHint.share_manage': 'Kdo může vytvářet nebo mazat veřejné odkazy ke sdílení',
}
export default cs
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts
index 02216a3..b54abd8 100644
--- a/client/src/i18n/translations/de.ts
+++ b/client/src/i18n/translations/de.ts
@@ -1420,6 +1420,55 @@ const de: Record = {
'collab.polls.options': 'Optionen',
'collab.polls.delete': 'Löschen',
'collab.polls.closedSection': 'Geschlossen',
+
+ // Permissions
+ 'admin.tabs.permissions': 'Berechtigungen',
+ 'perm.title': 'Berechtigungseinstellungen',
+ 'perm.subtitle': 'Steuern Sie, wer Aktionen in der Anwendung ausführen kann',
+ 'perm.saved': 'Berechtigungseinstellungen gespeichert',
+ 'perm.resetDefaults': 'Auf Standard zurücksetzen',
+ 'perm.customized': 'angepasst',
+ 'perm.level.admin': 'Nur Administrator',
+ 'perm.level.tripOwner': 'Reise-Eigentümer',
+ 'perm.level.tripMember': 'Reise-Mitglieder',
+ 'perm.level.everybody': 'Alle',
+ 'perm.cat.trip': 'Reiseverwaltung',
+ 'perm.cat.members': 'Mitgliederverwaltung',
+ 'perm.cat.files': 'Dateien',
+ 'perm.cat.content': 'Inhalte & Zeitplan',
+ 'perm.cat.extras': 'Budget, Packlisten & Zusammenarbeit',
+ 'perm.action.trip_create': 'Reisen erstellen',
+ 'perm.action.trip_edit': 'Reisedetails bearbeiten',
+ 'perm.action.trip_delete': 'Reisen löschen',
+ 'perm.action.trip_archive': 'Reisen archivieren / dearchivieren',
+ 'perm.action.trip_cover_upload': 'Titelbild hochladen',
+ 'perm.action.member_manage': 'Mitglieder hinzufügen / entfernen',
+ 'perm.action.file_upload': 'Dateien hochladen',
+ 'perm.action.file_edit': 'Datei-Metadaten bearbeiten',
+ 'perm.action.file_delete': 'Dateien löschen',
+ 'perm.action.place_edit': 'Orte hinzufügen / bearbeiten / löschen',
+ 'perm.action.day_edit': 'Tage, Notizen & Zuweisungen bearbeiten',
+ 'perm.action.reservation_edit': 'Reservierungen verwalten',
+ 'perm.action.budget_edit': 'Budget verwalten',
+ 'perm.action.packing_edit': 'Packlisten verwalten',
+ 'perm.action.collab_edit': 'Zusammenarbeit (Notizen, Umfragen, Chat)',
+ 'perm.action.share_manage': 'Freigabelinks verwalten',
+ 'perm.actionHint.trip_create': 'Wer kann neue Reisen erstellen',
+ 'perm.actionHint.trip_edit': 'Wer kann Reisename, Daten, Beschreibung und Währung ändern',
+ 'perm.actionHint.trip_delete': 'Wer kann eine Reise dauerhaft löschen',
+ 'perm.actionHint.trip_archive': 'Wer kann eine Reise archivieren oder dearchivieren',
+ 'perm.actionHint.trip_cover_upload': 'Wer kann das Titelbild hochladen oder ändern',
+ 'perm.actionHint.member_manage': 'Wer kann Reise-Mitglieder einladen oder entfernen',
+ 'perm.actionHint.file_upload': 'Wer kann Dateien zu einer Reise hochladen',
+ 'perm.actionHint.file_edit': 'Wer kann Dateibeschreibungen und Links bearbeiten',
+ 'perm.actionHint.file_delete': 'Wer kann Dateien in den Papierkorb verschieben oder dauerhaft löschen',
+ 'perm.actionHint.place_edit': 'Wer kann Orte hinzufügen, bearbeiten oder löschen',
+ 'perm.actionHint.day_edit': 'Wer kann Tage, Tagesnotizen und Ort-Zuweisungen bearbeiten',
+ 'perm.actionHint.reservation_edit': 'Wer kann Reservierungen erstellen, bearbeiten oder löschen',
+ 'perm.actionHint.budget_edit': 'Wer kann Budgetposten erstellen, bearbeiten oder löschen',
+ 'perm.actionHint.packing_edit': 'Wer kann Packstücke und Taschen verwalten',
+ 'perm.actionHint.collab_edit': 'Wer kann Notizen, Umfragen erstellen und Nachrichten senden',
+ 'perm.actionHint.share_manage': 'Wer kann öffentliche Freigabelinks erstellen oder löschen',
}
export default de
diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts
index 1d58761..97243fc 100644
--- a/client/src/i18n/translations/en.ts
+++ b/client/src/i18n/translations/en.ts
@@ -1416,6 +1416,55 @@ const en: Record = {
'collab.polls.options': 'Options',
'collab.polls.delete': 'Delete',
'collab.polls.closedSection': 'Closed',
+
+ // Permissions
+ 'admin.tabs.permissions': 'Permissions',
+ 'perm.title': 'Permission Settings',
+ 'perm.subtitle': 'Control who can perform actions across the application',
+ 'perm.saved': 'Permission settings saved',
+ 'perm.resetDefaults': 'Reset to defaults',
+ 'perm.customized': 'customized',
+ 'perm.level.admin': 'Admin only',
+ 'perm.level.tripOwner': 'Trip owner',
+ 'perm.level.tripMember': 'Trip members',
+ 'perm.level.everybody': 'Everyone',
+ 'perm.cat.trip': 'Trip Management',
+ 'perm.cat.members': 'Member Management',
+ 'perm.cat.files': 'Files',
+ 'perm.cat.content': 'Content & Schedule',
+ 'perm.cat.extras': 'Budget, Packing & Collaboration',
+ 'perm.action.trip_create': 'Create trips',
+ 'perm.action.trip_edit': 'Edit trip details',
+ 'perm.action.trip_delete': 'Delete trips',
+ 'perm.action.trip_archive': 'Archive / unarchive trips',
+ 'perm.action.trip_cover_upload': 'Upload cover image',
+ 'perm.action.member_manage': 'Add / remove members',
+ 'perm.action.file_upload': 'Upload files',
+ 'perm.action.file_edit': 'Edit file metadata',
+ 'perm.action.file_delete': 'Delete files',
+ 'perm.action.place_edit': 'Add / edit / delete places',
+ 'perm.action.day_edit': 'Edit days, notes & assignments',
+ 'perm.action.reservation_edit': 'Manage reservations',
+ 'perm.action.budget_edit': 'Manage budget',
+ 'perm.action.packing_edit': 'Manage packing lists',
+ 'perm.action.collab_edit': 'Collaboration (notes, polls, chat)',
+ 'perm.action.share_manage': 'Manage share links',
+ 'perm.actionHint.trip_create': 'Who can create new trips',
+ 'perm.actionHint.trip_edit': 'Who can change trip name, dates, description and currency',
+ 'perm.actionHint.trip_delete': 'Who can permanently delete a trip',
+ 'perm.actionHint.trip_archive': 'Who can archive or unarchive a trip',
+ 'perm.actionHint.trip_cover_upload': 'Who can upload or change the cover image',
+ 'perm.actionHint.member_manage': 'Who can invite or remove trip members',
+ 'perm.actionHint.file_upload': 'Who can upload files to a trip',
+ 'perm.actionHint.file_edit': 'Who can edit file descriptions and links',
+ 'perm.actionHint.file_delete': 'Who can move files to trash or permanently delete them',
+ 'perm.actionHint.place_edit': 'Who can add, edit or delete places',
+ 'perm.actionHint.day_edit': 'Who can edit days, day notes and place assignments',
+ 'perm.actionHint.reservation_edit': 'Who can create, edit or delete reservations',
+ 'perm.actionHint.budget_edit': 'Who can create, edit or delete budget items',
+ 'perm.actionHint.packing_edit': 'Who can manage packing items and bags',
+ 'perm.actionHint.collab_edit': 'Who can create notes, polls and send messages',
+ 'perm.actionHint.share_manage': 'Who can create or delete public share links',
}
export default en
diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts
index 4780fe3..056f202 100644
--- a/client/src/i18n/translations/es.ts
+++ b/client/src/i18n/translations/es.ts
@@ -1425,6 +1425,55 @@ const es: Record = {
// Settings (2.6.2)
'settings.currentPasswordRequired': 'La contraseña actual es obligatoria',
'settings.passwordWeak': 'La contraseña debe contener mayúsculas, minúsculas y números',
+
+ // Permissions
+ 'admin.tabs.permissions': 'Permisos',
+ 'perm.title': 'Configuración de permisos',
+ 'perm.subtitle': 'Controla quién puede realizar acciones en la aplicación',
+ 'perm.saved': 'Configuración de permisos guardada',
+ 'perm.resetDefaults': 'Restablecer valores predeterminados',
+ 'perm.customized': 'personalizado',
+ 'perm.level.admin': 'Solo administrador',
+ 'perm.level.tripOwner': 'Propietario del viaje',
+ 'perm.level.tripMember': 'Miembros del viaje',
+ 'perm.level.everybody': 'Todos',
+ 'perm.cat.trip': 'Gestión de viajes',
+ 'perm.cat.members': 'Gestión de miembros',
+ 'perm.cat.files': 'Archivos',
+ 'perm.cat.content': 'Contenido y horario',
+ 'perm.cat.extras': 'Presupuesto, equipaje y colaboración',
+ 'perm.action.trip_create': 'Crear viajes',
+ 'perm.action.trip_edit': 'Editar detalles del viaje',
+ 'perm.action.trip_delete': 'Eliminar viajes',
+ 'perm.action.trip_archive': 'Archivar / desarchivar viajes',
+ 'perm.action.trip_cover_upload': 'Subir imagen de portada',
+ 'perm.action.member_manage': 'Añadir / eliminar miembros',
+ 'perm.action.file_upload': 'Subir archivos',
+ 'perm.action.file_edit': 'Editar metadatos del archivo',
+ 'perm.action.file_delete': 'Eliminar archivos',
+ 'perm.action.place_edit': 'Añadir / editar / eliminar lugares',
+ 'perm.action.day_edit': 'Editar días, notas y asignaciones',
+ 'perm.action.reservation_edit': 'Gestionar reservas',
+ 'perm.action.budget_edit': 'Gestionar presupuesto',
+ 'perm.action.packing_edit': 'Gestionar listas de equipaje',
+ 'perm.action.collab_edit': 'Colaboración (notas, encuestas, chat)',
+ 'perm.action.share_manage': 'Gestionar enlaces compartidos',
+ 'perm.actionHint.trip_create': 'Quién puede crear nuevos viajes',
+ 'perm.actionHint.trip_edit': 'Quién puede cambiar el nombre, fechas, descripción y moneda del viaje',
+ 'perm.actionHint.trip_delete': 'Quién puede eliminar permanentemente un viaje',
+ 'perm.actionHint.trip_archive': 'Quién puede archivar o desarchivar un viaje',
+ 'perm.actionHint.trip_cover_upload': 'Quién puede subir o cambiar la imagen de portada',
+ 'perm.actionHint.member_manage': 'Quién puede invitar o eliminar miembros del viaje',
+ 'perm.actionHint.file_upload': 'Quién puede subir archivos a un viaje',
+ 'perm.actionHint.file_edit': 'Quién puede editar descripciones y enlaces de archivos',
+ 'perm.actionHint.file_delete': 'Quién puede mover archivos a la papelera o eliminarlos permanentemente',
+ 'perm.actionHint.place_edit': 'Quién puede añadir, editar o eliminar lugares',
+ 'perm.actionHint.day_edit': 'Quién puede editar días, notas de días y asignaciones de lugares',
+ 'perm.actionHint.reservation_edit': 'Quién puede crear, editar o eliminar reservas',
+ 'perm.actionHint.budget_edit': 'Quién puede crear, editar o eliminar partidas del presupuesto',
+ 'perm.actionHint.packing_edit': 'Quién puede gestionar artículos de equipaje y bolsas',
+ 'perm.actionHint.collab_edit': 'Quién puede crear notas, encuestas y enviar mensajes',
+ 'perm.actionHint.share_manage': 'Quién puede crear o eliminar enlaces compartidos públicos',
}
export default es
diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts
index 19bb83d..87d97cb 100644
--- a/client/src/i18n/translations/fr.ts
+++ b/client/src/i18n/translations/fr.ts
@@ -1419,6 +1419,55 @@ const fr: Record = {
'collab.polls.options': 'Options',
'collab.polls.delete': 'Supprimer',
'collab.polls.closedSection': 'Fermés',
+
+ // Permissions
+ 'admin.tabs.permissions': 'Permissions',
+ 'perm.title': 'Paramètres des permissions',
+ 'perm.subtitle': 'Contrôlez qui peut effectuer des actions dans l\'application',
+ 'perm.saved': 'Paramètres des permissions enregistrés',
+ 'perm.resetDefaults': 'Réinitialiser par défaut',
+ 'perm.customized': 'personnalisé',
+ 'perm.level.admin': 'Administrateur uniquement',
+ 'perm.level.tripOwner': 'Propriétaire du voyage',
+ 'perm.level.tripMember': 'Membres du voyage',
+ 'perm.level.everybody': 'Tout le monde',
+ 'perm.cat.trip': 'Gestion des voyages',
+ 'perm.cat.members': 'Gestion des membres',
+ 'perm.cat.files': 'Fichiers',
+ 'perm.cat.content': 'Contenu et planning',
+ 'perm.cat.extras': 'Budget, bagages et collaboration',
+ 'perm.action.trip_create': 'Créer des voyages',
+ 'perm.action.trip_edit': 'Modifier les détails du voyage',
+ 'perm.action.trip_delete': 'Supprimer des voyages',
+ 'perm.action.trip_archive': 'Archiver / désarchiver des voyages',
+ 'perm.action.trip_cover_upload': 'Télécharger l\'image de couverture',
+ 'perm.action.member_manage': 'Ajouter / supprimer des membres',
+ 'perm.action.file_upload': 'Télécharger des fichiers',
+ 'perm.action.file_edit': 'Modifier les métadonnées des fichiers',
+ 'perm.action.file_delete': 'Supprimer des fichiers',
+ 'perm.action.place_edit': 'Ajouter / modifier / supprimer des lieux',
+ 'perm.action.day_edit': 'Modifier les jours, notes et affectations',
+ 'perm.action.reservation_edit': 'Gérer les réservations',
+ 'perm.action.budget_edit': 'Gérer le budget',
+ 'perm.action.packing_edit': 'Gérer les listes de bagages',
+ 'perm.action.collab_edit': 'Collaboration (notes, sondages, chat)',
+ 'perm.action.share_manage': 'Gérer les liens de partage',
+ 'perm.actionHint.trip_create': 'Qui peut créer de nouveaux voyages',
+ 'perm.actionHint.trip_edit': 'Qui peut modifier le nom, les dates, la description et la devise du voyage',
+ 'perm.actionHint.trip_delete': 'Qui peut supprimer définitivement un voyage',
+ 'perm.actionHint.trip_archive': 'Qui peut archiver ou désarchiver un voyage',
+ 'perm.actionHint.trip_cover_upload': 'Qui peut télécharger ou modifier l\'image de couverture',
+ 'perm.actionHint.member_manage': 'Qui peut inviter ou supprimer des membres du voyage',
+ 'perm.actionHint.file_upload': 'Qui peut télécharger des fichiers vers un voyage',
+ 'perm.actionHint.file_edit': 'Qui peut modifier les descriptions et liens des fichiers',
+ 'perm.actionHint.file_delete': 'Qui peut déplacer des fichiers vers la corbeille ou les supprimer définitivement',
+ 'perm.actionHint.place_edit': 'Qui peut ajouter, modifier ou supprimer des lieux',
+ 'perm.actionHint.day_edit': 'Qui peut modifier les jours, notes de jours et affectations de lieux',
+ 'perm.actionHint.reservation_edit': 'Qui peut créer, modifier ou supprimer des réservations',
+ 'perm.actionHint.budget_edit': 'Qui peut créer, modifier ou supprimer des éléments de budget',
+ 'perm.actionHint.packing_edit': 'Qui peut gérer les articles de bagages et les sacs',
+ 'perm.actionHint.collab_edit': 'Qui peut créer des notes, des sondages et envoyer des messages',
+ 'perm.actionHint.share_manage': 'Qui peut créer ou supprimer des liens de partage publics',
}
export default fr
diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts
index 83ffbae..b747da7 100644
--- a/client/src/i18n/translations/hu.ts
+++ b/client/src/i18n/translations/hu.ts
@@ -1418,6 +1418,55 @@ const hu: Record = {
'memories.confirmShareTitle': 'Megosztás az utazótársakkal?',
'memories.confirmShareHint': '{count} fotó lesz látható az utazás összes tagja számára. Később egyenként is priváttá teheted őket.',
'memories.confirmShareButton': 'Fotók megosztása',
+
+ // Permissions
+ 'admin.tabs.permissions': 'Jogosultságok',
+ 'perm.title': 'Jogosultsági beállítások',
+ 'perm.subtitle': 'Szabályozd, ki milyen műveleteket végezhet az alkalmazásban',
+ 'perm.saved': 'Jogosultsági beállítások mentve',
+ 'perm.resetDefaults': 'Alapértelmezések visszaállítása',
+ 'perm.customized': 'testreszabott',
+ 'perm.level.admin': 'Csak adminisztrátor',
+ 'perm.level.tripOwner': 'Utazás tulajdonosa',
+ 'perm.level.tripMember': 'Utazás tagjai',
+ 'perm.level.everybody': 'Mindenki',
+ 'perm.cat.trip': 'Utazáskezelés',
+ 'perm.cat.members': 'Tagkezelés',
+ 'perm.cat.files': 'Fájlok',
+ 'perm.cat.content': 'Tartalom és menetrend',
+ 'perm.cat.extras': 'Költségvetés, csomagolás és együttműködés',
+ 'perm.action.trip_create': 'Utazások létrehozása',
+ 'perm.action.trip_edit': 'Utazás részleteinek szerkesztése',
+ 'perm.action.trip_delete': 'Utazások törlése',
+ 'perm.action.trip_archive': 'Utazások archiválása / visszaállítása',
+ 'perm.action.trip_cover_upload': 'Borítókép feltöltése',
+ 'perm.action.member_manage': 'Tagok hozzáadása / eltávolítása',
+ 'perm.action.file_upload': 'Fájlok feltöltése',
+ 'perm.action.file_edit': 'Fájl metaadatok szerkesztése',
+ 'perm.action.file_delete': 'Fájlok törlése',
+ 'perm.action.place_edit': 'Helyek hozzáadása / szerkesztése / törlése',
+ 'perm.action.day_edit': 'Napok, jegyzetek és hozzárendelések szerkesztése',
+ 'perm.action.reservation_edit': 'Foglalások kezelése',
+ 'perm.action.budget_edit': 'Költségvetés kezelése',
+ 'perm.action.packing_edit': 'Csomagolási listák kezelése',
+ 'perm.action.collab_edit': 'Együttműködés (jegyzetek, szavazások, chat)',
+ 'perm.action.share_manage': 'Megosztási linkek kezelése',
+ 'perm.actionHint.trip_create': 'Ki hozhat létre új utazásokat',
+ 'perm.actionHint.trip_edit': 'Ki módosíthatja az utazás nevét, dátumait, leírását és pénznemét',
+ 'perm.actionHint.trip_delete': 'Ki törölhet véglegesen egy utazást',
+ 'perm.actionHint.trip_archive': 'Ki archiválhat vagy állíthat vissza egy utazást',
+ 'perm.actionHint.trip_cover_upload': 'Ki tölthet fel vagy módosíthat borítóképet',
+ 'perm.actionHint.member_manage': 'Ki hívhat meg vagy távolíthat el utazás tagokat',
+ 'perm.actionHint.file_upload': 'Ki tölthet fel fájlokat egy utazáshoz',
+ 'perm.actionHint.file_edit': 'Ki szerkesztheti a fájlok leírásait és linkjeit',
+ 'perm.actionHint.file_delete': 'Ki helyezhet fájlokat a kukába vagy törölheti véglegesen',
+ 'perm.actionHint.place_edit': 'Ki adhat hozzá, szerkeszthet vagy törölhet helyeket',
+ 'perm.actionHint.day_edit': 'Ki szerkesztheti a napokat, napi jegyzeteket és hely-hozzárendeléseket',
+ 'perm.actionHint.reservation_edit': 'Ki hozhat létre, szerkeszthet vagy törölhet foglalásokat',
+ 'perm.actionHint.budget_edit': 'Ki hozhat létre, szerkeszthet vagy törölhet költségvetési tételeket',
+ 'perm.actionHint.packing_edit': 'Ki kezelheti a csomagolási tételeket és táskákat',
+ 'perm.actionHint.collab_edit': 'Ki hozhat létre jegyzeteket, szavazásokat és küldhet üzeneteket',
+ 'perm.actionHint.share_manage': 'Ki hozhat létre vagy törölhet nyilvános megosztási linkeket',
}
export default hu
diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts
index 913219a..160d357 100644
--- a/client/src/i18n/translations/it.ts
+++ b/client/src/i18n/translations/it.ts
@@ -1418,6 +1418,55 @@ const it: Record = {
'collab.polls.options': 'Opzioni',
'collab.polls.delete': 'Elimina',
'collab.polls.closedSection': 'Chiusi',
+
+ // Permissions
+ 'admin.tabs.permissions': 'Permessi',
+ 'perm.title': 'Impostazioni dei permessi',
+ 'perm.subtitle': 'Controlla chi può eseguire azioni nell\'applicazione',
+ 'perm.saved': 'Impostazioni dei permessi salvate',
+ 'perm.resetDefaults': 'Ripristina predefiniti',
+ 'perm.customized': 'personalizzato',
+ 'perm.level.admin': 'Solo amministratore',
+ 'perm.level.tripOwner': 'Proprietario del viaggio',
+ 'perm.level.tripMember': 'Membri del viaggio',
+ 'perm.level.everybody': 'Tutti',
+ 'perm.cat.trip': 'Gestione viaggi',
+ 'perm.cat.members': 'Gestione membri',
+ 'perm.cat.files': 'File',
+ 'perm.cat.content': 'Contenuti e programma',
+ 'perm.cat.extras': 'Budget, bagagli e collaborazione',
+ 'perm.action.trip_create': 'Creare viaggi',
+ 'perm.action.trip_edit': 'Modificare dettagli del viaggio',
+ 'perm.action.trip_delete': 'Eliminare viaggi',
+ 'perm.action.trip_archive': 'Archiviare / dearchiviare viaggi',
+ 'perm.action.trip_cover_upload': 'Caricare immagine di copertina',
+ 'perm.action.member_manage': 'Aggiungere / rimuovere membri',
+ 'perm.action.file_upload': 'Caricare file',
+ 'perm.action.file_edit': 'Modificare metadati dei file',
+ 'perm.action.file_delete': 'Eliminare file',
+ 'perm.action.place_edit': 'Aggiungere / modificare / eliminare luoghi',
+ 'perm.action.day_edit': 'Modificare giorni, note e assegnazioni',
+ 'perm.action.reservation_edit': 'Gestire prenotazioni',
+ 'perm.action.budget_edit': 'Gestire budget',
+ 'perm.action.packing_edit': 'Gestire liste bagagli',
+ 'perm.action.collab_edit': 'Collaborazione (note, sondaggi, chat)',
+ 'perm.action.share_manage': 'Gestire link di condivisione',
+ 'perm.actionHint.trip_create': 'Chi può creare nuovi viaggi',
+ 'perm.actionHint.trip_edit': 'Chi può modificare nome, date, descrizione e valuta del viaggio',
+ 'perm.actionHint.trip_delete': 'Chi può eliminare definitivamente un viaggio',
+ 'perm.actionHint.trip_archive': 'Chi può archiviare o dearchiviare un viaggio',
+ 'perm.actionHint.trip_cover_upload': 'Chi può caricare o modificare l\'immagine di copertina',
+ 'perm.actionHint.member_manage': 'Chi può invitare o rimuovere membri del viaggio',
+ 'perm.actionHint.file_upload': 'Chi può caricare file in un viaggio',
+ 'perm.actionHint.file_edit': 'Chi può modificare descrizioni e link dei file',
+ 'perm.actionHint.file_delete': 'Chi può spostare file nel cestino o eliminarli definitivamente',
+ 'perm.actionHint.place_edit': 'Chi può aggiungere, modificare o eliminare luoghi',
+ 'perm.actionHint.day_edit': 'Chi può modificare giorni, note dei giorni e assegnazioni dei luoghi',
+ 'perm.actionHint.reservation_edit': 'Chi può creare, modificare o eliminare prenotazioni',
+ 'perm.actionHint.budget_edit': 'Chi può creare, modificare o eliminare voci di budget',
+ 'perm.actionHint.packing_edit': 'Chi può gestire articoli da bagaglio e borse',
+ 'perm.actionHint.collab_edit': 'Chi può creare note, sondaggi e inviare messaggi',
+ 'perm.actionHint.share_manage': 'Chi può creare o eliminare link di condivisione pubblici',
}
export default it
\ No newline at end of file
diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts
index 242c841..af09c9c 100644
--- a/client/src/i18n/translations/nl.ts
+++ b/client/src/i18n/translations/nl.ts
@@ -1419,6 +1419,55 @@ const nl: Record = {
'collab.polls.options': 'Opties',
'collab.polls.delete': 'Verwijderen',
'collab.polls.closedSection': 'Gesloten',
+
+ // Permissions
+ 'admin.tabs.permissions': 'Rechten',
+ 'perm.title': 'Rechtinstellingen',
+ 'perm.subtitle': 'Bepaal wie welke acties mag uitvoeren in de applicatie',
+ 'perm.saved': 'Rechtinstellingen opgeslagen',
+ 'perm.resetDefaults': 'Standaardwaarden herstellen',
+ 'perm.customized': 'aangepast',
+ 'perm.level.admin': 'Alleen beheerder',
+ 'perm.level.tripOwner': 'Reiseigenaar',
+ 'perm.level.tripMember': 'Reisleden',
+ 'perm.level.everybody': 'Iedereen',
+ 'perm.cat.trip': 'Reisbeheer',
+ 'perm.cat.members': 'Ledenbeheer',
+ 'perm.cat.files': 'Bestanden',
+ 'perm.cat.content': 'Inhoud & planning',
+ 'perm.cat.extras': 'Budget, paklijsten & samenwerking',
+ 'perm.action.trip_create': 'Reizen aanmaken',
+ 'perm.action.trip_edit': 'Reisdetails bewerken',
+ 'perm.action.trip_delete': 'Reizen verwijderen',
+ 'perm.action.trip_archive': 'Reizen archiveren / dearchiveren',
+ 'perm.action.trip_cover_upload': 'Omslagfoto uploaden',
+ 'perm.action.member_manage': 'Leden toevoegen / verwijderen',
+ 'perm.action.file_upload': 'Bestanden uploaden',
+ 'perm.action.file_edit': 'Bestandsmetadata bewerken',
+ 'perm.action.file_delete': 'Bestanden verwijderen',
+ 'perm.action.place_edit': 'Plaatsen toevoegen / bewerken / verwijderen',
+ 'perm.action.day_edit': 'Dagen, notities & toewijzingen bewerken',
+ 'perm.action.reservation_edit': 'Reserveringen beheren',
+ 'perm.action.budget_edit': 'Budget beheren',
+ 'perm.action.packing_edit': 'Paklijsten beheren',
+ 'perm.action.collab_edit': 'Samenwerking (notities, polls, chat)',
+ 'perm.action.share_manage': 'Deellinks beheren',
+ 'perm.actionHint.trip_create': 'Wie kan nieuwe reizen aanmaken',
+ 'perm.actionHint.trip_edit': 'Wie kan reisnaam, data, beschrijving en valuta wijzigen',
+ 'perm.actionHint.trip_delete': 'Wie kan een reis permanent verwijderen',
+ 'perm.actionHint.trip_archive': 'Wie kan een reis archiveren of dearchiveren',
+ 'perm.actionHint.trip_cover_upload': 'Wie kan de omslagfoto uploaden of wijzigen',
+ 'perm.actionHint.member_manage': 'Wie kan reisleden uitnodigen of verwijderen',
+ 'perm.actionHint.file_upload': 'Wie kan bestanden uploaden naar een reis',
+ 'perm.actionHint.file_edit': 'Wie kan bestandsbeschrijvingen en links bewerken',
+ 'perm.actionHint.file_delete': 'Wie kan bestanden naar de prullenbak verplaatsen of permanent verwijderen',
+ 'perm.actionHint.place_edit': 'Wie kan plaatsen toevoegen, bewerken of verwijderen',
+ 'perm.actionHint.day_edit': 'Wie kan dagen, dagnotities en plaatstoewijzingen bewerken',
+ 'perm.actionHint.reservation_edit': 'Wie kan reserveringen aanmaken, bewerken of verwijderen',
+ 'perm.actionHint.budget_edit': 'Wie kan budgetposten aanmaken, bewerken of verwijderen',
+ 'perm.actionHint.packing_edit': 'Wie kan pakitems en tassen beheren',
+ 'perm.actionHint.collab_edit': 'Wie kan notities, polls aanmaken en berichten versturen',
+ 'perm.actionHint.share_manage': 'Wie kan openbare deellinks aanmaken of verwijderen',
}
export default nl
diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts
index 293a440..7f030da 100644
--- a/client/src/i18n/translations/ru.ts
+++ b/client/src/i18n/translations/ru.ts
@@ -1419,6 +1419,55 @@ const ru: Record = {
'collab.polls.options': 'Варианты',
'collab.polls.delete': 'Удалить',
'collab.polls.closedSection': 'Закрытые',
+
+ // Permissions
+ 'admin.tabs.permissions': 'Разрешения',
+ 'perm.title': 'Настройки разрешений',
+ 'perm.subtitle': 'Управляйте тем, кто может выполнять действия в приложении',
+ 'perm.saved': 'Настройки разрешений сохранены',
+ 'perm.resetDefaults': 'Сбросить по умолчанию',
+ 'perm.customized': 'изменено',
+ 'perm.level.admin': 'Только администратор',
+ 'perm.level.tripOwner': 'Владелец поездки',
+ 'perm.level.tripMember': 'Участники поездки',
+ 'perm.level.everybody': 'Все',
+ 'perm.cat.trip': 'Управление поездками',
+ 'perm.cat.members': 'Управление участниками',
+ 'perm.cat.files': 'Файлы',
+ 'perm.cat.content': 'Контент и расписание',
+ 'perm.cat.extras': 'Бюджет, сборы и совместная работа',
+ 'perm.action.trip_create': 'Создавать поездки',
+ 'perm.action.trip_edit': 'Редактировать детали поездки',
+ 'perm.action.trip_delete': 'Удалять поездки',
+ 'perm.action.trip_archive': 'Архивировать / разархивировать поездки',
+ 'perm.action.trip_cover_upload': 'Загружать обложку',
+ 'perm.action.member_manage': 'Добавлять / удалять участников',
+ 'perm.action.file_upload': 'Загружать файлы',
+ 'perm.action.file_edit': 'Редактировать метаданные файлов',
+ 'perm.action.file_delete': 'Удалять файлы',
+ 'perm.action.place_edit': 'Добавлять / редактировать / удалять места',
+ 'perm.action.day_edit': 'Редактировать дни, заметки и назначения',
+ 'perm.action.reservation_edit': 'Управлять бронированиями',
+ 'perm.action.budget_edit': 'Управлять бюджетом',
+ 'perm.action.packing_edit': 'Управлять списками вещей',
+ 'perm.action.collab_edit': 'Совместная работа (заметки, опросы, чат)',
+ 'perm.action.share_manage': 'Управлять ссылками для обмена',
+ 'perm.actionHint.trip_create': 'Кто может создавать новые поездки',
+ 'perm.actionHint.trip_edit': 'Кто может менять название, даты, описание и валюту поездки',
+ 'perm.actionHint.trip_delete': 'Кто может безвозвратно удалить поездку',
+ 'perm.actionHint.trip_archive': 'Кто может архивировать или разархивировать поездку',
+ 'perm.actionHint.trip_cover_upload': 'Кто может загружать или менять обложку',
+ 'perm.actionHint.member_manage': 'Кто может приглашать или удалять участников поездки',
+ 'perm.actionHint.file_upload': 'Кто может загружать файлы в поездку',
+ 'perm.actionHint.file_edit': 'Кто может редактировать описания и ссылки файлов',
+ 'perm.actionHint.file_delete': 'Кто может перемещать файлы в корзину или безвозвратно удалять',
+ 'perm.actionHint.place_edit': 'Кто может добавлять, редактировать или удалять места',
+ 'perm.actionHint.day_edit': 'Кто может редактировать дни, заметки к дням и назначения мест',
+ 'perm.actionHint.reservation_edit': 'Кто может создавать, редактировать или удалять бронирования',
+ 'perm.actionHint.budget_edit': 'Кто может создавать, редактировать или удалять статьи бюджета',
+ 'perm.actionHint.packing_edit': 'Кто может управлять вещами для сборов и сумками',
+ 'perm.actionHint.collab_edit': 'Кто может создавать заметки, опросы и отправлять сообщения',
+ 'perm.actionHint.share_manage': 'Кто может создавать или удалять публичные ссылки для обмена',
}
export default ru
diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts
index be146a7..5c5d646 100644
--- a/client/src/i18n/translations/zh.ts
+++ b/client/src/i18n/translations/zh.ts
@@ -1419,6 +1419,55 @@ const zh: Record = {
'collab.polls.options': '选项',
'collab.polls.delete': '删除',
'collab.polls.closedSection': '已关闭',
+
+ // Permissions
+ 'admin.tabs.permissions': '权限',
+ 'perm.title': '权限设置',
+ 'perm.subtitle': '控制谁可以在应用中执行操作',
+ 'perm.saved': '权限设置已保存',
+ 'perm.resetDefaults': '恢复默认',
+ 'perm.customized': '已自定义',
+ 'perm.level.admin': '仅管理员',
+ 'perm.level.tripOwner': '旅行所有者',
+ 'perm.level.tripMember': '旅行成员',
+ 'perm.level.everybody': '所有人',
+ 'perm.cat.trip': '旅行管理',
+ 'perm.cat.members': '成员管理',
+ 'perm.cat.files': '文件',
+ 'perm.cat.content': '内容与日程',
+ 'perm.cat.extras': '预算、行李与协作',
+ 'perm.action.trip_create': '创建旅行',
+ 'perm.action.trip_edit': '编辑旅行详情',
+ 'perm.action.trip_delete': '删除旅行',
+ 'perm.action.trip_archive': '归档 / 取消归档旅行',
+ 'perm.action.trip_cover_upload': '上传封面图片',
+ 'perm.action.member_manage': '添加 / 移除成员',
+ 'perm.action.file_upload': '上传文件',
+ 'perm.action.file_edit': '编辑文件元数据',
+ 'perm.action.file_delete': '删除文件',
+ 'perm.action.place_edit': '添加 / 编辑 / 删除地点',
+ 'perm.action.day_edit': '编辑日程、备注与分配',
+ 'perm.action.reservation_edit': '管理预订',
+ 'perm.action.budget_edit': '管理预算',
+ 'perm.action.packing_edit': '管理行李清单',
+ 'perm.action.collab_edit': '协作(笔记、投票、聊天)',
+ 'perm.action.share_manage': '管理分享链接',
+ 'perm.actionHint.trip_create': '谁可以创建新旅行',
+ 'perm.actionHint.trip_edit': '谁可以更改旅行名称、日期、描述和货币',
+ 'perm.actionHint.trip_delete': '谁可以永久删除旅行',
+ 'perm.actionHint.trip_archive': '谁可以归档或取消归档旅行',
+ 'perm.actionHint.trip_cover_upload': '谁可以上传或更改封面图片',
+ 'perm.actionHint.member_manage': '谁可以邀请或移除旅行成员',
+ 'perm.actionHint.file_upload': '谁可以向旅行上传文件',
+ 'perm.actionHint.file_edit': '谁可以编辑文件描述和链接',
+ 'perm.actionHint.file_delete': '谁可以将文件移至回收站或永久删除',
+ 'perm.actionHint.place_edit': '谁可以添加、编辑或删除地点',
+ 'perm.actionHint.day_edit': '谁可以编辑日程、日程备注和地点分配',
+ 'perm.actionHint.reservation_edit': '谁可以创建、编辑或删除预订',
+ 'perm.actionHint.budget_edit': '谁可以创建、编辑或删除预算项目',
+ 'perm.actionHint.packing_edit': '谁可以管理行李物品和包袋',
+ 'perm.actionHint.collab_edit': '谁可以创建笔记、投票和发送消息',
+ 'perm.actionHint.share_manage': '谁可以创建或删除公开分享链接',
}
export default zh
diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx
index 37d4e36..b1c44db 100644
--- a/client/src/pages/AdminPage.tsx
+++ b/client/src/pages/AdminPage.tsx
@@ -15,6 +15,7 @@ import AddonManager from '../components/Admin/AddonManager'
import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
import AuditLogPanel from '../components/Admin/AuditLogPanel'
import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
+import PermissionsPanel from '../components/Admin/PermissionsPanel'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react'
import CustomSelect from '../components/shared/CustomSelect'
@@ -61,6 +62,7 @@ export default function AdminPage(): React.ReactElement {
{ id: 'users', label: t('admin.tabs.users') },
{ id: 'config', label: t('admin.tabs.config') },
{ id: 'addons', label: t('admin.tabs.addons') },
+ { id: 'permissions', label: t('admin.tabs.permissions') },
{ id: 'settings', label: t('admin.tabs.settings') },
{ id: 'backup', label: t('admin.tabs.backup') },
{ id: 'audit', label: t('admin.tabs.audit') },
@@ -1153,6 +1155,8 @@ export default function AdminPage(): React.ReactElement {
)}
+ {activeTab === 'permissions' &&
}
+
{activeTab === 'backup' &&
}
{activeTab === 'audit' &&
}
diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx
index 6206fbc..d73f590 100644
--- a/client/src/pages/DashboardPage.tsx
+++ b/client/src/pages/DashboardPage.tsx
@@ -17,6 +17,7 @@ import {
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft,
LayoutGrid, List,
} from 'lucide-react'
+import { useCanDo } from '../store/permissionsStore'
interface DashboardTrip {
id: number
@@ -139,9 +140,9 @@ function LiquidGlass({ children, dark, style, className = '', onClick }: LiquidG
// ── Spotlight Card (next upcoming trip) ─────────────────────────────────────
interface TripCardProps {
trip: DashboardTrip
- onEdit: (trip: DashboardTrip) => void
- onDelete: (trip: DashboardTrip) => void
- onArchive: (id: number) => void
+ onEdit?: (trip: DashboardTrip) => void
+ onDelete?: (trip: DashboardTrip) => void
+ onArchive?: (id: number) => void
onClick: (trip: DashboardTrip) => void
t: (key: string, params?: Record
) => string
locale: string
@@ -188,12 +189,12 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
{/* Top-right actions */}
- {(!!trip.is_owner || isAdmin) && (
+ {(onEdit || onArchive || onDelete) && (
e.stopPropagation()}>
-
onEdit(trip)} title={t('common.edit')}>
-
onArchive(trip.id)} title={t('dashboard.archive')}>
-
onDelete(trip)} title={t('common.delete')} danger>
+ {onEdit &&
onEdit(trip)} title={t('common.edit')}>}
+ {onArchive &&
onArchive(trip.id)} title={t('dashboard.archive')}>}
+ {onDelete &&
onDelete(trip)} title={t('common.delete')} danger>}
)}
@@ -309,12 +310,12 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, isAdm
- {(!!trip.is_owner || isAdmin) && (
+ {(onEdit || onArchive || onDelete) && (
e.stopPropagation()}>
-
onEdit(trip)} icon={} label={t('common.edit')} />
- onArchive(trip.id)} icon={} label={t('dashboard.archive')} />
- onDelete(trip)} icon={} label={t('common.delete')} danger />
+ {onEdit && onEdit(trip)} icon={} label={t('common.edit')} />}
+ {onArchive && onArchive(trip.id)} icon={} label={t('dashboard.archive')} />}
+ {onDelete && onDelete(trip)} icon={} label={t('common.delete')} danger />}
)}
@@ -411,9 +412,9 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale, i
{/* Actions */}
{(!!trip.is_owner || isAdmin) && (
e.stopPropagation()}>
-
onEdit(trip)} icon={} label="" />
- onArchive(trip.id)} icon={} label="" />
- onDelete(trip)} icon={} label="" danger />
+ {onEdit && onEdit(trip)} icon={} label="" />}
+ {onArchive && onArchive(trip.id)} icon={} label="" />}
+ {onDelete && onDelete(trip)} icon={} label="" danger />}
)}
@@ -423,9 +424,9 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale, i
// ── Archived Trip Row ────────────────────────────────────────────────────────
interface ArchivedRowProps {
trip: DashboardTrip
- onEdit: (trip: DashboardTrip) => void
- onUnarchive: (id: number) => void
- onDelete: (trip: DashboardTrip) => void
+ onEdit?: (trip: DashboardTrip) => void
+ onUnarchive?: (id: number) => void
+ onDelete?: (trip: DashboardTrip) => void
onClick: (trip: DashboardTrip) => void
t: (key: string, params?: Record) => string
locale: string
@@ -460,16 +461,16 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale,
{(!!trip.is_owner || isAdmin) && (
e.stopPropagation()}>
-
}
)}
@@ -554,6 +555,7 @@ export default function DashboardPage(): React.ReactElement {
const { demoMode, user } = useAuthStore()
const isAdmin = user?.role === 'admin'
const { settings, updateSetting } = useSettingsStore()
+ const can = useCanDo()
const dm = settings.dark_mode
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const showCurrency = settings.dashboard_currency !== 'off'
@@ -681,7 +683,7 @@ export default function DashboardPage(): React.ReactElement {
title={viewMode === 'grid' ? t('dashboard.listView') : t('dashboard.gridView')}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
- padding: '0 14px',
+ padding: '0 14px', height: 37,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
transition: 'background 0.15s, border-color 0.15s',
@@ -696,7 +698,7 @@ export default function DashboardPage(): React.ReactElement {
onClick={() => setShowWidgetSettings(s => s ? false : true)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
- padding: '0 14px',
+ padding: '0 14px', height: 37,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
transition: 'background 0.15s, border-color 0.15s',
@@ -706,7 +708,7 @@ export default function DashboardPage(): React.ReactElement {
>
- { setEditingTrip(null); setShowForm(true) }}
style={{
display: 'flex', alignItems: 'center', gap: 7, padding: '9px 18px',
@@ -718,7 +720,7 @@ export default function DashboardPage(): React.ReactElement {
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
{t('dashboard.newTrip')}
-
+ }
@@ -783,12 +785,12 @@ export default function DashboardPage(): React.ReactElement {
{t('dashboard.emptyText')}
- { setEditingTrip(null); setShowForm(true) }}
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 22px', background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('dashboard.emptyButton')}
-
+ }
)}
@@ -797,9 +799,9 @@ export default function DashboardPage(): React.ReactElement {
{ setEditingTrip(tr); setShowForm(true) }}
- onDelete={handleDelete}
- onArchive={handleArchive}
+ onEdit={(can('trip_edit', spotlight) || can('trip_cover_upload', spotlight)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
+ onDelete={can('trip_delete', spotlight) ? handleDelete : undefined}
+ onArchive={can('trip_archive', spotlight) ? handleArchive : undefined}
onClick={tr => navigate(`/trips/${tr.id}`)}
/>
)}
@@ -813,9 +815,9 @@ export default function DashboardPage(): React.ReactElement {
key={trip.id}
trip={trip}
t={t} locale={locale} isAdmin={isAdmin}
- onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
- onDelete={handleDelete}
- onArchive={handleArchive}
+ onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
+ onDelete={can('trip_delete', trip) ? handleDelete : undefined}
+ onArchive={can('trip_archive', trip) ? handleArchive : undefined}
onClick={tr => navigate(`/trips/${tr.id}`)}
/>
))}
@@ -827,9 +829,9 @@ export default function DashboardPage(): React.ReactElement {
key={trip.id}
trip={trip}
t={t} locale={locale} isAdmin={isAdmin}
- onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
- onDelete={handleDelete}
- onArchive={handleArchive}
+ onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
+ onDelete={can('trip_delete', trip) ? handleDelete : undefined}
+ onArchive={can('trip_archive', trip) ? handleArchive : undefined}
onClick={tr => navigate(`/trips/${tr.id}`)}
/>
))}
@@ -857,9 +859,9 @@ export default function DashboardPage(): React.ReactElement {
key={trip.id}
trip={trip}
t={t} locale={locale} isAdmin={isAdmin}
- onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
- onUnarchive={handleUnarchive}
- onDelete={handleDelete}
+ onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
+ onUnarchive={can('trip_archive', trip) ? handleUnarchive : undefined}
+ onDelete={can('trip_delete', trip) ? handleDelete : undefined}
onClick={tr => navigate(`/trips/${tr.id}`)}
/>
))}
diff --git a/client/src/store/permissionsStore.ts b/client/src/store/permissionsStore.ts
new file mode 100644
index 0000000..20fd83b
--- /dev/null
+++ b/client/src/store/permissionsStore.ts
@@ -0,0 +1,52 @@
+import { create } from 'zustand'
+import { useAuthStore } from './authStore'
+
+export type PermissionLevel = 'admin' | 'trip_owner' | 'trip_member' | 'everybody'
+
+/** Minimal trip shape used by permission checks — accepts both Trip and DashboardTrip */
+type TripOwnerContext = { user_id?: unknown; owner_id?: unknown; is_owner?: unknown }
+
+interface PermissionsState {
+ permissions: Record
+ setPermissions: (perms: Record) => void
+}
+
+export const usePermissionsStore = create((set) => ({
+ permissions: {},
+ setPermissions: (perms) => set({ permissions: perms }),
+}))
+
+/**
+ * Hook that returns a permission checker bound to the current user.
+ * Usage: const can = useCanDo(); can('trip_create') or can('file_upload', trip)
+ */
+export function useCanDo() {
+ const perms = usePermissionsStore((s: PermissionsState) => s.permissions)
+ const user = useAuthStore((s) => s.user)
+
+ return function can(
+ actionKey: string,
+ trip?: TripOwnerContext | null,
+ ): boolean {
+ if (!user) return false
+ if (user.role === 'admin') return true
+
+ const level = perms[actionKey]
+ if (!level) return true // not configured = allow
+
+ // Support both Trip (owner_id) and DashboardTrip/server response (user_id)
+ const tripOwnerId = (trip?.user_id as number | undefined) ?? (trip?.owner_id as number | undefined) ?? null
+ const isOwnerFlag = trip?.is_owner === true || trip?.is_owner === 1
+ const isOwner = isOwnerFlag || (tripOwnerId !== null && tripOwnerId === user.id)
+ const isMember = !isOwner && trip != null
+
+ switch (level) {
+ case 'admin': return false
+ case 'trip_owner': return isOwner
+ case 'trip_member': return isOwner || isMember
+ case 'everybody': return true
+ default: return false
+ }
+ }
+}
+
diff --git a/server/src/db/database.ts b/server/src/db/database.ts
index 0b59233..58f76c1 100644
--- a/server/src/db/database.ts
+++ b/server/src/db/database.ts
@@ -128,4 +128,9 @@ function isOwner(tripId: number | string, userId: number): boolean {
return !!_db!.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
}
-export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner };
+function getTripOwnerId(tripId: number | string): number | undefined {
+ const row = _db!.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined;
+ return row?.user_id;
+}
+
+export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner, getTripOwnerId };
diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts
index 0ae2370..1988680 100644
--- a/server/src/routes/admin.ts
+++ b/server/src/routes/admin.ts
@@ -8,6 +8,7 @@ import { db } from '../db/database';
import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest, User, Addon } from '../types';
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
+import { getAllPermissions, savePermissions, PERMISSION_ACTIONS } from '../services/permissions';
import { revokeUserSessions } from '../mcp';
const router = express.Router();
@@ -158,6 +159,35 @@ router.get('/stats', (_req: Request, res: Response) => {
res.json({ totalUsers, totalTrips, totalPlaces, totalFiles });
});
+// Permissions management
+router.get('/permissions', (_req: Request, res: Response) => {
+ const current = getAllPermissions();
+ const actions = PERMISSION_ACTIONS.map(a => ({
+ key: a.key,
+ level: current[a.key],
+ defaultLevel: a.defaultLevel,
+ allowedLevels: a.allowedLevels,
+ }));
+ res.json({ permissions: actions });
+});
+
+router.put('/permissions', (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { permissions } = req.body;
+ if (!permissions || typeof permissions !== 'object') {
+ return res.status(400).json({ error: 'permissions object required' });
+ }
+ savePermissions(permissions);
+ writeAudit({
+ userId: authReq.user.id,
+ action: 'admin.permissions_update',
+ resource: 'permissions',
+ ip: getClientIp(req),
+ details: permissions,
+ });
+ res.json({ success: true, permissions: getAllPermissions() });
+});
+
router.get('/audit-log', (req: Request, res: Response) => {
const limitRaw = parseInt(String(req.query.limit || '100'), 10);
const offsetRaw = parseInt(String(req.query.offset || '0'), 10);
diff --git a/server/src/routes/assignments.ts b/server/src/routes/assignments.ts
index cb2d9a9..b2cf887 100644
--- a/server/src/routes/assignments.ts
+++ b/server/src/routes/assignments.ts
@@ -4,6 +4,7 @@ import { authenticate } from '../middleware/auth';
import { requireTripAccess } from '../middleware/tripAccess';
import { broadcast } from '../websocket';
import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from '../services/queryHelpers';
+import { checkPermission } from '../services/permissions';
import { AuthRequest, AssignmentRow, DayAssignment, Tag, Participant } from '../types';
const router = express.Router({ mergeParams: true });
@@ -110,6 +111,10 @@ router.get('/trips/:tripId/days/:dayId/assignments', authenticate, requireTripAc
});
router.post('/trips/:tripId/days/:dayId/assignments', authenticate, requireTripAccess, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const { tripId, dayId } = req.params;
const { place_id, notes } = req.body;
@@ -132,6 +137,10 @@ router.post('/trips/:tripId/days/:dayId/assignments', authenticate, requireTripA
});
router.delete('/trips/:tripId/days/:dayId/assignments/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const { tripId, dayId, id } = req.params;
const assignment = db.prepare(
@@ -146,6 +155,10 @@ router.delete('/trips/:tripId/days/:dayId/assignments/:id', authenticate, requir
});
router.put('/trips/:tripId/days/:dayId/assignments/reorder', authenticate, requireTripAccess, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const { tripId, dayId } = req.params;
const { orderedIds } = req.body;
@@ -168,6 +181,10 @@ router.put('/trips/:tripId/days/:dayId/assignments/reorder', authenticate, requi
});
router.put('/trips/:tripId/assignments/:id/move', authenticate, requireTripAccess, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const { tripId, id } = req.params;
const { new_day_id, order_index } = req.body;
@@ -204,6 +221,10 @@ router.get('/trips/:tripId/assignments/:id/participants', authenticate, requireT
});
router.put('/trips/:tripId/assignments/:id/time', authenticate, requireTripAccess, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const { tripId, id } = req.params;
const assignment = db.prepare(`
@@ -223,6 +244,10 @@ router.put('/trips/:tripId/assignments/:id/time', authenticate, requireTripAcces
});
router.put('/trips/:tripId/assignments/:id/participants', authenticate, requireTripAccess, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const { tripId, id } = req.params;
const { user_ids } = req.body;
diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts
index 86e44ad..abb871d 100644
--- a/server/src/routes/auth.ts
+++ b/server/src/routes/auth.ts
@@ -13,6 +13,7 @@ import { db } from '../db/database';
import { authenticate, demoUploadBlock } from '../middleware/auth';
import { JWT_SECRET } from '../config';
import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto';
+import { getAllPermissions } from '../services/permissions';
import { randomBytes, createHash } from 'crypto';
import { revokeUserSessions } from '../mcp';
import { AuthRequest, User } from '../types';
@@ -209,6 +210,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
notification_channel: notifChannel,
trip_reminders_enabled: tripRemindersEnabled,
+ permissions: getAllPermissions(),
});
});
diff --git a/server/src/routes/budget.ts b/server/src/routes/budget.ts
index 410f62b..621b958 100644
--- a/server/src/routes/budget.ts
+++ b/server/src/routes/budget.ts
@@ -1,7 +1,8 @@
import express, { Request, Response } from 'express';
-import { db, canAccessTrip } from '../db/database';
+import { db, canAccessTrip, getTripOwnerId } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
+import { checkPermission } from '../services/permissions';
import { AuthRequest, BudgetItem, BudgetItemMember } from '../types';
const router = express.Router({ mergeParams: true });
@@ -83,6 +84,11 @@ router.post('/', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('budget_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
if (!name) return res.status(400).json({ error: 'Name is required' });
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?').get(tripId) as { max: number | null };
@@ -115,6 +121,11 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('budget_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Budget item not found' });
@@ -150,6 +161,11 @@ router.put('/:id/members', authenticate, (req: Request, res: Response) => {
const { tripId, id } = req.params;
if (!canAccessTrip(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('budget_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Budget item not found' });
@@ -180,6 +196,11 @@ router.put('/:id/members/:userId/paid', authenticate, (req: Request, res: Respon
const { tripId, id, userId } = req.params;
if (!canAccessTrip(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('budget_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const { paid } = req.body;
db.prepare('UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?')
.run(paid ? 1 : 0, id, userId);
@@ -273,6 +294,11 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('budget_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Budget item not found' });
diff --git a/server/src/routes/collab.ts b/server/src/routes/collab.ts
index 7a61178..a51a23b 100644
--- a/server/src/routes/collab.ts
+++ b/server/src/routes/collab.ts
@@ -3,10 +3,11 @@ import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
-import { db, canAccessTrip } from '../db/database';
+import { db, canAccessTrip, getTripOwnerId } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { validateStringLengths } from '../middleware/validate';
+import { checkPermission } from '../services/permissions';
import { AuthRequest, CollabNote, CollabPoll, CollabMessage, TripFile } from '../types';
interface ReactionRow {
@@ -112,6 +113,10 @@ router.post('/notes', authenticate, (req: Request, res: Response) => {
const { tripId } = req.params;
const { title, content, category, color, website } = req.body;
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
if (!title) return res.status(400).json({ error: 'Title is required' });
const result = db.prepare(`
@@ -138,6 +143,10 @@ router.put('/notes/:id', authenticate, (req: Request, res: Response) => {
const { tripId, id } = req.params;
const { title, content, category, color, pinned, website } = req.body;
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
const existing = db.prepare('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!existing) return res.status(404).json({ error: 'Note not found' });
@@ -175,6 +184,10 @@ router.delete('/notes/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
const existing = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!existing) return res.status(404).json({ error: 'Note not found' });
@@ -195,6 +208,10 @@ router.post('/notes/:id/files', authenticate, noteUpload.single('file'), (req: R
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
if (!verifyTripAccess(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const note = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
@@ -213,6 +230,10 @@ router.delete('/notes/:id/files/:fileId', authenticate, (req: Request, res: Resp
const authReq = req as AuthRequest;
const { tripId, id, fileId } = req.params;
if (!verifyTripAccess(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND note_id = ?').get(fileId, id) as TripFile | undefined;
if (!file) return res.status(404).json({ error: 'File not found' });
@@ -278,6 +299,10 @@ router.post('/polls', authenticate, (req: Request, res: Response) => {
const { tripId } = req.params;
const { question, options, multiple, multiple_choice, deadline } = req.body;
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
if (!question) return res.status(400).json({ error: 'Question is required' });
if (!Array.isArray(options) || options.length < 2) {
return res.status(400).json({ error: 'At least 2 options are required' });
@@ -300,6 +325,10 @@ router.post('/polls/:id/vote', authenticate, (req: Request, res: Response) => {
const { tripId, id } = req.params;
const { option_index } = req.body;
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId) as CollabPoll | undefined;
if (!poll) return res.status(404).json({ error: 'Poll not found' });
@@ -332,6 +361,10 @@ router.put('/polls/:id/close', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
@@ -347,6 +380,10 @@ router.delete('/polls/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
const poll = db.prepare('SELECT id FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
@@ -401,6 +438,10 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r
const { tripId } = req.params;
const { text, reply_to } = req.body;
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
if (!text || !text.trim()) return res.status(400).json({ error: 'Message text is required' });
if (reply_to) {
@@ -439,6 +480,10 @@ router.post('/messages/:id/react', authenticate, (req: Request, res: Response) =
const { tripId, id } = req.params;
const { emoji } = req.body;
if (!verifyTripAccess(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
if (!emoji) return res.status(400).json({ error: 'Emoji is required' });
const msg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(id, tripId);
@@ -460,6 +505,10 @@ router.delete('/messages/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
const message = db.prepare('SELECT * FROM collab_messages WHERE id = ? AND trip_id = ?').get(id, tripId) as CollabMessage | undefined;
if (!message) return res.status(404).json({ error: 'Message not found' });
diff --git a/server/src/routes/dayNotes.ts b/server/src/routes/dayNotes.ts
index 1605c8e..bcba782 100644
--- a/server/src/routes/dayNotes.ts
+++ b/server/src/routes/dayNotes.ts
@@ -1,8 +1,9 @@
import express, { Request, Response } from 'express';
-import { db, canAccessTrip } from '../db/database';
+import { db, canAccessTrip, getTripOwnerId } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { validateStringLengths } from '../middleware/validate';
+import { checkPermission } from '../services/permissions';
import { AuthRequest, DayNote } from '../types';
const router = express.Router({ mergeParams: true });
@@ -28,6 +29,11 @@ router.post('/', authenticate, validateStringLengths({ text: 500, time: 150 }),
const { tripId, dayId } = req.params;
if (!verifyAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('day_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
if (!day) return res.status(404).json({ error: 'Day not found' });
@@ -48,6 +54,11 @@ router.put('/:id', authenticate, validateStringLengths({ text: 500, time: 150 })
const { tripId, dayId, id } = req.params;
if (!verifyAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('day_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const note = db.prepare('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId) as DayNote | undefined;
if (!note) return res.status(404).json({ error: 'Note not found' });
@@ -72,6 +83,11 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const { tripId, dayId, id } = req.params;
if (!verifyAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('day_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const note = db.prepare('SELECT id FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId);
if (!note) return res.status(404).json({ error: 'Note not found' });
diff --git a/server/src/routes/days.ts b/server/src/routes/days.ts
index c59e1b7..469b47e 100644
--- a/server/src/routes/days.ts
+++ b/server/src/routes/days.ts
@@ -4,6 +4,7 @@ import { authenticate } from '../middleware/auth';
import { requireTripAccess } from '../middleware/tripAccess';
import { broadcast } from '../websocket';
import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from '../services/queryHelpers';
+import { checkPermission } from '../services/permissions';
import { AuthRequest, AssignmentRow, Day, DayNote } from '../types';
const router = express.Router({ mergeParams: true });
@@ -126,6 +127,10 @@ router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) =
});
router.post('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const { tripId } = req.params;
const { date, notes } = req.body;
@@ -144,6 +149,10 @@ router.post('/', authenticate, requireTripAccess, (req: Request, res: Response)
});
router.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const { tripId, id } = req.params;
const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId) as Day | undefined;
@@ -161,6 +170,10 @@ router.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response
});
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const { tripId, id } = req.params;
const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId);
@@ -199,6 +212,10 @@ accommodationsRouter.get('/', authenticate, requireTripAccess, (req: Request, re
});
accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const { tripId } = req.params;
const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = req.body;
@@ -243,6 +260,10 @@ accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, r
});
accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const { tripId, id } = req.params;
interface DayAccommodation { id: number; trip_id: number; place_id: number; start_day_id: number; end_day_id: number; check_in: string | null; check_out: string | null; confirmation: string | null; notes: string | null; }
@@ -294,6 +315,10 @@ accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request,
});
accommodationsRouter.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const { tripId, id } = req.params;
const existing = db.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(id, tripId);
diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts
index 1658b26..fdf2912 100644
--- a/server/src/routes/files.ts
+++ b/server/src/routes/files.ts
@@ -10,6 +10,7 @@ import { authenticate, demoUploadBlock } from '../middleware/auth';
import { requireTripAccess } from '../middleware/tripAccess';
import { broadcast } from '../websocket';
import { AuthRequest, TripFile } from '../types';
+import { checkPermission } from '../services/permissions';
const router = express.Router({ mergeParams: true });
@@ -157,6 +158,9 @@ router.get('/', authenticate, (req: Request, res: Response) => {
router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single('file'), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
+ const { user_id: tripOwnerId } = authReq.trip!;
+ if (!checkPermission('file_upload', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission to upload files' });
const { place_id, description, reservation_id } = req.body;
if (!req.file) {
@@ -189,8 +193,10 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const { tripId, id } = req.params;
const { description, place_id, reservation_id } = req.body;
- const trip = verifyTripOwnership(tripId, authReq.user.id);
- if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const access = canAccessTrip(tripId, authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('file_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission to edit files' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined;
if (!file) return res.status(404).json({ error: 'File not found' });
@@ -237,8 +243,10 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
- const trip = verifyTripOwnership(tripId, authReq.user.id);
- if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const access = canAccessTrip(tripId, authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('file_delete', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission to delete files' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined;
if (!file) return res.status(404).json({ error: 'File not found' });
diff --git a/server/src/routes/packing.ts b/server/src/routes/packing.ts
index 0b84d8b..aceb855 100644
--- a/server/src/routes/packing.ts
+++ b/server/src/routes/packing.ts
@@ -1,7 +1,8 @@
import express, { Request, Response } from 'express';
-import { db, canAccessTrip } from '../db/database';
+import { db, canAccessTrip, getTripOwnerId } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
+import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
const router = express.Router({ mergeParams: true });
@@ -33,6 +34,11 @@ router.post('/import', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('packing_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
if (!Array.isArray(items) || items.length === 0) return res.status(400).json({ error: 'items must be a non-empty array' });
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
@@ -79,6 +85,11 @@ router.post('/', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('packing_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
if (!name) return res.status(400).json({ error: 'Item name is required' });
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
@@ -101,6 +112,11 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('packing_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const item = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Item not found' });
@@ -136,6 +152,11 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('packing_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Item not found' });
@@ -161,6 +182,10 @@ router.post('/bags', authenticate, (req: Request, res: Response) => {
const { name, color } = req.body;
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('packing_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_bags WHERE trip_id = ?').get(tripId) as { max: number | null };
const result = db.prepare('INSERT INTO packing_bags (trip_id, name, color, sort_order) VALUES (?, ?, ?, ?)').run(tripId, name.trim(), color || '#6366f1', (maxOrder.max ?? -1) + 1);
@@ -175,6 +200,10 @@ router.put('/bags/:bagId', authenticate, (req: Request, res: Response) => {
const { name, color, weight_limit_grams } = req.body;
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('packing_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?').get(bagId, tripId);
if (!bag) return res.status(404).json({ error: 'Bag not found' });
db.prepare('UPDATE packing_bags SET name = COALESCE(?, name), color = COALESCE(?, color), weight_limit_grams = ? WHERE id = ?').run(name?.trim() || null, color || null, weight_limit_grams ?? null, bagId);
@@ -188,6 +217,10 @@ router.delete('/bags/:bagId', authenticate, (req: Request, res: Response) => {
const { tripId, bagId } = req.params;
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('packing_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?').get(bagId, tripId);
if (!bag) return res.status(404).json({ error: 'Bag not found' });
db.prepare('DELETE FROM packing_bags WHERE id = ?').run(bagId);
@@ -204,6 +237,11 @@ router.post('/apply-template/:templateId', authenticate, (req: Request, res: Res
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('packing_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const templateItems = db.prepare(`
SELECT ti.name, tc.name as category
FROM packing_template_items ti
@@ -261,6 +299,11 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('packing_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const cat = decodeURIComponent(categoryName);
db.prepare('DELETE FROM packing_category_assignees WHERE trip_id = ? AND category_name = ?').run(tripId, cat);
@@ -300,6 +343,11 @@ router.put('/reorder', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('packing_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const update = db.prepare('UPDATE packing_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
const updateMany = db.transaction((ids: number[]) => {
ids.forEach((id, index) => {
diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts
index 1463f6f..2f3b214 100644
--- a/server/src/routes/places.ts
+++ b/server/src/routes/places.ts
@@ -7,6 +7,7 @@ import { requireTripAccess } from '../middleware/tripAccess';
import { broadcast } from '../websocket';
import { loadTagsByPlaceIds } from '../services/queryHelpers';
import { validateStringLengths } from '../middleware/validate';
+import { checkPermission } from '../services/permissions';
import { AuthRequest, Place } from '../types';
const gpxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
@@ -76,7 +77,11 @@ router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) =
});
router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
- const { tripId } = req.params
+ const authReq = req as AuthRequest;
+ if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
+ const { tripId } = req.params
const {
name, description, lat, lng, address, category_id, price, currency,
@@ -117,6 +122,10 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
// Import places from GPX file with full track geometry (must be before /:id)
router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('file'), (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const { tripId } = req.params;
const file = (req as any).file;
if (!file) return res.status(400).json({ error: 'No file uploaded' });
@@ -259,7 +268,11 @@ router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, r
});
router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
- const { tripId, id } = req.params
+ const authReq = req as AuthRequest;
+ if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
+ const { tripId, id } = req.params
const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined;
if (!existingPlace) {
@@ -331,7 +344,11 @@ router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name
});
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
- const { tripId, id } = req.params
+ const authReq = req as AuthRequest;
+ if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
+ const { tripId, id } = req.params
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!place) {
diff --git a/server/src/routes/reservations.ts b/server/src/routes/reservations.ts
index 5bd4625..215d891 100644
--- a/server/src/routes/reservations.ts
+++ b/server/src/routes/reservations.ts
@@ -1,7 +1,8 @@
import express, { Request, Response } from 'express';
-import { db, canAccessTrip } from '../db/database';
+import { db, canAccessTrip, getTripOwnerId } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
+import { checkPermission } from '../services/permissions';
import { AuthRequest, Reservation } from '../types';
const router = express.Router({ mergeParams: true });
@@ -40,6 +41,11 @@ router.post('/', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('reservation_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
if (!title) return res.status(400).json({ error: 'Title is required' });
// Auto-create accommodation for hotel reservations
@@ -118,6 +124,11 @@ router.put('/positions', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('reservation_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' });
const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?');
@@ -140,6 +151,11 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('reservation_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as Reservation | undefined;
if (!reservation) return res.status(404).json({ error: 'Reservation not found' });
@@ -236,6 +252,11 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('reservation_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
const reservation = db.prepare('SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as { id: number; title: string; type: string; accommodation_id: number | null } | undefined;
if (!reservation) return res.status(404).json({ error: 'Reservation not found' });
diff --git a/server/src/routes/share.ts b/server/src/routes/share.ts
index d36ca03..2f6652b 100644
--- a/server/src/routes/share.ts
+++ b/server/src/routes/share.ts
@@ -1,7 +1,8 @@
import express, { Request, Response } from 'express';
import crypto from 'crypto';
-import { db, canAccessTrip } from '../db/database';
+import { db, canAccessTrip, getTripOwnerId } from '../db/database';
import { authenticate } from '../middleware/auth';
+import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
import { loadTagsByPlaceIds } from '../services/queryHelpers';
@@ -12,6 +13,10 @@ router.post('/trips/:tripId/share-link', authenticate, (req: Request, res: Respo
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('share_manage', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
const { share_map = true, share_bookings = true, share_packing = false, share_budget = false, share_collab = false } = req.body || {};
@@ -45,6 +50,10 @@ router.delete('/trips/:tripId/share-link', authenticate, (req: Request, res: Res
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(tripId);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('share_manage', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId);
res.json({ success: true });
diff --git a/server/src/routes/trips.ts b/server/src/routes/trips.ts
index 7ebce62..b35ee03 100644
--- a/server/src/routes/trips.ts
+++ b/server/src/routes/trips.ts
@@ -3,11 +3,12 @@ import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
-import { db, canAccessTrip, isOwner } from '../db/database';
+import { db, canAccessTrip, getTripOwnerId } from '../db/database';
import { authenticate, demoUploadBlock } from '../middleware/auth';
import { broadcast } from '../websocket';
import { AuthRequest, Trip, User } from '../types';
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
+import { checkPermission } from '../services/permissions';
const router = express.Router();
@@ -143,6 +144,8 @@ router.get('/', authenticate, (req: Request, res: Response) => {
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
+ if (!checkPermission('trip_create', authReq.user.role, null, authReq.user.id, false))
+ return res.status(403).json({ error: 'No permission to create trips' });
const { title, description, start_date, end_date, currency, reminder_days } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
if (start_date && end_date && new Date(end_date) < new Date(start_date))
@@ -182,8 +185,28 @@ router.get('/:id', authenticate, (req: Request, res: Response) => {
router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
- if (!isOwner(req.params.id, authReq.user.id) && authReq.user.role !== 'admin')
- return res.status(403).json({ error: 'Only the trip owner can edit trip details' });
+ const access = canAccessTrip(req.params.id, authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+
+ const tripOwnerId = access.user_id;
+ const isMember = access.user_id !== authReq.user.id;
+
+ // Archive check
+ if (req.body.is_archived !== undefined) {
+ if (!checkPermission('trip_archive', authReq.user.role, tripOwnerId, authReq.user.id, isMember))
+ return res.status(403).json({ error: 'No permission to archive/unarchive this trip' });
+ }
+ // Cover image check
+ if (req.body.cover_image !== undefined) {
+ if (!checkPermission('trip_cover_upload', authReq.user.role, tripOwnerId, authReq.user.id, isMember))
+ return res.status(403).json({ error: 'No permission to change cover image' });
+ }
+ // General edit check (title, description, dates, currency, reminder_days)
+ const editFields = ['title', 'description', 'start_date', 'end_date', 'currency', 'reminder_days'];
+ if (editFields.some(f => req.body[f] !== undefined)) {
+ if (!checkPermission('trip_edit', authReq.user.role, tripOwnerId, authReq.user.id, isMember))
+ return res.status(403).json({ error: 'No permission to edit this trip' });
+ }
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as Trip | undefined;
if (!trip) return res.status(404).json({ error: 'Trip not found' });
@@ -241,8 +264,11 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cover'), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
- if (!isOwner(req.params.id, authReq.user.id))
- return res.status(403).json({ error: 'Only the owner can change the cover image' });
+ const tripOwnerId = getTripOwnerId(req.params.id);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ const isMember = tripOwnerId !== authReq.user.id && !!canAccessTrip(req.params.id, authReq.user.id);
+ if (!checkPermission('trip_cover_upload', authReq.user.role, tripOwnerId, authReq.user.id, isMember))
+ return res.status(403).json({ error: 'No permission to change the cover image' });
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as Trip | undefined;
if (!trip) return res.status(404).json({ error: 'Trip not found' });
@@ -264,8 +290,10 @@ router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cov
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
- if (!isOwner(req.params.id, authReq.user.id) && authReq.user.role !== 'admin')
- return res.status(403).json({ error: 'Only the owner can delete the trip' });
+ const tripOwnerId = getTripOwnerId(req.params.id);
+ if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('trip_delete', authReq.user.role, tripOwnerId, authReq.user.id, false))
+ return res.status(403).json({ error: 'No permission to delete this trip' });
const deletedTripId = Number(req.params.id);
const delTrip = db.prepare('SELECT title, user_id FROM trips WHERE id = ?').get(req.params.id) as { title: string; user_id: number } | undefined;
const isAdminDel = authReq.user.role === 'admin' && delTrip && delTrip.user_id !== authReq.user.id;
@@ -284,7 +312,7 @@ router.get('/:id/members', authenticate, (req: Request, res: Response) => {
if (!canAccessTrip(req.params.id, authReq.user.id))
return res.status(404).json({ error: 'Trip not found' });
- const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id) as { user_id: number };
+ const tripOwnerId = getTripOwnerId(req.params.id)!;
const members = db.prepare(`
SELECT u.id, u.username, u.email, u.avatar,
CASE WHEN u.id = ? THEN 'owner' ELSE 'member' END as role,
@@ -295,9 +323,9 @@ router.get('/:id/members', authenticate, (req: Request, res: Response) => {
LEFT JOIN users ib ON ib.id = m.invited_by
WHERE m.trip_id = ?
ORDER BY m.added_at ASC
- `).all(trip.user_id, req.params.id) as { id: number; username: string; email: string; avatar: string | null; role: string; added_at: string; invited_by_username: string | null }[];
+ `).all(tripOwnerId, req.params.id) as { id: number; username: string; email: string; avatar: string | null; role: string; added_at: string; invited_by_username: string | null }[];
- const owner = db.prepare('SELECT id, username, email, avatar FROM users WHERE id = ?').get(trip.user_id) as Pick;
+ const owner = db.prepare('SELECT id, username, email, avatar FROM users WHERE id = ?').get(tripOwnerId) as Pick;
res.json({
owner: { ...owner, role: 'owner', avatar_url: owner.avatar ? `/uploads/avatars/${owner.avatar}` : null },
@@ -311,6 +339,11 @@ router.post('/:id/members', authenticate, (req: Request, res: Response) => {
if (!canAccessTrip(req.params.id, authReq.user.id))
return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = getTripOwnerId(req.params.id)!;
+ const isMember = tripOwnerId !== authReq.user.id;
+ if (!checkPermission('member_manage', authReq.user.role, tripOwnerId, authReq.user.id, isMember))
+ return res.status(403).json({ error: 'No permission to manage members' });
+
const { identifier } = req.body;
if (!identifier) return res.status(400).json({ error: 'Email or username required' });
@@ -320,8 +353,7 @@ router.post('/:id/members', authenticate, (req: Request, res: Response) => {
if (!target) return res.status(404).json({ error: 'User not found' });
- const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id) as { user_id: number };
- if (target.id === trip.user_id)
+ if (target.id === tripOwnerId)
return res.status(400).json({ error: 'Trip owner is already a member' });
const existing = db.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(req.params.id, target.id);
@@ -345,8 +377,12 @@ router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response
const targetId = parseInt(req.params.userId);
const isSelf = targetId === authReq.user.id;
- if (!isSelf && !isOwner(req.params.id, authReq.user.id))
- return res.status(403).json({ error: 'No permission' });
+ if (!isSelf) {
+ const tripOwnerId = getTripOwnerId(req.params.id)!;
+ const memberCheck = tripOwnerId !== authReq.user.id;
+ if (!checkPermission('member_manage', authReq.user.role, tripOwnerId, authReq.user.id, memberCheck))
+ return res.status(403).json({ error: 'No permission to remove members' });
+ }
db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(req.params.id, targetId);
res.json({ success: true });
diff --git a/server/src/services/permissions.ts b/server/src/services/permissions.ts
new file mode 100644
index 0000000..46adc6a
--- /dev/null
+++ b/server/src/services/permissions.ts
@@ -0,0 +1,145 @@
+import { db } from '../db/database';
+
+/**
+ * Permission levels (hierarchical, higher includes lower):
+ * admin > trip_owner > trip_member > everybody
+ *
+ * "everybody" means any authenticated user with trip access.
+ * For trip_create, "everybody" means any authenticated user (no trip context).
+ */
+export type PermissionLevel = 'admin' | 'trip_owner' | 'trip_member' | 'everybody';
+
+export interface PermissionAction {
+ key: string;
+ defaultLevel: PermissionLevel;
+ allowedLevels: PermissionLevel[];
+}
+
+// All configurable actions with their defaults matching upstream behavior
+export const PERMISSION_ACTIONS: PermissionAction[] = [
+ // Trip management
+ { key: 'trip_create', defaultLevel: 'everybody', allowedLevels: ['admin', 'everybody'] },
+ { key: 'trip_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
+ { key: 'trip_delete', defaultLevel: 'trip_owner', allowedLevels: ['admin', 'trip_owner'] },
+ { key: 'trip_archive', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] },
+ { key: 'trip_cover_upload', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] },
+
+ // Member management
+ { key: 'member_manage', defaultLevel: 'trip_member', allowedLevels: ['admin', 'trip_owner', 'trip_member'] },
+
+ // Files
+ { key: 'file_upload', defaultLevel: 'trip_member', allowedLevels: ['admin', 'trip_owner', 'trip_member'] },
+ { key: 'file_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
+ { key: 'file_delete', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
+
+ // Places
+ { key: 'place_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
+
+ // Budget
+ { key: 'budget_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
+
+ // Packing
+ { key: 'packing_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
+
+ // Reservations
+ { key: 'reservation_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
+
+ // Day notes & schedule
+ { key: 'day_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
+
+ // Collaboration (notes, polls, messages)
+ { key: 'collab_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
+
+ // Share link management
+ { key: 'share_manage', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] },
+];
+
+const ACTIONS_MAP = new Map(PERMISSION_ACTIONS.map(a => [a.key, a]));
+
+// In-memory cache, invalidated on save
+let cache: Map | null = null;
+
+function loadPermissions(): Map {
+ if (cache) return cache;
+ cache = new Map();
+ try {
+ const rows = db.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'perm_%'").all() as { key: string; value: string }[];
+ for (const row of rows) {
+ const actionKey = row.key.replace('perm_', '');
+ if (ACTIONS_MAP.has(actionKey)) {
+ cache.set(actionKey, row.value as PermissionLevel);
+ }
+ }
+ } catch { /* table might not exist yet during init */ }
+ return cache;
+}
+
+export function invalidatePermissionsCache(): void {
+ cache = null;
+}
+
+export function getPermissionLevel(actionKey: string): PermissionLevel {
+ const perms = loadPermissions();
+ const stored = perms.get(actionKey);
+ if (stored) return stored;
+ const action = ACTIONS_MAP.get(actionKey);
+ return action?.defaultLevel ?? 'trip_owner';
+}
+
+export function getAllPermissions(): Record {
+ const perms = loadPermissions();
+ const result: Record = {};
+ for (const action of PERMISSION_ACTIONS) {
+ result[action.key] = perms.get(action.key) ?? action.defaultLevel;
+ }
+ return result;
+}
+
+export function savePermissions(settings: Record): void {
+ const upsert = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
+ const txn = db.transaction(() => {
+ for (const [actionKey, level] of Object.entries(settings)) {
+ const action = ACTIONS_MAP.get(actionKey);
+ if (!action) continue;
+ if (!action.allowedLevels.includes(level as PermissionLevel)) continue;
+ upsert.run(`perm_${actionKey}`, level);
+ }
+ });
+ txn();
+ invalidatePermissionsCache();
+}
+
+/**
+ * Check if a user passes the permission check for a given action.
+ *
+ * @param actionKey - The permission action key
+ * @param userRole - 'admin' | 'user'
+ * @param tripUserId - The trip owner's user ID (null for non-trip actions like trip_create)
+ * @param userId - The requesting user's ID
+ * @param isMember - Whether the user is a trip member (not owner)
+ */
+export function checkPermission(
+ actionKey: string,
+ userRole: string,
+ tripUserId: number | null,
+ userId: number,
+ isMember: boolean
+): boolean {
+ // Admins always pass
+ if (userRole === 'admin') return true;
+
+ const required = getPermissionLevel(actionKey);
+
+ switch (required) {
+ case 'admin':
+ return false; // already checked above
+ case 'trip_owner':
+ return tripUserId !== null && tripUserId === userId;
+ case 'trip_member':
+ return (tripUserId !== null && tripUserId === userId) || isMember;
+ case 'everybody':
+ return true;
+ default:
+ return false;
+ }
+}
From 015be3d53a82e80e0d1faa70408bf1770df4bac4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=A9rnyi=20M=C3=A1rk?=
Date: Tue, 31 Mar 2026 20:53:08 +0200
Subject: [PATCH 02/14] fix: incorrect hook order
---
client/src/components/Admin/PermissionsPanel.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/client/src/components/Admin/PermissionsPanel.tsx b/client/src/components/Admin/PermissionsPanel.tsx
index 7a7eaef..3b3af47 100644
--- a/client/src/components/Admin/PermissionsPanel.tsx
+++ b/client/src/components/Admin/PermissionsPanel.tsx
@@ -85,6 +85,8 @@ export default function PermissionsPanel(): React.ReactElement {
setDirty(true)
}
+ const entryMap = useMemo(() => new Map(entries.map(e => [e.key, e])), [entries])
+
if (loading) {
return (
@@ -93,8 +95,6 @@ export default function PermissionsPanel(): React.ReactElement {
)
}
- const entryMap = useMemo(() => new Map(entries.map(e => [e.key, e])), [entries])
-
return (
From c1bce755cad4c0ba3aed18b530e33b7eb901374d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=A9rnyi=20M=C3=A1rk?=
Date: Tue, 31 Mar 2026 21:47:10 +0200
Subject: [PATCH 03/14] refactor: dedupe database requests
---
server/src/db/database.ts | 7 +--
server/src/routes/budget.ts | 28 ++++------
server/src/routes/collab.ts | 86 +++++++++++++------------------
server/src/routes/dayNotes.ts | 26 ++++------
server/src/routes/packing.ts | 42 ++++-----------
server/src/routes/reservations.ts | 18 ++-----
server/src/routes/share.ts | 16 +++---
server/src/routes/trips.ts | 29 ++++++-----
8 files changed, 98 insertions(+), 154 deletions(-)
diff --git a/server/src/db/database.ts b/server/src/db/database.ts
index 58f76c1..0b59233 100644
--- a/server/src/db/database.ts
+++ b/server/src/db/database.ts
@@ -128,9 +128,4 @@ function isOwner(tripId: number | string, userId: number): boolean {
return !!_db!.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
}
-function getTripOwnerId(tripId: number | string): number | undefined {
- const row = _db!.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined;
- return row?.user_id;
-}
-
-export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner, getTripOwnerId };
+export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner };
diff --git a/server/src/routes/budget.ts b/server/src/routes/budget.ts
index 621b958..9befbc4 100644
--- a/server/src/routes/budget.ts
+++ b/server/src/routes/budget.ts
@@ -1,5 +1,5 @@
import express, { Request, Response } from 'express';
-import { db, canAccessTrip, getTripOwnerId } from '../db/database';
+import { db, canAccessTrip } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { checkPermission } from '../services/permissions';
@@ -84,9 +84,7 @@ router.post('/', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('budget_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!name) return res.status(400).json({ error: 'Name is required' });
@@ -121,9 +119,7 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('budget_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
@@ -159,11 +155,10 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
router.put('/:id/members', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
- if (!canAccessTrip(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const access = canAccessTrip(Number(tripId), authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('budget_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ if (!checkPermission('budget_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
@@ -194,11 +189,10 @@ router.put('/:id/members', authenticate, (req: Request, res: Response) => {
router.put('/:id/members/:userId/paid', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id, userId } = req.params;
- if (!canAccessTrip(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+ const access = canAccessTrip(Number(tripId), authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('budget_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ if (!checkPermission('budget_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { paid } = req.body;
@@ -294,9 +288,7 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('budget_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
diff --git a/server/src/routes/collab.ts b/server/src/routes/collab.ts
index a51a23b..18e51b6 100644
--- a/server/src/routes/collab.ts
+++ b/server/src/routes/collab.ts
@@ -3,7 +3,7 @@ import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
-import { db, canAccessTrip, getTripOwnerId } from '../db/database';
+import { db, canAccessTrip } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { validateStringLengths } from '../middleware/validate';
@@ -112,10 +112,9 @@ router.post('/notes', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { title, content, category, color, website } = req.body;
- if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ const access = verifyTripAccess(tripId, authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!title) return res.status(400).json({ error: 'Title is required' });
@@ -142,10 +141,9 @@ router.put('/notes/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const { title, content, category, color, pinned, website } = req.body;
- if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ const access = verifyTripAccess(tripId, authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const existing = db.prepare('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
@@ -183,10 +181,9 @@ router.put('/notes/:id', authenticate, (req: Request, res: Response) => {
router.delete('/notes/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
- if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ const access = verifyTripAccess(tripId, authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const existing = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
@@ -207,10 +204,9 @@ router.delete('/notes/:id', authenticate, (req: Request, res: Response) => {
router.post('/notes/:id/files', authenticate, noteUpload.single('file'), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
- if (!verifyTripAccess(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ const access = verifyTripAccess(Number(tripId), authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
@@ -229,10 +225,9 @@ router.post('/notes/:id/files', authenticate, noteUpload.single('file'), (req: R
router.delete('/notes/:id/files/:fileId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id, fileId } = req.params;
- if (!verifyTripAccess(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ const access = verifyTripAccess(Number(tripId), authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND note_id = ?').get(fileId, id) as TripFile | undefined;
@@ -298,10 +293,9 @@ router.post('/polls', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { question, options, multiple, multiple_choice, deadline } = req.body;
- if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ const access = verifyTripAccess(tripId, authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!question) return res.status(400).json({ error: 'Question is required' });
if (!Array.isArray(options) || options.length < 2) {
@@ -324,10 +318,9 @@ router.post('/polls/:id/vote', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const { option_index } = req.body;
- if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ const access = verifyTripAccess(tripId, authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId) as CollabPoll | undefined;
@@ -360,10 +353,9 @@ router.post('/polls/:id/vote', authenticate, (req: Request, res: Response) => {
router.put('/polls/:id/close', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
- if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ const access = verifyTripAccess(tripId, authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
@@ -379,10 +371,9 @@ router.put('/polls/:id/close', authenticate, (req: Request, res: Response) => {
router.delete('/polls/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
- if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ const access = verifyTripAccess(tripId, authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const poll = db.prepare('SELECT id FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
@@ -437,10 +428,9 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { text, reply_to } = req.body;
- if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ const access = verifyTripAccess(tripId, authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!text || !text.trim()) return res.status(400).json({ error: 'Message text is required' });
@@ -479,10 +469,9 @@ router.post('/messages/:id/react', authenticate, (req: Request, res: Response) =
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const { emoji } = req.body;
- if (!verifyTripAccess(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ const access = verifyTripAccess(Number(tripId), authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!emoji) return res.status(400).json({ error: 'Emoji is required' });
@@ -504,10 +493,9 @@ router.post('/messages/:id/react', authenticate, (req: Request, res: Response) =
router.delete('/messages/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
- if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ const access = verifyTripAccess(tripId, authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const message = db.prepare('SELECT * FROM collab_messages WHERE id = ? AND trip_id = ?').get(id, tripId) as CollabMessage | undefined;
diff --git a/server/src/routes/dayNotes.ts b/server/src/routes/dayNotes.ts
index bcba782..7c60f3f 100644
--- a/server/src/routes/dayNotes.ts
+++ b/server/src/routes/dayNotes.ts
@@ -1,5 +1,5 @@
import express, { Request, Response } from 'express';
-import { db, canAccessTrip, getTripOwnerId } from '../db/database';
+import { db, canAccessTrip } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { validateStringLengths } from '../middleware/validate';
@@ -27,11 +27,9 @@ router.get('/', authenticate, (req: Request, res: Response) => {
router.post('/', authenticate, validateStringLengths({ text: 500, time: 150 }), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, dayId } = req.params;
- if (!verifyAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
-
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('day_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ const access = verifyAccess(tripId, authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('day_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
@@ -52,11 +50,9 @@ router.post('/', authenticate, validateStringLengths({ text: 500, time: 150 }),
router.put('/:id', authenticate, validateStringLengths({ text: 500, time: 150 }), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, dayId, id } = req.params;
- if (!verifyAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
-
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('day_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ const access = verifyAccess(tripId, authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('day_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const note = db.prepare('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId) as DayNote | undefined;
@@ -81,11 +77,9 @@ router.put('/:id', authenticate, validateStringLengths({ text: 500, time: 150 })
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, dayId, id } = req.params;
- if (!verifyAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
-
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('day_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ const access = verifyAccess(tripId, authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('day_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const note = db.prepare('SELECT id FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId);
diff --git a/server/src/routes/packing.ts b/server/src/routes/packing.ts
index aceb855..bf325ab 100644
--- a/server/src/routes/packing.ts
+++ b/server/src/routes/packing.ts
@@ -1,5 +1,5 @@
import express, { Request, Response } from 'express';
-import { db, canAccessTrip, getTripOwnerId } from '../db/database';
+import { db, canAccessTrip } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { checkPermission } from '../services/permissions';
@@ -34,9 +34,7 @@ router.post('/import', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('packing_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!Array.isArray(items) || items.length === 0) return res.status(400).json({ error: 'items must be a non-empty array' });
@@ -85,9 +83,7 @@ router.post('/', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('packing_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!name) return res.status(400).json({ error: 'Item name is required' });
@@ -112,9 +108,7 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('packing_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const item = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
@@ -152,9 +146,7 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('packing_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
@@ -182,9 +174,7 @@ router.post('/bags', authenticate, (req: Request, res: Response) => {
const { name, color } = req.body;
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('packing_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_bags WHERE trip_id = ?').get(tripId) as { max: number | null };
@@ -200,9 +190,7 @@ router.put('/bags/:bagId', authenticate, (req: Request, res: Response) => {
const { name, color, weight_limit_grams } = req.body;
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('packing_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?').get(bagId, tripId);
if (!bag) return res.status(404).json({ error: 'Bag not found' });
@@ -217,9 +205,7 @@ router.delete('/bags/:bagId', authenticate, (req: Request, res: Response) => {
const { tripId, bagId } = req.params;
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('packing_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?').get(bagId, tripId);
if (!bag) return res.status(404).json({ error: 'Bag not found' });
@@ -237,9 +223,7 @@ router.post('/apply-template/:templateId', authenticate, (req: Request, res: Res
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('packing_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const templateItems = db.prepare(`
@@ -299,9 +283,7 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('packing_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const cat = decodeURIComponent(categoryName);
@@ -343,9 +325,7 @@ router.put('/reorder', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('packing_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const update = db.prepare('UPDATE packing_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
diff --git a/server/src/routes/reservations.ts b/server/src/routes/reservations.ts
index 215d891..f2df031 100644
--- a/server/src/routes/reservations.ts
+++ b/server/src/routes/reservations.ts
@@ -1,5 +1,5 @@
import express, { Request, Response } from 'express';
-import { db, canAccessTrip, getTripOwnerId } from '../db/database';
+import { db, canAccessTrip } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { checkPermission } from '../services/permissions';
@@ -41,9 +41,7 @@ router.post('/', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('reservation_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!title) return res.status(400).json({ error: 'Title is required' });
@@ -124,9 +122,7 @@ router.put('/positions', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('reservation_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' });
@@ -151,9 +147,7 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('reservation_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as Reservation | undefined;
@@ -252,9 +246,7 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('reservation_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const reservation = db.prepare('SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as { id: number; title: string; type: string; accommodation_id: number | null } | undefined;
diff --git a/server/src/routes/share.ts b/server/src/routes/share.ts
index 2f6652b..87f962a 100644
--- a/server/src/routes/share.ts
+++ b/server/src/routes/share.ts
@@ -1,6 +1,6 @@
import express, { Request, Response } from 'express';
import crypto from 'crypto';
-import { db, canAccessTrip, getTripOwnerId } from '../db/database';
+import { db, canAccessTrip } from '../db/database';
import { authenticate } from '../middleware/auth';
import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
@@ -12,10 +12,9 @@ const router = express.Router();
router.post('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
- if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('share_manage', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ const access = canAccessTrip(tripId, authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('share_manage', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { share_map = true, share_bookings = true, share_packing = false, share_budget = false, share_collab = false } = req.body || {};
@@ -49,10 +48,9 @@ router.get('/trips/:tripId/share-link', authenticate, (req: Request, res: Respon
router.delete('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
- if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(tripId);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('share_manage', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
+ const access = canAccessTrip(tripId, authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('share_manage', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId);
diff --git a/server/src/routes/trips.ts b/server/src/routes/trips.ts
index b35ee03..07a130b 100644
--- a/server/src/routes/trips.ts
+++ b/server/src/routes/trips.ts
@@ -3,7 +3,7 @@ import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
-import { db, canAccessTrip, getTripOwnerId } from '../db/database';
+import { db, canAccessTrip } from '../db/database';
import { authenticate, demoUploadBlock } from '../middleware/auth';
import { broadcast } from '../websocket';
import { AuthRequest, Trip, User } from '../types';
@@ -264,9 +264,10 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cover'), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
- const tripOwnerId = getTripOwnerId(req.params.id);
+ const access = canAccessTrip(req.params.id, authReq.user.id);
+ const tripOwnerId = access?.user_id;
if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
- const isMember = tripOwnerId !== authReq.user.id && !!canAccessTrip(req.params.id, authReq.user.id);
+ const isMember = tripOwnerId !== authReq.user.id;
if (!checkPermission('trip_cover_upload', authReq.user.role, tripOwnerId, authReq.user.id, isMember))
return res.status(403).json({ error: 'No permission to change the cover image' });
@@ -290,8 +291,9 @@ router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cov
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
- const tripOwnerId = getTripOwnerId(req.params.id);
- if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
+ const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id) as { user_id: number } | undefined;
+ if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const tripOwnerId = trip.user_id;
if (!checkPermission('trip_delete', authReq.user.role, tripOwnerId, authReq.user.id, false))
return res.status(403).json({ error: 'No permission to delete this trip' });
const deletedTripId = Number(req.params.id);
@@ -309,10 +311,11 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
router.get('/:id/members', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
- if (!canAccessTrip(req.params.id, authReq.user.id))
+ const access = canAccessTrip(req.params.id, authReq.user.id);
+ if (!access)
return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(req.params.id)!;
+ const tripOwnerId = access.user_id;
const members = db.prepare(`
SELECT u.id, u.username, u.email, u.avatar,
CASE WHEN u.id = ? THEN 'owner' ELSE 'member' END as role,
@@ -336,10 +339,11 @@ router.get('/:id/members', authenticate, (req: Request, res: Response) => {
router.post('/:id/members', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
- if (!canAccessTrip(req.params.id, authReq.user.id))
+ const access = canAccessTrip(req.params.id, authReq.user.id);
+ if (!access)
return res.status(404).json({ error: 'Trip not found' });
- const tripOwnerId = getTripOwnerId(req.params.id)!;
+ const tripOwnerId = access.user_id;
const isMember = tripOwnerId !== authReq.user.id;
if (!checkPermission('member_manage', authReq.user.role, tripOwnerId, authReq.user.id, isMember))
return res.status(403).json({ error: 'No permission to manage members' });
@@ -378,9 +382,10 @@ router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response
const targetId = parseInt(req.params.userId);
const isSelf = targetId === authReq.user.id;
if (!isSelf) {
- const tripOwnerId = getTripOwnerId(req.params.id)!;
- const memberCheck = tripOwnerId !== authReq.user.id;
- if (!checkPermission('member_manage', authReq.user.role, tripOwnerId, authReq.user.id, memberCheck))
+ const access = canAccessTrip(req.params.id, authReq.user.id);
+ if (!access) return res.status(404).json({ error: 'Trip not found' });
+ const memberCheck = access.user_id !== authReq.user.id;
+ if (!checkPermission('member_manage', authReq.user.role, access.user_id, authReq.user.id, memberCheck))
return res.status(403).json({ error: 'No permission to remove members' });
}
From eee2bbe47a9ffbe48366e98977e920756630159d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=A9rnyi=20M=C3=A1rk?=
Date: Tue, 31 Mar 2026 21:47:30 +0200
Subject: [PATCH 04/14] fix: change trip_edit to trip_owner
---
server/src/services/permissions.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/server/src/services/permissions.ts b/server/src/services/permissions.ts
index 46adc6a..ddf453d 100644
--- a/server/src/services/permissions.ts
+++ b/server/src/services/permissions.ts
@@ -19,7 +19,7 @@ export interface PermissionAction {
export const PERMISSION_ACTIONS: PermissionAction[] = [
// Trip management
{ key: 'trip_create', defaultLevel: 'everybody', allowedLevels: ['admin', 'everybody'] },
- { key: 'trip_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
+ { key: 'trip_edit', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] },
{ key: 'trip_delete', defaultLevel: 'trip_owner', allowedLevels: ['admin', 'trip_owner'] },
{ key: 'trip_archive', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] },
{ key: 'trip_cover_upload', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] },
From d74133745a04f3c733baa0ffdf40ed76c0001646 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=A9rnyi=20M=C3=A1rk?=
Date: Tue, 31 Mar 2026 21:57:17 +0200
Subject: [PATCH 05/14] chore: update package-lock.json and .gitignore
---
.gitignore | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index 595a6b5..57a3d9e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,6 +31,7 @@ Thumbs.db
# IDE
.vscode/
.idea/
+.claude/
# Logs
logs
@@ -54,4 +55,4 @@ coverage
.eslintcache
.cache
*.tsbuildinfo
-*.tgz
+*.tgz
\ No newline at end of file
From 5f71b85c067d51102f67d664c4377763bb013bd9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=A9rnyi=20M=C3=A1rk?=
Date: Tue, 31 Mar 2026 22:06:52 +0200
Subject: [PATCH 06/14] feat: add client-side permission gating to all
write-action UIs
Gate all mutating UI elements with useCanDo() permission checks:
- BudgetPanel (budget_edit), PackingListPanel (packing_edit)
- DayPlanSidebar, DayDetailPanel (day_edit)
- ReservationsPanel, ReservationModal (reservation_edit)
- CollabNotes, CollabPolls, CollabChat (collab_edit)
- FileManager (file_edit, file_delete, file_upload)
- PlaceFormModal, PlaceInspector, PlacesSidebar (place_edit, file_upload)
- TripFormModal (trip_edit, trip_cover_upload)
- DashboardPage (trip_edit, trip_cover_upload, trip_delete, trip_archive)
- TripMembersModal (member_manage, share_manage)
Also: fix redundant getTripOwnerId queries in trips.ts, remove dead
getTripOwnerId function, fix TripMembersModal grid when share hidden,
fix canRemove logic, guard TripListItem empty actions div.
---
client/src/components/Budget/BudgetPanel.tsx | 130 ++++++++++--------
client/src/components/Collab/CollabChat.tsx | 53 ++++---
client/src/components/Collab/CollabNotes.tsx | 37 +++--
client/src/components/Collab/CollabPolls.tsx | 64 +++++----
client/src/components/Files/FileManager.tsx | 19 +--
.../components/Packing/PackingListPanel.tsx | 65 ++++++---
.../src/components/Planner/DayDetailPanel.tsx | 21 +--
.../src/components/Planner/DayPlanSidebar.tsx | 50 +++----
.../src/components/Planner/PlaceFormModal.tsx | 8 +-
.../src/components/Planner/PlaceInspector.tsx | 2 +-
.../src/components/Planner/PlacesSidebar.tsx | 4 +-
.../components/Planner/ReservationModal.tsx | 6 +-
.../components/Planner/ReservationsPanel.tsx | 63 +++++----
client/src/components/Trips/TripFormModal.tsx | 19 ++-
.../src/components/Trips/TripMembersModal.tsx | 4 +-
client/src/pages/DashboardPage.tsx | 2 +-
client/src/pages/TripPlannerPage.tsx | 7 +-
17 files changed, 333 insertions(+), 221 deletions(-)
diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx
index a6a8a9e..349af9c 100644
--- a/client/src/components/Budget/BudgetPanel.tsx
+++ b/client/src/components/Budget/BudgetPanel.tsx
@@ -2,6 +2,7 @@ import ReactDOM from 'react-dom'
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import DOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
+import { useCanDo } from '../../store/permissionsStore'
import { useTranslation } from '../../i18n'
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
@@ -59,7 +60,7 @@ const calcPD = (p, d) => (d > 0 ? p / d : null)
const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null)
// ── Inline Edit Cell ─────────────────────────────────────────────────────────
-function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip }) {
+function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }) {
const [editing, setEditing] = useState(false)
const [editValue, setEditValue] = useState(value ?? '')
const inputRef = useRef(null)
@@ -86,12 +87,12 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
: (value || '')
return (
- { setEditValue(value ?? ''); setEditing(true) }} title={editTooltip}
- style={{ cursor: 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, display: 'flex', alignItems: 'center',
+
{ if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip}
+ style={{ cursor: readOnly ? 'default' : 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, display: 'flex', alignItems: 'center',
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
- onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
- onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
+ onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
+ onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
{display || placeholder || '-'}
)
@@ -227,9 +228,10 @@ interface BudgetMemberChipsProps {
onSetMembers: (memberIds: number[]) => void
onTogglePaid?: (userId: number, paid: boolean) => void
compact?: boolean
+ readOnly?: boolean
}
-function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true }: BudgetMemberChipsProps) {
+function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) {
const chipSize = compact ? 20 : 30
const btnSize = compact ? 18 : 28
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
@@ -271,17 +273,19 @@ function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTog
{members.map(m => (
onTogglePaid(m.user_id, !m.paid) : undefined}
+ onClick={!readOnly && onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
/>
))}
-
- {members.length > 0 ? : }
-
+ {!readOnly && (
+
+ {members.length > 0 ? : }
+
+ )}
{showDropdown && ReactDOM.createPortal(
(null)
const [settlementOpen, setSettlementOpen] = useState(false)
const currency = trip?.currency || 'EUR'
+ const canEdit = can('budget_edit', trip)
const fmt = (v, cur) => fmtNum(v, locale, cur)
const hasMultipleMembers = tripMembers.length > 1
@@ -482,16 +488,18 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
{t('budget.emptyTitle')}
{t('budget.emptyText')}
-
-
setNewCategoryName(e.target.value)}
- onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
- placeholder={t('budget.emptyPlaceholder')}
- style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
-
-
-
-
+ {canEdit && (
+
+
setNewCategoryName(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
+ placeholder={t('budget.emptyPlaceholder')}
+ style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
+
+
+
+
+ )}
)
}
@@ -518,7 +526,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
- {editingCat?.name === cat ? (
+ {canEdit && editingCat?.name === cat ? (
{cat}
-
setEditingCat({ name: cat, value: cat })}
- style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
- onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
-
-
+ {canEdit && (
+
setEditingCat({ name: cat, value: cat })}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
+ onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
+
+
+ )}
>
)}
{fmt(subtotal, currency)}
- handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
- style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
- onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
-
-
+ {canEdit && (
+ handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
+ style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
+ onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
+
+
+ )}
@@ -574,7 +586,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
- handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} />
+ handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
{/* Mobile: larger chips under name since Persons column is hidden */}
{hasMultipleMembers && (
@@ -584,12 +596,13 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
compact={false}
+ readOnly={!canEdit}
/>
)}
|
- handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} />
+ handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
{hasMultipleMembers ? (
@@ -598,29 +611,32 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
tripMembers={tripMembers}
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
+ readOnly={!canEdit}
/>
) : (
- handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
+ handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
)}
|
- handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
+ handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
{pp != null ? fmt(pp, currency) : '-'} |
{pd != null ? fmt(pd, currency) : '-'} |
{ppd != null ? fmt(ppd, currency) : '-'} |
- handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} /> |
+ handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> |
+ {canEdit && (
handleDeleteItem(item.id)} title={t('common.delete')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
+ )}
|
)
})}
- handleAddItem(cat, data)} t={t} />
+ {canEdit && handleAddItem(cat, data)} t={t} />}
@@ -633,25 +649,27 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
{}}
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
searchable
/>
-
-
setNewCategoryName(e.target.value)}
- onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
- placeholder={t('budget.categoryName')}
- style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
- />
-
-
-
-
+ {canEdit && (
+
+
setNewCategoryName(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
+ placeholder={t('budget.categoryName')}
+ style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
+ />
+
+
+
+
+ )}
s.settings.time_format) === '12h'
+ const can = useCanDo()
+ const trip = useTripStore((s) => s.trip)
+ const canEdit = can('collab_edit', trip)
const [messages, setMessages] = useState([])
const [loading, setLoading] = useState(true)
@@ -636,11 +641,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
style={{ position: 'relative' }}
onMouseEnter={() => setHoveredId(msg.id)}
onMouseLeave={() => setHoveredId(null)}
- onContextMenu={e => { e.preventDefault(); setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
+ onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
onTouchEnd={e => {
const now = Date.now()
const lastTap = e.currentTarget.dataset.lastTap || 0
- if (now - lastTap < 300) {
+ if (now - lastTap < 300 && canEdit) {
e.preventDefault()
const touch = e.changedTouches?.[0]
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
@@ -703,7 +708,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
>
- {own && (
+ {own && canEdit && (
handleDelete(msg.id)} title="Delete" style={{
width: 24, height: 24, borderRadius: '50%', border: 'none',
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
@@ -735,7 +740,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
{msg.reactions.map(r => {
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
return (
- handleReact(msg.id, r.emoji)} />
+ canEdit && handleReact(msg.id, r.emoji)} />
)
})}
@@ -780,23 +785,27 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
{/* Emoji button */}
-
setShowEmoji(!showEmoji)} style={{
- width: 34, height: 34, borderRadius: '50%', border: 'none',
- background: showEmoji ? 'var(--bg-hover)' : 'transparent',
- color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
- cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
- }}>
-
-
+ {canEdit && (
+
setShowEmoji(!showEmoji)} style={{
+ width: 34, height: 34, borderRadius: '50%', border: 'none',
+ background: showEmoji ? 'var(--bg-hover)' : 'transparent',
+ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
+ cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
+ }}>
+
+
+ )}
{/* Send */}
-
-
-
+ {canEdit && (
+
+
+
+ )}
diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx
index a09dedb..8658d8e 100644
--- a/client/src/components/Collab/CollabNotes.tsx
+++ b/client/src/components/Collab/CollabNotes.tsx
@@ -5,6 +5,8 @@ import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2 } from 'lucide-react'
import { collabApi } from '../../api/client'
+import { useCanDo } from '../../store/permissionsStore'
+import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import type { User } from '../../types'
@@ -226,6 +228,9 @@ interface NoteFormModalProps {
}
function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) {
+ const can = useCanDo()
+ const tripObj = useTripStore((s) => s.trip)
+ const canUploadFiles = can('file_upload', tripObj)
const isEdit = !!note
const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean)
@@ -298,6 +303,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
}}
onClick={e => e.stopPropagation()}
onPaste={e => {
+ if (!canUploadFiles) return
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
@@ -450,7 +456,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
{/* File attachments */}
-
+ {canUploadFiles &&
{t('collab.notes.attachFiles')}
@@ -483,7 +489,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
{t('files.attach') || 'Add'}
-
+ }
{/* Submit */}
) => Promise
onDelete: (noteId: number) => Promise
onEdit: (note: CollabNote) => void
@@ -699,7 +706,7 @@ interface NoteCardProps {
t: (key: string) => string
}
-function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
+function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
const [hovered, setHovered] = useState(false)
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
@@ -760,24 +767,24 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPre
)}
- e.currentTarget.style.color = color}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
{note.pinned ? : }
-
- onEdit?.(note)} title={t('collab.notes.edit')}
+ }
+ {canEdit && onEdit?.(note)} title={t('collab.notes.edit')}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
- }
+ {canEdit && e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
+ }
{/* Author avatar */}
s.trip)
+ const canEdit = can('collab_edit', trip)
const [notes, setNotes] = useState([])
const [loading, setLoading] = useState(true)
const [showNewModal, setShowNewModal] = useState(false)
@@ -1130,11 +1140,11 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
setShowNewModal(true)}
+ {canEdit && setShowNewModal(true)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
{t('collab.notes.new')}
-
+ }
@@ -1252,6 +1262,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
key={note.id}
note={note}
currentUser={currentUser}
+ canEdit={canEdit}
onUpdate={handleUpdateNote}
onDelete={handleDeleteNote}
onEdit={setEditingNote}
@@ -1303,12 +1314,12 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
)}
-
{ setViewingNote(null); setEditingNote(viewingNote) }}
+ {canEdit && { setViewingNote(null); setEditingNote(viewingNote) }}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
+ }
setViewingNote(null)}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
diff --git a/client/src/components/Collab/CollabPolls.tsx b/client/src/components/Collab/CollabPolls.tsx
index 66083ef..dc71ff9 100644
--- a/client/src/components/Collab/CollabPolls.tsx
+++ b/client/src/components/Collab/CollabPolls.tsx
@@ -3,6 +3,8 @@ import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
import { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
+import { useCanDo } from '../../store/permissionsStore'
+import { useTripStore } from '../../store/tripStore'
import ReactDOM from 'react-dom'
import type { User } from '../../types'
@@ -190,13 +192,14 @@ function VoterChip({ voter, offset }: VoterChipProps) {
interface PollCardProps {
poll: Poll
currentUser: User
+ canEdit: boolean
onVote: (pollId: number, optionId: number) => Promise
onClose: (pollId: number) => Promise
onDelete: (pollId: number) => Promise
t: (key: string) => string
}
-function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardProps) {
+function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: PollCardProps) {
const total = totalVotes(poll)
const isClosed = poll.is_closed || isExpired(poll.deadline)
const remaining = timeRemaining(poll.deadline)
@@ -238,22 +241,24 @@ function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardP
{/* Actions */}
-
- {!isClosed && (
-
onClose(poll.id)} title={t('collab.polls.close')}
+ {canEdit && (
+
+ {!isClosed && (
+ onClose(poll.id)} title={t('collab.polls.close')}
+ style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
+ onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
+ onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
+
+
+ )}
+ onDelete(poll.id)} title={t('collab.polls.delete')}
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
- onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
+ onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
+
- )}
- onDelete(poll.id)} title={t('collab.polls.delete')}
- style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
- onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
- onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
-
-
+
+ )}
{/* Options */}
@@ -265,15 +270,15 @@ function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardP
const isWinner = isClosed && count === Math.max(...(poll.options || []).map(o => o.voters?.length || 0)) && count > 0
return (
- !isClosed && onVote(poll.id, idx)}
- disabled={isClosed}
+ !isClosed && canEdit && onVote(poll.id, idx)}
+ disabled={isClosed || !canEdit}
style={{
position: 'relative', display: 'flex', alignItems: 'center', gap: 8,
- padding: '10px 12px', borderRadius: 10, border: 'none', cursor: isClosed ? 'default' : 'pointer',
+ padding: '10px 12px', borderRadius: 10, border: 'none', cursor: (isClosed || !canEdit) ? 'default' : 'pointer',
background: 'var(--bg-secondary)', fontFamily: FONT, textAlign: 'left', width: '100%',
overflow: 'hidden', transition: 'transform 0.1s',
}}
- onMouseEnter={e => { if (!isClosed) e.currentTarget.style.transform = 'scale(1.01)' }}
+ onMouseEnter={e => { if (!isClosed && canEdit) e.currentTarget.style.transform = 'scale(1.01)' }}
onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'}
>
{/* Progress bar background */}
@@ -337,6 +342,9 @@ interface CollabPollsProps {
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
const { t } = useTranslation()
+ const can = useCanDo()
+ const trip = useTripStore((s) => s.trip)
+ const canEdit = can('collab_edit', trip)
const [polls, setPolls] = useState([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
@@ -426,13 +434,15 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
{t('collab.polls.title')}
- setShowForm(true)} style={{
- display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
- background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
- fontFamily: FONT, border: 'none', cursor: 'pointer',
- }}>
- {t('collab.polls.new')}
-
+ {canEdit && (
+ setShowForm(true)} style={{
+ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
+ background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
+ fontFamily: FONT, border: 'none', cursor: 'pointer',
+ }}>
+ {t('collab.polls.new')}
+
+ )}
{/* Content */}
@@ -446,7 +456,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
) : (
{activePolls.length > 0 && activePolls.map(poll => (
-
+
))}
{closedPolls.length > 0 && (
<>
@@ -456,7 +466,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
)}
{closedPolls.map(poll => (
-
+
))}
>
)}
diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx
index d0e85bb..3fcf8e2 100644
--- a/client/src/components/Files/FileManager.tsx
+++ b/client/src/components/Files/FileManager.tsx
@@ -257,6 +257,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
})
const handlePaste = useCallback((e) => {
+ if (!can('file_upload', trip)) return
const items = e.clipboardData?.items
if (!items) return
const pastedFiles = []
@@ -396,14 +397,14 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
{isTrash ? (
<>
-
handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
+ {can('file_delete', trip) && handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#22c55e'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
- handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
+ }
+ {can('file_delete', trip) && handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
+ }
>
) : (
<>
@@ -411,18 +412,18 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
onMouseEnter={e => { if (!file.starred) e.currentTarget.style.color = '#facc15' }} onMouseLeave={e => { if (!file.starred) e.currentTarget.style.color = 'var(--text-faint)' }}>
-
setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
+ {can('file_edit', trip) && setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
+ }
openFile({ ...file, url: fileUrl })} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
+ {can('file_delete', trip) && handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
+ }
>
)}
@@ -685,7 +686,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
{showTrash ? (
/* Trash view */
- {trashFiles.length > 0 && (
+ {trashFiles.length > 0 && can('file_delete', trip) && (
Promise
+ canEdit?: boolean
}
-function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag }: ArtikelZeileProps) {
+function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(item.name)
const [hovered, setHovered] = useState(false)
@@ -130,7 +132,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
{item.checked ? : }
- {editing ? (
+ {editing && canEdit ? (
setEditName(e.target.value)}
@@ -140,10 +142,10 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
/>
) : (
!item.checked && setEditing(true)}
+ onClick={() => canEdit && !item.checked && setEditing(true)}
style={{
flex: 1, fontSize: 13.5,
- cursor: item.checked ? 'default' : 'text',
+ cursor: !canEdit || item.checked ? 'default' : 'text',
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
textDecoration: item.checked ? 'line-through' : 'none',
}}
@@ -159,7 +161,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
{
+ if (!canEdit) return
const raw = e.target.value.replace(/[^0-9]/g, '')
const v = raw === '' ? null : parseInt(raw)
try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch {}
@@ -171,9 +175,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
setShowBagPicker(p => !p)}
+ onClick={() => canEdit && setShowBagPicker(p => !p)}
style={{
- width: 22, height: 22, borderRadius: '50%', cursor: 'pointer', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
+ width: 22, height: 22, borderRadius: '50%', cursor: canEdit ? 'pointer' : 'default', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
border: item.bag_id ? `2.5px solid ${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}` : '2px dashed var(--border-primary)',
background: item.bag_id ? `${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}30` : 'transparent',
}}
@@ -247,6 +251,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
)}
+ {canEdit && (
)
}
@@ -319,9 +325,10 @@ interface KategorieGruppeProps {
bagTrackingEnabled?: boolean
bags?: PackingBag[]
onCreateBag: (name: string) => Promise
+ canEdit?: boolean
}
-function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag }: KategorieGruppeProps) {
+function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
const [offen, setOffen] = useState(true)
const [editingName, setEditingName] = useState(false)
const [editKatName, setEditKatName] = useState(kategorie)
@@ -380,7 +387,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
- {editingName ? (
+ {editingName && canEdit ? (
setEditKatName(e.target.value)}
@@ -398,11 +405,11 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
{assignees.map(a => (
{ e.stopPropagation(); onSetAssignees(kategorie, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }}
+ onClick={e => { e.stopPropagation(); if (canEdit) onSetAssignees(kategorie, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }}
>
))}
+ {canEdit && (
{ e.stopPropagation(); setShowAssigneeDropdown(v => !v) }}
style={{
@@ -479,6 +487,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
)}
+ )}
setShowMenu(false)}>
- } label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />
+ {canEdit && } label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />}
} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
+ {canEdit && <>
} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
+ >}
)}
@@ -510,10 +521,10 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
{offen && (
{items.map(item => (
-
{}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} />
+ {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
))}
{/* Inline add item */}
- {showAddItem ? (
+ {canEdit && (showAddItem ? (
)}
@@ -589,6 +600,9 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
const [addingCategory, setAddingCategory] = useState(false)
const [newCatName, setNewCatName] = useState('')
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
+ const can = useCanDo()
+ const trip = useTripStore((s) => s.trip)
+ const canEdit = can('packing_edit', trip)
const toast = useToast()
const { t } = useTranslation()
@@ -814,7 +828,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
- {abgehakt > 0 && (
+ {canEdit && abgehakt > 0 && (
{t('packing.clearCheckedShort', { count: abgehakt })}
)}
+ {canEdit && (
setShowImportModal(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
@@ -830,7 +845,8 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
}}>
{t('packing.import')}
- {availableTemplates.length > 0 && (
+ )}
+ {canEdit && availableTemplates.length > 0 && (
setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
@@ -899,7 +915,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
)}
- {addingCategory ? (
+ {canEdit && (addingCategory ? (
{ e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
{t('packing.addCategory')}
- )}
+ ))}
{/* ── Filter-Tabs ── */}
@@ -972,6 +988,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
bagTrackingEnabled={bagTrackingEnabled}
bags={bags}
onCreateBag={handleCreateBagByName}
+ canEdit={canEdit}
/>
))}
@@ -998,10 +1015,12 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
+ {canEdit && (
handleDeleteBag(bag.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
+ )}
@@ -1039,7 +1058,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
{/* Add bag */}
- {showAddBag ? (
+ {canEdit && (showAddBag ? (
setNewBagName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
@@ -1054,7 +1073,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 12, padding: '5px 8px', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%' }}>
{t('packing.addBag')}
- )}
+ ))}
)}
@@ -1083,10 +1102,12 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
+ {canEdit && (
handleDeleteBag(bag.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
+ )}
@@ -1124,7 +1145,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
{/* Add bag */}
- {showAddBag ? (
+ {canEdit && (showAddBag ? (
)}
diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx
index d455595..22cc76e 100644
--- a/client/src/components/Planner/DayDetailPanel.tsx
+++ b/client/src/components/Planner/DayDetailPanel.tsx
@@ -5,6 +5,8 @@ import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
import { weatherApi, accommodationsApi } from '../../api/client'
+import { useCanDo } from '../../store/permissionsStore'
+import { useTripStore } from '../../store/tripStore'
import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker'
import { useSettingsStore } from '../../store/settingsStore'
@@ -56,6 +58,9 @@ interface DayDetailPanelProps {
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0 }: DayDetailPanelProps) {
const { t, language, locale } = useTranslation()
+ const can = useCanDo()
+ const tripObj = useTripStore((s) => s.trip)
+ const canEditDays = can('day_edit', tripObj)
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
@@ -337,13 +342,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
{acc.place_name}
{acc.place_address && {acc.place_address}
}
- { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
+ {canEditDays && { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
-
- { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
+ }
+ {canEditDays && { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
-
+ }
{/* Details grid */}
@@ -394,22 +399,22 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
)
})}
{/* Add another hotel */}
- setShowHotelPicker(true)} style={{
+ {canEditDays && setShowHotelPicker(true)} style={{
width: '100%', padding: 8, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
fontSize: 10, color: 'var(--text-faint)', fontFamily: 'inherit',
}}>
{t('day.addAccommodation')}
-
+ }
) : (
- setShowHotelPicker(true)} style={{
+ canEditDays ? setShowHotelPicker(true)} style={{
width: '100%', padding: 10, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit',
}}>
{t('day.addAccommodation')}
-
+ : null
)}
{/* Hotel Picker Popup — portal to body to escape transform stacking context */}
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx
index 4002ea4..6841bbd 100644
--- a/client/src/components/Planner/DayPlanSidebar.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.tsx
@@ -16,6 +16,7 @@ import WeatherWidget from '../Weather/WeatherWidget'
import { useToast } from '../shared/Toast'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTripStore } from '../../store/tripStore'
+import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
@@ -94,6 +95,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const ctxMenu = useContextMenu()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const tripStore = useTripStore()
+ const can = useCanDo()
+ const canEditDays = can('day_edit', trip)
const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId)
@@ -824,12 +827,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{day.title || t('dayplan.dayN', { n: index + 1 })}
- startEditTitle(day, e)}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '4px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
>
-
+ }
{(() => {
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
// Sort: check-out first, then ongoing stays, then check-in last
@@ -873,7 +876,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
- openAddNote(day.id, e)}
title={t('dayplan.addNote')}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
@@ -881,7 +884,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
>
-
+ }
toggleDay(day.id, e)}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
@@ -1004,8 +1007,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{showDropLine && }
{
+ if (!canEditDays) { e.preventDefault(); return }
e.dataTransfer.setData('assignmentId', String(assignment.id))
e.dataTransfer.setData('fromDayId', String(day.id))
e.dataTransfer.effectAllowed = 'move'
@@ -1039,12 +1043,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
onContextMenu={e => ctxMenu.open(e, [
- onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
- onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
+ canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
+ canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
{ divider: true },
- onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
+ canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])}
onMouseEnter={() => setHoveredId(assignment.id)}
onMouseLeave={() => setHoveredId(null)}
@@ -1062,9 +1066,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
opacity: isDraggingThis ? 0.4 : 1,
}}
>
-
}
{ e.stopPropagation(); toggleLock(assignment.id) }}
onMouseEnter={e => { e.stopPropagation(); setLockHoverId(assignment.id) }}
@@ -1167,14 +1171,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
)}
- }
)
@@ -1273,8 +1277,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{showDropLine && }
{ e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
+ draggable={canEditDays}
+ onDragStart={e => { if (!canEditDays) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
onDrop={e => {
@@ -1299,9 +1303,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
}
}}
onContextMenu={e => ctxMenu.open(e, [
- { label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
- { divider: true },
- { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
+ canEditDays && { label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
+ canEditDays && { divider: true },
+ canEditDays && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
])}
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
onMouseLeave={() => setHoveredId(null)}
@@ -1316,9 +1320,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
}}
>
-
}
@@ -1330,14 +1334,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{note.time}
)}
-
+ {canEditDays &&
openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}>
deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}>
-
-
+
}
+ {canEditDays &&
{ e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
{ e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
-
+
}
)
diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx
index 40c4d9b..3d30f1a 100644
--- a/client/src/components/Planner/PlaceFormModal.tsx
+++ b/client/src/components/Planner/PlaceFormModal.tsx
@@ -3,6 +3,8 @@ import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { mapsApi } from '../../api/client'
import { useAuthStore } from '../../store/authStore'
+import { useCanDo } from '../../store/permissionsStore'
+import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
@@ -66,6 +68,9 @@ export default function PlaceFormModal({
const toast = useToast()
const { t, language } = useTranslation()
const { hasMapsKey } = useAuthStore()
+ const can = useCanDo()
+ const tripObj = useTripStore((s) => s.trip)
+ const canUploadFiles = can('file_upload', tripObj)
useEffect(() => {
if (place) {
@@ -171,6 +176,7 @@ export default function PlaceFormModal({
// Paste support for files/images
const handlePaste = (e) => {
+ if (!canUploadFiles) return
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
@@ -386,7 +392,7 @@ export default function PlaceFormModal({
{/* File Attachments */}
- {true && (
+ {canUploadFiles && (
diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx
index ec4aaa2..6b78182 100644
--- a/client/src/components/Planner/PlaceInspector.tsx
+++ b/client/src/components/Planner/PlaceInspector.tsx
@@ -122,7 +122,7 @@ interface PlaceInspectorProps {
onAssignToDay: (placeId: number, dayId: number) => void
onRemoveAssignment: (assignmentId: number, dayId: number) => void
files: TripFile[]
- onFileUpload: (fd: FormData) => Promise
+ onFileUpload?: (fd: FormData) => Promise
tripMembers?: TripMember[]
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
onUpdatePlace: (placeId: number, data: Partial) => void
diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx
index 8f57c4c..2b2b368 100644
--- a/client/src/components/Planner/PlacesSidebar.tsx
+++ b/client/src/components/Planner/PlacesSidebar.tsx
@@ -229,9 +229,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
-
+ {canEditPlaces &&
{t('places.addPlace')}
-
+ }
) : (
filtered.map(place => {
diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx
index 9472fc2..dbc3ab3 100644
--- a/client/src/components/Planner/ReservationModal.tsx
+++ b/client/src/components/Planner/ReservationModal.tsx
@@ -59,7 +59,7 @@ interface ReservationModalProps {
assignments: AssignmentsMap
selectedDayId: number | null
files?: TripFile[]
- onFileUpload: (fd: FormData) => Promise
+ onFileUpload?: (fd: FormData) => Promise
onFileDelete: (fileId: number) => Promise
accommodations?: Accommodation[]
}
@@ -504,14 +504,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
))}
-
fileInputRef.current?.click()} disabled={uploadingFile} style={{
+ {onFileUpload && fileInputRef.current?.click()} disabled={uploadingFile} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
}}>
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
-
+ }
{/* Link existing file picker */}
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx
index 9bb73c3..b6f9c55 100644
--- a/client/src/components/Planner/ReservationsPanel.tsx
+++ b/client/src/components/Planner/ReservationsPanel.tsx
@@ -1,6 +1,7 @@
import { useState, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
+import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
@@ -56,9 +57,10 @@ interface ReservationCardProps {
files?: TripFile[]
onNavigateToFiles: () => void
assignmentLookup: Record
+ canEdit: boolean
}
-function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup }: ReservationCardProps) {
+function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit }: ReservationCardProps) {
const { toggleReservationStatus } = useTripStore()
const toast = useToast()
const { t, locale } = useTranslation()
@@ -95,24 +97,34 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{/* Header bar */}
-
- {confirmed ? t('reservations.confirmed') : t('reservations.pending')}
-
+ {canEdit ? (
+
+ {confirmed ? t('reservations.confirmed') : t('reservations.pending')}
+
+ ) : (
+
+ {confirmed ? t('reservations.confirmed') : t('reservations.pending')}
+
+ )}
{t(typeInfo.labelKey)}
{r.title}
-
onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
- onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
- onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
-
-
setShowDeleteConfirm(true)} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
- onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
- onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
-
+ {canEdit && (
+
onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
+ onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
+ onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
+
+
+ )}
+ {canEdit && (
+
setShowDeleteConfirm(true)} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
+ onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
+ onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
+
+
+ )}
{/* Details */}
@@ -330,6 +342,9 @@ interface ReservationsPanelProps {
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
const { t, locale } = useTranslation()
+ const can = useCanDo()
+ const trip = useTripStore((s) => s.trip)
+ const canEdit = can('reservation_edit', trip)
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
@@ -348,13 +363,15 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
-
- {t('reservations.addManual')}
-
+ {canEdit && (
+
+ {t('reservations.addManual')}
+
+ )}
{/* Content */}
@@ -370,14 +387,14 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
{allPending.length > 0 && (
- {allPending.map(r => )}
+ {allPending.map(r => )}
)}
{allConfirmed.length > 0 && (
- {allConfirmed.map(r => )}
+ {allConfirmed.map(r => )}
)}
diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx
index b851f57..4e834b9 100644
--- a/client/src/components/Trips/TripFormModal.tsx
+++ b/client/src/components/Trips/TripFormModal.tsx
@@ -4,6 +4,7 @@ import { Calendar, Camera, X, Clipboard, UserPlus, Bell } from 'lucide-react'
import { tripsApi, authApi } from '../../api/client'
import CustomSelect from '../shared/CustomSelect'
import { useAuthStore } from '../../store/authStore'
+import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
@@ -25,6 +26,9 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const currentUser = useAuthStore(s => s.user)
const tripRemindersEnabled = useAuthStore(s => s.tripRemindersEnabled)
const setTripRemindersEnabled = useAuthStore(s => s.setTripRemindersEnabled)
+ const can = useCanDo()
+ const canUploadCover = !isEditing || can('trip_cover_upload', trip)
+ const canEditTrip = !isEditing || can('trip_edit', trip)
const [formData, setFormData] = useState({
title: '',
@@ -174,6 +178,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
// Paste support for cover image
const handlePaste = (e) => {
+ if (!canUploadCover) return
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
@@ -231,8 +236,8 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
{error}
)}
- {/* Cover image — available for both create and edit */}
-
+ {/* Cover image — gated by trip_cover_upload permission */}
+ {canUploadCover &&
{coverPreview ? (
@@ -260,20 +265,20 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
{uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
)}
-
+
}
- update('title', e.target.value)}
- required placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
+ canEditTrip && update('title', e.target.value)}
+ required readOnly={!canEditTrip} placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
-
diff --git a/client/src/components/Trips/TripMembersModal.tsx b/client/src/components/Trips/TripMembersModal.tsx
index 4b40be0..47a6b54 100644
--- a/client/src/components/Trips/TripMembersModal.tsx
+++ b/client/src/components/Trips/TripMembersModal.tsx
@@ -253,7 +253,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
return (
-
+
{/* Left column: Members */}
@@ -323,7 +323,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
{allMembers.map(member => {
const isSelf = member.id === user?.id
- const canRemove = isSelf || (canManageMembers && (isCurrentOwner ? member.role !== 'owner' : false))
+ const canRemove = isSelf || (canManageMembers && member.role !== 'owner')
return (
{/* Actions */}
- {(!!trip.is_owner || isAdmin) && (
+ {(onEdit || onArchive || onDelete) && (
e.stopPropagation()}>
{onEdit &&
onEdit(trip)} icon={} label="" />}
{onArchive && onArchive(trip.id)} icon={} label="" />}
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index 5e48167..4de73c1 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { useParams, useNavigate } from 'react-router-dom'
import { useTripStore } from '../store/tripStore'
+import { useCanDo } from '../store/permissionsStore'
import { useSettingsStore } from '../store/settingsStore'
import { MapView } from '../components/Map/MapView'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
@@ -38,6 +39,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
const { settings } = useSettingsStore()
const tripStore = useTripStore()
const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore
+ const can = useCanDo()
+ const canUploadFiles = can('file_upload', trip)
const [enabledAddons, setEnabledAddons] = useState>({ packing: true, budget: true, documents: true })
const [tripAccommodations, setTripAccommodations] = useState([])
@@ -584,7 +587,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
onAssignToDay={handleAssignToDay}
onRemoveAssignment={handleRemoveAssignment}
files={files}
- onFileUpload={(fd) => tripStore.addFile(tripId, fd)}
+ onFileUpload={canUploadFiles ? (fd) => tripStore.addFile(tripId, fd) : undefined}
tripMembers={tripMembers}
onSetParticipants={async (assignmentId, dayId, userIds) => {
try {
@@ -688,7 +691,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
{ setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
- { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} accommodations={tripAccommodations} />
+ { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripStore.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} accommodations={tripAccommodations} />
setDeletePlaceId(null)}
From 5e05bcd0dbe596bca077874f0a19029a5c0f520b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=A9rnyi=20M=C3=A1rk?=
Date: Tue, 31 Mar 2026 22:35:00 +0200
Subject: [PATCH 07/14] Revert "fix: change trip_edit to trip_owner"
This reverts commit 24f95be247ee0bdf49ab72fa69d4261c61194d63.
---
server/src/services/permissions.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/server/src/services/permissions.ts b/server/src/services/permissions.ts
index ddf453d..46adc6a 100644
--- a/server/src/services/permissions.ts
+++ b/server/src/services/permissions.ts
@@ -19,7 +19,7 @@ export interface PermissionAction {
export const PERMISSION_ACTIONS: PermissionAction[] = [
// Trip management
{ key: 'trip_create', defaultLevel: 'everybody', allowedLevels: ['admin', 'everybody'] },
- { key: 'trip_edit', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] },
+ { key: 'trip_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
{ key: 'trip_delete', defaultLevel: 'trip_owner', allowedLevels: ['admin', 'trip_owner'] },
{ key: 'trip_archive', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] },
{ key: 'trip_cover_upload', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] },
From 6d5067247c030a3a18106e4261e340d478831e02 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=A9rnyi=20M=C3=A1rk?=
Date: Tue, 31 Mar 2026 23:02:43 +0200
Subject: [PATCH 08/14] refactor: remove dead isAdmin prop from dashboard cards
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Permission gating via useCanDo() makes the isAdmin prop redundant —
admin bypass is handled inside the permission system itself.
---
client/src/pages/DashboardPage.tsx | 23 ++++++++++-------------
1 file changed, 10 insertions(+), 13 deletions(-)
diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx
index a27c417..6272fe8 100644
--- a/client/src/pages/DashboardPage.tsx
+++ b/client/src/pages/DashboardPage.tsx
@@ -147,10 +147,9 @@ interface TripCardProps {
t: (key: string, params?: Record) => string
locale: string
dark?: boolean
- isAdmin?: boolean
}
-function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark, isAdmin }: TripCardProps): React.ReactElement {
+function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark }: TripCardProps): React.ReactElement {
const status = getTripStatus(trip)
const coverBg = trip.cover_image
@@ -233,7 +232,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
}
// ── Regular Trip Card ────────────────────────────────────────────────────────
-function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, isAdmin }: Omit): React.ReactElement {
+function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit): React.ReactElement {
const status = getTripStatus(trip)
const [hovered, setHovered] = useState(false)
@@ -324,7 +323,7 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, isAdm
}
// ── List View Item ──────────────────────────────────────────────────────────
-function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale, isAdmin }: Omit): React.ReactElement {
+function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit): React.ReactElement {
const status = getTripStatus(trip)
const [hovered, setHovered] = useState(false)
@@ -430,10 +429,9 @@ interface ArchivedRowProps {
onClick: (trip: DashboardTrip) => void
t: (key: string, params?: Record) => string
locale: string
- isAdmin?: boolean
}
-function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale, isAdmin }: ArchivedRowProps): React.ReactElement {
+function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }: ArchivedRowProps): React.ReactElement {
return (
onClick(trip)} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px',
@@ -459,7 +457,7 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale,
)}
- {(!!trip.is_owner || isAdmin) && (
+ {(onEdit || onUnarchive || onDelete) && (
e.stopPropagation()}>
{onUnarchive &&
onUnarchive(trip.id)} title={t('dashboard.restore')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-primary)' }}
@@ -552,8 +550,7 @@ export default function DashboardPage(): React.ReactElement {
const navigate = useNavigate()
const toast = useToast()
const { t, locale } = useTranslation()
- const { demoMode, user } = useAuthStore()
- const isAdmin = user?.role === 'admin'
+ const { demoMode } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const can = useCanDo()
const dm = settings.dark_mode
@@ -798,7 +795,7 @@ export default function DashboardPage(): React.ReactElement {
{!isLoading && spotlight && viewMode === 'grid' && (
{ setEditingTrip(tr); setShowForm(true) } : undefined}
onDelete={can('trip_delete', spotlight) ? handleDelete : undefined}
onArchive={can('trip_archive', spotlight) ? handleArchive : undefined}
@@ -814,7 +811,7 @@ export default function DashboardPage(): React.ReactElement {
{ setEditingTrip(tr); setShowForm(true) } : undefined}
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
onArchive={can('trip_archive', trip) ? handleArchive : undefined}
@@ -828,7 +825,7 @@ export default function DashboardPage(): React.ReactElement {
{ setEditingTrip(tr); setShowForm(true) } : undefined}
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
onArchive={can('trip_archive', trip) ? handleArchive : undefined}
@@ -858,7 +855,7 @@ export default function DashboardPage(): React.ReactElement {
{ setEditingTrip(tr); setShowForm(true) } : undefined}
onUnarchive={can('trip_archive', trip) ? handleUnarchive : undefined}
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
From 6d18d5ed2d5281c54cb925f6ce7682851265ae03 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=A9rnyi=20M=C3=A1rk?=
Date: Tue, 31 Mar 2026 23:05:51 +0200
Subject: [PATCH 09/14] fix: gate collab notes category settings button with
collab_edit
---
client/src/components/Collab/CollabNotes.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx
index 8658d8e..ccf6b7b 100644
--- a/client/src/components/Collab/CollabNotes.tsx
+++ b/client/src/components/Collab/CollabNotes.tsx
@@ -1134,12 +1134,12 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
{t('collab.notes.title')}
-
setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
+ {canEdit && setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
+ }
{canEdit &&
setShowNewModal(true)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
From 1ff85464846c43275b177944d80f5bb62634f1b3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=A9rnyi=20M=C3=A1rk?=
Date: Tue, 31 Mar 2026 23:15:43 +0200
Subject: [PATCH 10/14] fix: i18n chat reply/delete titles, gate collab
category settings
---
client/src/components/Collab/CollabChat.tsx | 4 ++--
client/src/i18n/translations/ar.ts | 1 +
client/src/i18n/translations/br.ts | 1 +
client/src/i18n/translations/cs.ts | 1 +
client/src/i18n/translations/de.ts | 1 +
client/src/i18n/translations/en.ts | 1 +
client/src/i18n/translations/es.ts | 1 +
client/src/i18n/translations/fr.ts | 1 +
client/src/i18n/translations/hu.ts | 1 +
client/src/i18n/translations/it.ts | 1 +
client/src/i18n/translations/nl.ts | 1 +
client/src/i18n/translations/ru.ts | 1 +
client/src/i18n/translations/zh.ts | 1 +
13 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/client/src/components/Collab/CollabChat.tsx b/client/src/components/Collab/CollabChat.tsx
index df2706d..2afa755 100644
--- a/client/src/components/Collab/CollabChat.tsx
+++ b/client/src/components/Collab/CollabChat.tsx
@@ -697,7 +697,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
transition: 'opacity .1s',
...(own ? { left: -6 } : { right: -6 }),
}}>
- setReplyTo(msg)} title="Reply" style={{
+ setReplyTo(msg)} title={t('collab.chat.reply')} style={{
width: 24, height: 24, borderRadius: '50%', border: 'none',
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
@@ -709,7 +709,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
{own && canEdit && (
- handleDelete(msg.id)} title="Delete" style={{
+ handleDelete(msg.id)} title={t('common.delete')} style={{
width: 24, height: 24, borderRadius: '50%', border: 'none',
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts
index 391d448..1974c6f 100644
--- a/client/src/i18n/translations/ar.ts
+++ b/client/src/i18n/translations/ar.ts
@@ -1373,6 +1373,7 @@ const ar: Record = {
'collab.chat.today': 'اليوم',
'collab.chat.yesterday': 'أمس',
'collab.chat.deletedMessage': 'حذف رسالة',
+ 'collab.chat.reply': 'رد',
'collab.chat.loadMore': 'تحميل الرسائل الأقدم',
'collab.chat.justNow': 'الآن',
'collab.chat.minutesAgo': 'منذ {n} د',
diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts
index 203d618..5ae2943 100644
--- a/client/src/i18n/translations/br.ts
+++ b/client/src/i18n/translations/br.ts
@@ -1313,6 +1313,7 @@ const br: Record = {
'collab.chat.today': 'Hoje',
'collab.chat.yesterday': 'Ontem',
'collab.chat.deletedMessage': 'apagou uma mensagem',
+ 'collab.chat.reply': 'Responder',
'collab.chat.loadMore': 'Carregar mensagens antigas',
'collab.chat.justNow': 'agora mesmo',
'collab.chat.minutesAgo': 'há {n} min',
diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts
index d34d383..a57b3b7 100644
--- a/client/src/i18n/translations/cs.ts
+++ b/client/src/i18n/translations/cs.ts
@@ -1373,6 +1373,7 @@ const cs: Record = {
'collab.chat.today': 'Dnes',
'collab.chat.yesterday': 'Včera',
'collab.chat.deletedMessage': 'smazal zprávu',
+ 'collab.chat.reply': 'Odpovědět',
'collab.chat.loadMore': 'Načíst starší zprávy',
'collab.chat.justNow': 'právě teď',
'collab.chat.minutesAgo': 'před {n} min',
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts
index b54abd8..f998bfd 100644
--- a/client/src/i18n/translations/de.ts
+++ b/client/src/i18n/translations/de.ts
@@ -1370,6 +1370,7 @@ const de: Record = {
'collab.chat.today': 'Heute',
'collab.chat.yesterday': 'Gestern',
'collab.chat.deletedMessage': 'hat eine Nachricht gelöscht',
+ 'collab.chat.reply': 'Antworten',
'collab.chat.loadMore': 'Ältere Nachrichten laden',
'collab.chat.justNow': 'gerade eben',
'collab.chat.minutesAgo': 'vor {n} Min.',
diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts
index 97243fc..15aba3b 100644
--- a/client/src/i18n/translations/en.ts
+++ b/client/src/i18n/translations/en.ts
@@ -1366,6 +1366,7 @@ const en: Record = {
'collab.chat.today': 'Today',
'collab.chat.yesterday': 'Yesterday',
'collab.chat.deletedMessage': 'deleted a message',
+ 'collab.chat.reply': 'Reply',
'collab.chat.loadMore': 'Load older messages',
'collab.chat.justNow': 'just now',
'collab.chat.minutesAgo': '{n}m ago',
diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts
index 056f202..48dd65a 100644
--- a/client/src/i18n/translations/es.ts
+++ b/client/src/i18n/translations/es.ts
@@ -1323,6 +1323,7 @@ const es: Record = {
'collab.chat.today': 'Hoy',
'collab.chat.yesterday': 'Ayer',
'collab.chat.deletedMessage': 'eliminó un mensaje',
+ 'collab.chat.reply': 'Responder',
'collab.chat.loadMore': 'Cargar mensajes anteriores',
'collab.chat.justNow': 'justo ahora',
'collab.chat.minutesAgo': 'hace {n} min',
diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts
index 87d97cb..3550b4f 100644
--- a/client/src/i18n/translations/fr.ts
+++ b/client/src/i18n/translations/fr.ts
@@ -1369,6 +1369,7 @@ const fr: Record = {
'collab.chat.today': 'Aujourd\'hui',
'collab.chat.yesterday': 'Hier',
'collab.chat.deletedMessage': 'a supprimé un message',
+ 'collab.chat.reply': 'Répondre',
'collab.chat.loadMore': 'Charger les messages précédents',
'collab.chat.justNow': 'à l\'instant',
'collab.chat.minutesAgo': 'il y a {n} min',
diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts
index b747da7..3815d73 100644
--- a/client/src/i18n/translations/hu.ts
+++ b/client/src/i18n/translations/hu.ts
@@ -1329,6 +1329,7 @@ const hu: Record = {
'collab.chat.today': 'Ma',
'collab.chat.yesterday': 'Tegnap',
'collab.chat.deletedMessage': 'törölt egy üzenetet',
+ 'collab.chat.reply': 'Válasz',
'collab.chat.loadMore': 'Korábbi üzenetek betöltése',
'collab.chat.justNow': 'éppen most',
'collab.chat.minutesAgo': '{n} perce',
diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts
index 160d357..530ff91 100644
--- a/client/src/i18n/translations/it.ts
+++ b/client/src/i18n/translations/it.ts
@@ -1368,6 +1368,7 @@ const it: Record = {
'collab.chat.today': 'Oggi',
'collab.chat.yesterday': 'Ieri',
'collab.chat.deletedMessage': 'ha eliminato un messaggio',
+ 'collab.chat.reply': 'Rispondi',
'collab.chat.loadMore': 'Carica messaggi precedenti',
'collab.chat.justNow': 'ora',
'collab.chat.minutesAgo': '{n}m fa',
diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts
index af09c9c..7f8a985 100644
--- a/client/src/i18n/translations/nl.ts
+++ b/client/src/i18n/translations/nl.ts
@@ -1369,6 +1369,7 @@ const nl: Record = {
'collab.chat.today': 'Vandaag',
'collab.chat.yesterday': 'Gisteren',
'collab.chat.deletedMessage': 'heeft een bericht verwijderd',
+ 'collab.chat.reply': 'Beantwoorden',
'collab.chat.loadMore': 'Oudere berichten laden',
'collab.chat.justNow': 'zojuist',
'collab.chat.minutesAgo': '{n} min. geleden',
diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts
index 7f030da..cd23c2d 100644
--- a/client/src/i18n/translations/ru.ts
+++ b/client/src/i18n/translations/ru.ts
@@ -1369,6 +1369,7 @@ const ru: Record = {
'collab.chat.today': 'Сегодня',
'collab.chat.yesterday': 'Вчера',
'collab.chat.deletedMessage': 'удалил(а) сообщение',
+ 'collab.chat.reply': 'Ответить',
'collab.chat.loadMore': 'Загрузить старые сообщения',
'collab.chat.justNow': 'только что',
'collab.chat.minutesAgo': '{n} мин. назад',
diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts
index 5c5d646..0e887df 100644
--- a/client/src/i18n/translations/zh.ts
+++ b/client/src/i18n/translations/zh.ts
@@ -1369,6 +1369,7 @@ const zh: Record = {
'collab.chat.today': '今天',
'collab.chat.yesterday': '昨天',
'collab.chat.deletedMessage': '删除了一条消息',
+ 'collab.chat.reply': '回复',
'collab.chat.loadMore': '加载更早的消息',
'collab.chat.justNow': '刚刚',
'collab.chat.minutesAgo': '{n} 分钟前',
From 23edfe3dfc4f7822fed51f17739113d6f4e8a770 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=A9rnyi=20M=C3=A1rk?=
Date: Tue, 31 Mar 2026 23:33:27 +0200
Subject: [PATCH 11/14] fix: harden permissions system after code review
- Gate permissions in /app-config behind optionalAuth so unauthenticated
requests don't receive admin configuration
- Fix trip_delete isMember parameter (was hardcoded false)
- Return skipped keys from savePermissions for admin visibility
- Add disabled prop to CustomSelect, use in BudgetPanel currency picker
- Fix CollabChat reaction handler returning false instead of void
- Pass canUploadFiles as prop to NoteFormModal instead of internal store read
- Make edit-only NoteFormModal props optional (onDeleteFile, note, tripId)
- Add missing trailing newlines to .gitignore and it.ts
---
.gitignore | 2 +-
client/src/components/Budget/BudgetPanel.tsx | 3 ++-
client/src/components/Collab/CollabChat.tsx | 2 +-
client/src/components/Collab/CollabNotes.tsx | 15 ++++++++-------
client/src/components/shared/CustomSelect.tsx | 10 +++++++---
client/src/i18n/translations/it.ts | 2 +-
server/src/routes/admin.ts | 4 ++--
server/src/routes/auth.ts | 8 ++++----
server/src/routes/trips.ts | 3 ++-
server/src/services/permissions.ts | 10 +++++++---
10 files changed, 35 insertions(+), 24 deletions(-)
diff --git a/.gitignore b/.gitignore
index 57a3d9e..24ca73e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,4 +55,4 @@ coverage
.eslintcache
.cache
*.tsbuildinfo
-*.tgz
\ No newline at end of file
+*.tgz
diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx
index 349af9c..e95d990 100644
--- a/client/src/components/Budget/BudgetPanel.tsx
+++ b/client/src/components/Budget/BudgetPanel.tsx
@@ -649,7 +649,8 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
{}}
+ onChange={setCurrency}
+ disabled={!canEdit}
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
searchable
/>
diff --git a/client/src/components/Collab/CollabChat.tsx b/client/src/components/Collab/CollabChat.tsx
index 2afa755..251a443 100644
--- a/client/src/components/Collab/CollabChat.tsx
+++ b/client/src/components/Collab/CollabChat.tsx
@@ -740,7 +740,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
{msg.reactions.map(r => {
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
return (
- canEdit && handleReact(msg.id, r.emoji)} />
+ { if (canEdit) handleReact(msg.id, r.emoji) }} />
)
})}
diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx
index ccf6b7b..bee4e0d 100644
--- a/client/src/components/Collab/CollabNotes.tsx
+++ b/client/src/components/Collab/CollabNotes.tsx
@@ -218,19 +218,17 @@ function UserAvatar({ user, size = 14 }: UserAvatarProps) {
interface NoteFormModalProps {
onClose: () => void
onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise
- onDeleteFile: (noteId: number, fileId: number) => Promise
+ onDeleteFile?: (noteId: number, fileId: number) => Promise
existingCategories: string[]
categoryColors: Record
getCategoryColor: (category: string) => string
- note: CollabNote | null
- tripId: number
+ note?: CollabNote | null
+ tripId?: number
t: (key: string) => string
+ canUploadFiles?: boolean
}
-function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) {
- const can = useCanDo()
- const tripObj = useTripStore((s) => s.trip)
- const canUploadFiles = can('file_upload', tripObj)
+function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t, canUploadFiles = true }: NoteFormModalProps) {
const isEdit = !!note
const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean)
@@ -889,6 +887,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('collab_edit', trip)
+ const canUploadFiles = can('file_upload', trip)
const [notes, setNotes] = useState([])
const [loading, setLoading] = useState(true)
const [showNewModal, setShowNewModal] = useState(false)
@@ -1343,6 +1342,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
existingCategories={categories}
categoryColors={categoryColors}
getCategoryColor={getCategoryColor}
+ canUploadFiles={canUploadFiles}
t={t}
/>
)}
@@ -1358,6 +1358,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
existingCategories={categories}
categoryColors={categoryColors}
getCategoryColor={getCategoryColor}
+ canUploadFiles={canUploadFiles}
t={t}
/>
)}
diff --git a/client/src/components/shared/CustomSelect.tsx b/client/src/components/shared/CustomSelect.tsx
index 2df9c5e..2526c8a 100644
--- a/client/src/components/shared/CustomSelect.tsx
+++ b/client/src/components/shared/CustomSelect.tsx
@@ -19,6 +19,7 @@ interface CustomSelectProps {
searchable?: boolean
style?: React.CSSProperties
size?: 'sm' | 'md'
+ disabled?: boolean
}
export default function CustomSelect({
@@ -29,6 +30,7 @@ export default function CustomSelect({
searchable = false,
style = {},
size = 'md',
+ disabled = false,
}: CustomSelectProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
@@ -83,17 +85,19 @@ export default function CustomSelect({
{/* Trigger */}
{ setOpen(o => !o); setSearch('') }}
+ disabled={disabled}
+ onClick={() => { if (!disabled) { setOpen(o => !o); setSearch('') } }}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: sm ? '8px 12px' : '8px 14px', borderRadius: 10,
border: '1px solid var(--border-primary)',
background: 'var(--bg-input)', color: 'var(--text-primary)',
fontSize: 13, fontWeight: 500, fontFamily: 'inherit',
- cursor: 'pointer', outline: 'none', textAlign: 'left',
+ cursor: disabled ? 'default' : 'pointer', outline: 'none', textAlign: 'left',
transition: 'border-color 0.15s', overflow: 'hidden', minWidth: 0,
+ opacity: disabled ? 0.5 : 1,
}}
- onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
+ onMouseEnter={e => { if (!disabled) e.currentTarget.style.borderColor = 'var(--text-faint)' }}
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
>
{selected?.icon && {selected.icon}}
diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts
index 530ff91..53acacc 100644
--- a/client/src/i18n/translations/it.ts
+++ b/client/src/i18n/translations/it.ts
@@ -1470,4 +1470,4 @@ const it: Record = {
'perm.actionHint.share_manage': 'Chi può creare o eliminare link di condivisione pubblici',
}
-export default it
\ No newline at end of file
+export default it
diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts
index 1988680..674aa31 100644
--- a/server/src/routes/admin.ts
+++ b/server/src/routes/admin.ts
@@ -177,7 +177,7 @@ router.put('/permissions', (req: Request, res: Response) => {
if (!permissions || typeof permissions !== 'object') {
return res.status(400).json({ error: 'permissions object required' });
}
- savePermissions(permissions);
+ const { skipped } = savePermissions(permissions);
writeAudit({
userId: authReq.user.id,
action: 'admin.permissions_update',
@@ -185,7 +185,7 @@ router.put('/permissions', (req: Request, res: Response) => {
ip: getClientIp(req),
details: permissions,
});
- res.json({ success: true, permissions: getAllPermissions() });
+ res.json({ success: true, permissions: getAllPermissions(), ...(skipped.length ? { skipped } : {}) });
});
router.get('/audit-log', (req: Request, res: Response) => {
diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts
index abb871d..679d20d 100644
--- a/server/src/routes/auth.ts
+++ b/server/src/routes/auth.ts
@@ -10,13 +10,13 @@ import fetch from 'node-fetch';
import { authenticator } from 'otplib';
import QRCode from 'qrcode';
import { db } from '../db/database';
-import { authenticate, demoUploadBlock } from '../middleware/auth';
+import { authenticate, optionalAuth, demoUploadBlock } from '../middleware/auth';
import { JWT_SECRET } from '../config';
import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto';
import { getAllPermissions } from '../services/permissions';
import { randomBytes, createHash } from 'crypto';
import { revokeUserSessions } from '../mcp';
-import { AuthRequest, User } from '../types';
+import { AuthRequest, OptionalAuthRequest, User } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
import { decrypt_api_key, maybe_encrypt_api_key } from '../services/apiKeyCrypto';
import { startTripReminders } from '../scheduler';
@@ -171,7 +171,7 @@ function generateToken(user: { id: number | bigint }) {
);
}
-router.get('/app-config', (_req: Request, res: Response) => {
+router.get('/app-config', optionalAuth, (req: Request, res: Response) => {
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
const allowRegistration = userCount === 0 || (setting?.value ?? 'true') === 'true';
@@ -210,7 +210,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
notification_channel: notifChannel,
trip_reminders_enabled: tripRemindersEnabled,
- permissions: getAllPermissions(),
+ permissions: (req as OptionalAuthRequest).user ? getAllPermissions() : undefined,
});
});
diff --git a/server/src/routes/trips.ts b/server/src/routes/trips.ts
index 07a130b..f400a65 100644
--- a/server/src/routes/trips.ts
+++ b/server/src/routes/trips.ts
@@ -294,7 +294,8 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id) as { user_id: number } | undefined;
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const tripOwnerId = trip.user_id;
- if (!checkPermission('trip_delete', authReq.user.role, tripOwnerId, authReq.user.id, false))
+ const isMemberDel = tripOwnerId !== authReq.user.id;
+ if (!checkPermission('trip_delete', authReq.user.role, tripOwnerId, authReq.user.id, isMemberDel))
return res.status(403).json({ error: 'No permission to delete this trip' });
const deletedTripId = Number(req.params.id);
const delTrip = db.prepare('SELECT title, user_id FROM trips WHERE id = ?').get(req.params.id) as { title: string; user_id: number } | undefined;
diff --git a/server/src/services/permissions.ts b/server/src/services/permissions.ts
index 46adc6a..4912ac9 100644
--- a/server/src/services/permissions.ts
+++ b/server/src/services/permissions.ts
@@ -95,18 +95,22 @@ export function getAllPermissions(): Record {
return result;
}
-export function savePermissions(settings: Record): void {
+export function savePermissions(settings: Record): { skipped: string[] } {
+ const skipped: string[] = [];
const upsert = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
const txn = db.transaction(() => {
for (const [actionKey, level] of Object.entries(settings)) {
const action = ACTIONS_MAP.get(actionKey);
- if (!action) continue;
- if (!action.allowedLevels.includes(level as PermissionLevel)) continue;
+ if (!action || !action.allowedLevels.includes(level as PermissionLevel)) {
+ skipped.push(actionKey);
+ continue;
+ }
upsert.run(`perm_${actionKey}`, level);
}
});
txn();
invalidatePermissionsCache();
+ return { skipped };
}
/**
From 1fbc19ad4f67df83e1d99a6b6f520171c91231a0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=A9rnyi=20M=C3=A1rk?=
Date: Tue, 31 Mar 2026 23:45:11 +0200
Subject: [PATCH 12/14] fix: add missing permission checks to file routes and
map context menu
- Add checkPermission to 6 unprotected file endpoints (star, restore,
permanent delete, empty trash, link, unlink)
- Gate map right-click place creation with place_edit permission
- Use file_upload permission for collab note file uploads
---
client/src/pages/TripPlannerPage.tsx | 1 +
server/src/routes/collab.ts | 4 ++--
server/src/routes/files.ts | 12 ++++++++++++
3 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index 4de73c1..eff748f 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -169,6 +169,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
}, [])
const handleMapContextMenu = useCallback(async (e) => {
+ if (!can('place_edit', trip)) return
e.originalEvent?.preventDefault()
const { lat, lng } = e.latlng
setPrefillCoords({ lat, lng })
diff --git a/server/src/routes/collab.ts b/server/src/routes/collab.ts
index 18e51b6..98be75a 100644
--- a/server/src/routes/collab.ts
+++ b/server/src/routes/collab.ts
@@ -206,8 +206,8 @@ router.post('/notes/:id/files', authenticate, noteUpload.single('file'), (req: R
const { tripId, id } = req.params;
const access = verifyTripAccess(Number(tripId), authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
- if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
- return res.status(403).json({ error: 'No permission' });
+ if (!checkPermission('file_upload', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission to upload files' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const note = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts
index fdf2912..6da1b0a 100644
--- a/server/src/routes/files.ts
+++ b/server/src/routes/files.ts
@@ -226,6 +226,8 @@ router.patch('/:id/star', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('file_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined;
if (!file) return res.status(404).json({ error: 'File not found' });
@@ -263,6 +265,8 @@ router.post('/:id/restore', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('file_delete', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NOT NULL').get(id, tripId) as TripFile | undefined;
if (!file) return res.status(404).json({ error: 'File not found in trash' });
@@ -281,6 +285,8 @@ router.delete('/:id/permanent', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('file_delete', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NOT NULL').get(id, tripId) as TripFile | undefined;
if (!file) return res.status(404).json({ error: 'File not found in trash' });
@@ -302,6 +308,8 @@ router.delete('/trash/empty', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('file_delete', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
const trashed = db.prepare('SELECT * FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').all(tripId) as TripFile[];
for (const file of trashed) {
@@ -323,6 +331,8 @@ router.post('/:id/link', authenticate, (req: Request, res: Response) => {
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('file_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!file) return res.status(404).json({ error: 'File not found' });
@@ -346,6 +356,8 @@ router.delete('/:id/link/:linkId', authenticate, (req: Request, res: Response) =
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ if (!checkPermission('file_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
db.prepare('DELETE FROM file_links WHERE id = ? AND file_id = ?').run(linkId, id);
res.json({ success: true });
From d1ad5da9199bf6f2a8add5541c2f5590f57e3a3e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=A9rnyi=20M=C3=A1rk?=
Date: Tue, 31 Mar 2026 23:52:29 +0200
Subject: [PATCH 13/14] fix: tighten trip_edit and member_manage defaults to
trip_owner
Previously defaulted to trip_member which is more permissive than
upstream behavior. Admins can still open it up via the panel.
---
server/src/services/permissions.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/server/src/services/permissions.ts b/server/src/services/permissions.ts
index 4912ac9..2a0fddd 100644
--- a/server/src/services/permissions.ts
+++ b/server/src/services/permissions.ts
@@ -19,13 +19,13 @@ export interface PermissionAction {
export const PERMISSION_ACTIONS: PermissionAction[] = [
// Trip management
{ key: 'trip_create', defaultLevel: 'everybody', allowedLevels: ['admin', 'everybody'] },
- { key: 'trip_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
+ { key: 'trip_edit', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] },
{ key: 'trip_delete', defaultLevel: 'trip_owner', allowedLevels: ['admin', 'trip_owner'] },
{ key: 'trip_archive', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] },
{ key: 'trip_cover_upload', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] },
// Member management
- { key: 'member_manage', defaultLevel: 'trip_member', allowedLevels: ['admin', 'trip_owner', 'trip_member'] },
+ { key: 'member_manage', defaultLevel: 'trip_owner', allowedLevels: ['admin', 'trip_owner', 'trip_member'] },
// Files
{ key: 'file_upload', defaultLevel: 'trip_member', allowedLevels: ['admin', 'trip_owner', 'trip_member'] },
From 9a2c7c5db630d97f1a9d58cd22305855ce2818dc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=A9rnyi=20M=C3=A1rk?=
Date: Tue, 31 Mar 2026 23:56:19 +0200
Subject: [PATCH 14/14] fix: address PR review feedback
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Suppress note context menu when canEditDays is false instead of
showing empty menu
- Untie poll voting from collab_edit — voting is participation, not
editing; any trip member can vote
- Restore NoteFormModal props (note, tripId) to required; remove
leftover canUploadFiles prop in favor of direct zustand hook
---
client/src/components/Collab/CollabNotes.tsx | 15 ++++++++-------
client/src/components/Collab/CollabPolls.tsx | 8 ++++----
client/src/components/Planner/DayPlanSidebar.tsx | 10 +++++-----
3 files changed, 17 insertions(+), 16 deletions(-)
diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx
index bee4e0d..1486b55 100644
--- a/client/src/components/Collab/CollabNotes.tsx
+++ b/client/src/components/Collab/CollabNotes.tsx
@@ -222,13 +222,15 @@ interface NoteFormModalProps {
existingCategories: string[]
categoryColors: Record
getCategoryColor: (category: string) => string
- note?: CollabNote | null
- tripId?: number
+ note: CollabNote | null
+ tripId: number
t: (key: string) => string
- canUploadFiles?: boolean
}
-function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t, canUploadFiles = true }: NoteFormModalProps) {
+function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) {
+ const can = useCanDo()
+ const tripObj = useTripStore((s) => s.trip)
+ const canUploadFiles = can('file_upload', tripObj)
const isEdit = !!note
const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean)
@@ -887,7 +889,6 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('collab_edit', trip)
- const canUploadFiles = can('file_upload', trip)
const [notes, setNotes] = useState([])
const [loading, setLoading] = useState(true)
const [showNewModal, setShowNewModal] = useState(false)
@@ -1337,12 +1338,13 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
{showNewModal && (
setShowNewModal(false)}
onSubmit={handleCreateNote}
existingCategories={categories}
categoryColors={categoryColors}
getCategoryColor={getCategoryColor}
- canUploadFiles={canUploadFiles}
t={t}
/>
)}
@@ -1358,7 +1360,6 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
existingCategories={categories}
categoryColors={categoryColors}
getCategoryColor={getCategoryColor}
- canUploadFiles={canUploadFiles}
t={t}
/>
)}
diff --git a/client/src/components/Collab/CollabPolls.tsx b/client/src/components/Collab/CollabPolls.tsx
index dc71ff9..1a63ea1 100644
--- a/client/src/components/Collab/CollabPolls.tsx
+++ b/client/src/components/Collab/CollabPolls.tsx
@@ -270,15 +270,15 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
const isWinner = isClosed && count === Math.max(...(poll.options || []).map(o => o.voters?.length || 0)) && count > 0
return (
- !isClosed && canEdit && onVote(poll.id, idx)}
- disabled={isClosed || !canEdit}
+ !isClosed && onVote(poll.id, idx)}
+ disabled={isClosed}
style={{
position: 'relative', display: 'flex', alignItems: 'center', gap: 8,
- padding: '10px 12px', borderRadius: 10, border: 'none', cursor: (isClosed || !canEdit) ? 'default' : 'pointer',
+ padding: '10px 12px', borderRadius: 10, border: 'none', cursor: isClosed ? 'default' : 'pointer',
background: 'var(--bg-secondary)', fontFamily: FONT, textAlign: 'left', width: '100%',
overflow: 'hidden', transition: 'transform 0.1s',
}}
- onMouseEnter={e => { if (!isClosed && canEdit) e.currentTarget.style.transform = 'scale(1.01)' }}
+ onMouseEnter={e => { if (!isClosed) e.currentTarget.style.transform = 'scale(1.01)' }}
onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'}
>
{/* Progress bar background */}
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx
index 6841bbd..6081b72 100644
--- a/client/src/components/Planner/DayPlanSidebar.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.tsx
@@ -1302,11 +1302,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
}
}}
- onContextMenu={e => ctxMenu.open(e, [
- canEditDays && { label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
- canEditDays && { divider: true },
- canEditDays && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
- ])}
+ onContextMenu={canEditDays ? e => ctxMenu.open(e, [
+ { label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
+ { divider: true },
+ { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
+ ]) : undefined}
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
onMouseLeave={() => setHoveredId(null)}
style={{