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
This commit is contained in:
Gérnyi Márk
2026-03-31 20:30:12 +02:00
parent ff1c1ed56a
commit 7d3b37a2a3
36 changed files with 1384 additions and 84 deletions

View File

@@ -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<string, PermissionLevel> }) => {
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')

View File

@@ -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<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
}
export const addonsApi = {

View File

@@ -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<string, string> = {
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<PermissionEntry[]>([])
const [values, setValues] = useState<Record<string, PermissionLevel>>({})
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<string, PermissionLevel> = {}
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<string, PermissionLevel> = {}
for (const p of entries) defaults[p.key] = p.defaultLevel
setValues(defaults)
setDirty(true)
}
if (loading) {
return (
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" />
</div>
)
}
const entryMap = useMemo(() => new Map(entries.map(e => [e.key, e])), [entries])
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
<div>
<h2 className="font-semibold text-slate-900">{t('perm.title')}</h2>
<p className="text-xs text-slate-400 mt-0.5">{t('perm.subtitle')}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleReset}
disabled={saving}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" />
{t('perm.resetDefaults')}
</button>
<button
onClick={handleSave}
disabled={saving || !dirty}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:bg-slate-400 transition-colors"
>
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
{t('common.save')}
</button>
</div>
</div>
<div className="divide-y divide-slate-100">
{CATEGORIES.map(cat => (
<div key={cat.id} className="px-6 py-4">
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">
{t(`perm.cat.${cat.id}`)}
</h3>
<div className="space-y-3">
{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 (
<div key={key} className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-700">{t(`perm.action.${key}`)}</p>
<p className="text-xs text-slate-400 mt-0.5">{t(`perm.actionHint.${key}`)}</p>
</div>
<div className="flex items-center gap-2">
{!isDefault && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700">
{t('perm.customized')}
</span>
)}
<CustomSelect
value={currentLevel}
onChange={(val) => handleChange(key, val as PermissionLevel)}
options={entry.allowedLevels.map(l => ({
value: l,
label: t(LEVEL_LABELS[l] || l),
}))}
/>
</div>
</div>
)
})}
</div>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -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<TripFile[]>([])
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 */}
<div
{can('file_upload', trip) && <div
{...getRootProps()}
style={{
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
@@ -735,7 +739,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</p>
</>
)}
</div>
</div>}
{/* Filter tabs */}
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>

View File

@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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({
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Kopfbereich */}
<div style={{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
<button
{canEditPlaces && <button
onClick={onAddPlace}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
@@ -98,7 +102,8 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
}}
>
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
</button>
</button>}
{canEditPlaces && <>
<input ref={gpxInputRef} type="file" accept=".gpx" style={{ display: 'none' }} onChange={handleGpxImport} />
<button
onClick={() => gpxInputRef.current?.click()}
@@ -112,6 +117,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
>
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')}
</button>
</>}
{/* Filter-Tabs */}
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
@@ -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,

View File

@@ -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, string | number>) => string }) {
const [shareToken, setShareToken] = useState<string | null>(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 }:
</div>
{/* Add member dropdown */}
<div>
{canManageMembers && <div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 8 }}>
{t('members.inviteUser')}
</label>
@@ -293,10 +299,10 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
<UserPlus size={13} /> {adding ? '…' : t('members.invite')}
</button>
</div>
{availableUsers.length === 0 && allUsers.length > 0 && (
{availableUsers.length === 0 && allUsers.length > 0 && canManageMembers && (
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '6px 0 0' }}>{t('members.allHaveAccess')}</p>
)}
</div>
</div>}
{/* Members list */}
<div>
@@ -317,7 +323,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{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 (
<div key={member.id} style={{
display: 'flex', alignItems: 'center', gap: 10,
@@ -358,9 +364,9 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
</div>
{/* Right column: Share Link */}
<div style={{ borderLeft: '1px solid var(--border-faint)', paddingLeft: 24 }}>
{canManageShare && <div style={{ borderLeft: '1px solid var(--border-faint)', paddingLeft: 24 }}>
<ShareLinkSection tripId={tripId} t={t} />
</div>
</div>}
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
</div>

View File

@@ -1423,6 +1423,55 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'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

View File

@@ -1402,6 +1402,55 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'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

View File

@@ -1423,6 +1423,55 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'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

View File

@@ -1420,6 +1420,55 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'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

View File

@@ -1416,6 +1416,55 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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

View File

@@ -1425,6 +1425,55 @@ const es: Record<string, string> = {
// 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

View File

@@ -1419,6 +1419,55 @@ const fr: Record<string, string> = {
'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

View File

@@ -1418,6 +1418,55 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'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

View File

@@ -1418,6 +1418,55 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'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

View File

@@ -1419,6 +1419,55 @@ const nl: Record<string, string> = {
'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

View File

@@ -1419,6 +1419,55 @@ const ru: Record<string, string> = {
'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

View File

@@ -1419,6 +1419,55 @@ const zh: Record<string, string> = {
'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

View File

@@ -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 {
</div>
)}
{activeTab === 'permissions' && <PermissionsPanel />}
{activeTab === 'backup' && <BackupPanel />}
{activeTab === 'audit' && <AuditLogPanel serverTimezone={serverTimezone} />}

View File

@@ -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, string | number | null>) => string
locale: string
@@ -188,12 +189,12 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
</div>
{/* Top-right actions */}
{(!!trip.is_owner || isAdmin) && (
{(onEdit || onArchive || onDelete) && (
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 6 }}
onClick={e => e.stopPropagation()}>
<IconBtn onClick={() => onEdit(trip)} title={t('common.edit')}><Edit2 size={14} /></IconBtn>
<IconBtn onClick={() => onArchive(trip.id)} title={t('dashboard.archive')}><Archive size={14} /></IconBtn>
<IconBtn onClick={() => onDelete(trip)} title={t('common.delete')} danger><Trash2 size={14} /></IconBtn>
{onEdit && <IconBtn onClick={() => onEdit(trip)} title={t('common.edit')}><Edit2 size={14} /></IconBtn>}
{onArchive && <IconBtn onClick={() => onArchive(trip.id)} title={t('dashboard.archive')}><Archive size={14} /></IconBtn>}
{onDelete && <IconBtn onClick={() => onDelete(trip)} title={t('common.delete')} danger><Trash2 size={14} /></IconBtn>}
</div>
)}
@@ -309,12 +310,12 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, isAdm
<Stat label={t('dashboard.places')} value={trip.place_count || 0} />
</div>
{(!!trip.is_owner || isAdmin) && (
{(onEdit || onArchive || onDelete) && (
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid #f3f4f6', paddingTop: 10 }}
onClick={e => e.stopPropagation()}>
<CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label={t('common.edit')} />
<CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label={t('dashboard.archive')} />
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label={t('common.delete')} danger />
{onEdit && <CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label={t('common.edit')} />}
{onArchive && <CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label={t('dashboard.archive')} />}
{onDelete && <CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label={t('common.delete')} danger />}
</div>
)}
</div>
@@ -411,9 +412,9 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale, i
{/* Actions */}
{(!!trip.is_owner || isAdmin) && (
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
<CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label="" />
<CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label="" />
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label="" danger />
{onEdit && <CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label="" />}
{onArchive && <CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label="" />}
{onDelete && <CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label="" danger />}
</div>
)}
</div>
@@ -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, string | number | null>) => string
locale: string
@@ -460,16 +461,16 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale,
</div>
{(!!trip.is_owner || isAdmin) && (
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
<button onClick={() => onUnarchive(trip.id)} title={t('dashboard.restore')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)' }}
{onUnarchive && <button onClick={() => onUnarchive(trip.id)} title={t('dashboard.restore')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-muted)' }}>
<ArchiveRestore size={12} /> {t('dashboard.restore')}
</button>
<button onClick={() => onDelete(trip)} title={t('common.delete')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-faint)' }}
</button>}
{onDelete && <button onClick={() => onDelete(trip)} title={t('common.delete')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-faint)' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#fecaca'; e.currentTarget.style.color = '#ef4444' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
<Trash2 size={12} />
</button>
</button>}
</div>
)}
</div>
@@ -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 {
>
<Settings size={15} />
</button>
<button
{can('trip_create') && <button
onClick={() => { setEditingTrip(null); setShowForm(true) }}
style={{
display: 'flex', alignItems: 'center', gap: 7, padding: '9px 18px',
@@ -718,7 +720,7 @@ export default function DashboardPage(): React.ReactElement {
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
<Plus size={15} /> {t('dashboard.newTrip')}
</button>
</button>}
</div>
</div>
@@ -783,12 +785,12 @@ export default function DashboardPage(): React.ReactElement {
<p style={{ margin: '0 0 24px', fontSize: 14, color: '#9ca3af', maxWidth: 340, marginLeft: 'auto', marginRight: 'auto' }}>
{t('dashboard.emptyText')}
</p>
<button
{can('trip_create') && <button
onClick={() => { setEditingTrip(null); setShowForm(true) }}
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 22px', background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
>
<Plus size={16} /> {t('dashboard.emptyButton')}
</button>
</button>}
</div>
)}
@@ -797,9 +799,9 @@ export default function DashboardPage(): React.ReactElement {
<SpotlightCard
trip={spotlight}
t={t} locale={locale} dark={dark} isAdmin={isAdmin}
onEdit={tr => { 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}`)}
/>
))}

View File

@@ -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<string, PermissionLevel>
setPermissions: (perms: Record<string, PermissionLevel>) => void
}
export const usePermissionsStore = create<PermissionsState>((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
}
}
}

View File

@@ -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 };

View File

@@ -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);

View File

@@ -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;

View File

@@ -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(),
});
});

View File

@@ -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' });

View File

@@ -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' });

View File

@@ -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' });

View File

@@ -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);

View File

@@ -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' });

View File

@@ -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) => {

View File

@@ -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) {

View File

@@ -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' });

View File

@@ -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 });

View File

@@ -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<User, 'id' | 'username' | 'email' | 'avatar'>;
const owner = db.prepare('SELECT id, username, email, avatar FROM users WHERE id = ?').get(tripOwnerId) as Pick<User, 'id' | 'username' | 'email' | 'avatar'>;
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 });

View File

@@ -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<string, PermissionLevel> | null = null;
function loadPermissions(): Map<string, PermissionLevel> {
if (cache) return cache;
cache = new Map<string, PermissionLevel>();
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<string, PermissionLevel> {
const perms = loadPermissions();
const result: Record<string, PermissionLevel> = {};
for (const action of PERMISSION_ACTIONS) {
result[action.key] = perms.get(action.key) ?? action.defaultLevel;
}
return result;
}
export function savePermissions(settings: Record<string, string>): 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;
}
}