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 */} -
+ {canManageShare &&
-
+
}
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()}> - - } + {onDelete && + }
)} @@ -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 { > - + } @@ -783,12 +785,12 @@ export default function DashboardPage(): React.ReactElement {

{t('dashboard.emptyText')}

- + } )} @@ -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} /> ))} - + {!readOnly && ( + + )} {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} - + {canEdit && ( + + )} )}
{fmt(subtotal, currency)} - + {canEdit && ( + + )}
@@ -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 && ( + )} ) })} - 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 && (
@@ -780,23 +785,27 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
{/* Emoji button */} - + {canEdit && ( + + )}