From 5f71b85c067d51102f67d664c4377763bb013bd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rnyi=20M=C3=A1rk?= Date: Tue, 31 Mar 2026 22:06:52 +0200 Subject: [PATCH] feat: add client-side permission gating to all write-action UIs Gate all mutating UI elements with useCanDo() permission checks: - BudgetPanel (budget_edit), PackingListPanel (packing_edit) - DayPlanSidebar, DayDetailPanel (day_edit) - ReservationsPanel, ReservationModal (reservation_edit) - CollabNotes, CollabPolls, CollabChat (collab_edit) - FileManager (file_edit, file_delete, file_upload) - PlaceFormModal, PlaceInspector, PlacesSidebar (place_edit, file_upload) - TripFormModal (trip_edit, trip_cover_upload) - DashboardPage (trip_edit, trip_cover_upload, trip_delete, trip_archive) - TripMembersModal (member_manage, share_manage) Also: fix redundant getTripOwnerId queries in trips.ts, remove dead getTripOwnerId function, fix TripMembersModal grid when share hidden, fix canRemove logic, guard TripListItem empty actions div. --- client/src/components/Budget/BudgetPanel.tsx | 130 ++++++++++-------- client/src/components/Collab/CollabChat.tsx | 53 ++++--- client/src/components/Collab/CollabNotes.tsx | 37 +++-- client/src/components/Collab/CollabPolls.tsx | 64 +++++---- client/src/components/Files/FileManager.tsx | 19 +-- .../components/Packing/PackingListPanel.tsx | 65 ++++++--- .../src/components/Planner/DayDetailPanel.tsx | 21 +-- .../src/components/Planner/DayPlanSidebar.tsx | 50 +++---- .../src/components/Planner/PlaceFormModal.tsx | 8 +- .../src/components/Planner/PlaceInspector.tsx | 2 +- .../src/components/Planner/PlacesSidebar.tsx | 4 +- .../components/Planner/ReservationModal.tsx | 6 +- .../components/Planner/ReservationsPanel.tsx | 63 +++++---- client/src/components/Trips/TripFormModal.tsx | 19 ++- .../src/components/Trips/TripMembersModal.tsx | 4 +- client/src/pages/DashboardPage.tsx | 2 +- client/src/pages/TripPlannerPage.tsx | 7 +- 17 files changed, 333 insertions(+), 221 deletions(-) diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index a6a8a9e..349af9c 100644 --- a/client/src/components/Budget/BudgetPanel.tsx +++ b/client/src/components/Budget/BudgetPanel.tsx @@ -2,6 +2,7 @@ import ReactDOM from 'react-dom' import { useState, useEffect, useRef, useMemo, useCallback } from 'react' import DOM from 'react-dom' import { useTripStore } from '../../store/tripStore' +import { useCanDo } from '../../store/permissionsStore' import { useTranslation } from '../../i18n' import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' @@ -59,7 +60,7 @@ const calcPD = (p, d) => (d > 0 ? p / d : null) const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null) // ── Inline Edit Cell ───────────────────────────────────────────────────────── -function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip }) { +function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }) { const [editing, setEditing] = useState(false) const [editValue, setEditValue] = useState(value ?? '') const inputRef = useRef(null) @@ -86,12 +87,12 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder : (value || '') return ( -
{ setEditValue(value ?? ''); setEditing(true) }} title={editTooltip} - style={{ cursor: 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, display: 'flex', alignItems: 'center', +
{ if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip} + style={{ cursor: readOnly ? 'default' : 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, display: 'flex', alignItems: 'center', justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s', color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }} - onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} - onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> + onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }} + onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}> {display || placeholder || '-'}
) @@ -227,9 +228,10 @@ interface BudgetMemberChipsProps { onSetMembers: (memberIds: number[]) => void onTogglePaid?: (userId: number, paid: boolean) => void compact?: boolean + readOnly?: boolean } -function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true }: BudgetMemberChipsProps) { +function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) { const chipSize = compact ? 20 : 30 const btnSize = compact ? 18 : 28 const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14) @@ -271,17 +273,19 @@ function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTog {members.map(m => ( onTogglePaid(m.user_id, !m.paid) : undefined} + onClick={!readOnly && onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined} /> ))} - + {!readOnly && ( + + )} {showDropdown && ReactDOM.createPortal(
(null) const [settlementOpen, setSettlementOpen] = useState(false) const currency = trip?.currency || 'EUR' + const canEdit = can('budget_edit', trip) const fmt = (v, cur) => fmtNum(v, locale, cur) const hasMultipleMembers = tripMembers.length > 1 @@ -482,16 +488,18 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro

{t('budget.emptyTitle')}

{t('budget.emptyText')}

-
- setNewCategoryName(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleAddCategory()} - placeholder={t('budget.emptyPlaceholder')} - style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} /> - -
+ {canEdit && ( +
+ setNewCategoryName(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleAddCategory()} + placeholder={t('budget.emptyPlaceholder')} + style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} /> + +
+ )}
) } @@ -518,7 +526,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
- {editingCat?.name === cat ? ( + {canEdit && editingCat?.name === cat ? ( {cat} - + {canEdit && ( + + )} )}
{fmt(subtotal, currency)} - + {canEdit && ( + + )}
@@ -574,7 +586,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> - handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} /> + handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> {/* Mobile: larger chips under name since Persons column is hidden */} {hasMultipleMembers && (
@@ -584,12 +596,13 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)} onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)} compact={false} + readOnly={!canEdit} />
)} - handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} /> + handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> {hasMultipleMembers ? ( @@ -598,29 +611,32 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro tripMembers={tripMembers} onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)} onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)} + readOnly={!canEdit} /> ) : ( - handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} /> + handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> )} - handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} /> + handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> {pp != null ? fmt(pp, currency) : '-'} {pd != null ? fmt(pd, currency) : '-'} {ppd != null ? fmt(ppd, currency) : '-'} - handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} /> + handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> + {canEdit && ( + )} ) })} - handleAddItem(cat, data)} t={t} /> + {canEdit && handleAddItem(cat, data)} t={t} />}
@@ -633,25 +649,27 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
{}} options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))} searchable />
-
- setNewCategoryName(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }} - placeholder={t('budget.categoryName')} - style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }} - /> - -
+ {canEdit && ( +
+ setNewCategoryName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }} + placeholder={t('budget.categoryName')} + style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }} + /> + +
+ )}
s.settings.time_format) === '12h' + const can = useCanDo() + const trip = useTripStore((s) => s.trip) + const canEdit = can('collab_edit', trip) const [messages, setMessages] = useState([]) const [loading, setLoading] = useState(true) @@ -636,11 +641,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) { style={{ position: 'relative' }} onMouseEnter={() => setHoveredId(msg.id)} onMouseLeave={() => setHoveredId(null)} - onContextMenu={e => { e.preventDefault(); setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }} + onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }} onTouchEnd={e => { const now = Date.now() const lastTap = e.currentTarget.dataset.lastTap || 0 - if (now - lastTap < 300) { + if (now - lastTap < 300 && canEdit) { e.preventDefault() const touch = e.changedTouches?.[0] if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY }) @@ -703,7 +708,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) { > - {own && ( + {own && canEdit && (
@@ -780,23 +785,27 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
{/* Emoji button */} - + {canEdit && ( + + )}