diff --git a/.gitignore b/.gitignore index 595a6b5..24ca73e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ Thumbs.db # IDE .vscode/ .idea/ +.claude/ # Logs logs diff --git a/client/src/App.tsx b/client/src/App.tsx index c1b1be6..fa31363 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -14,6 +14,7 @@ import SharedTripPage from './pages/SharedTripPage' import { ToastContainer } from './components/shared/Toast' import { TranslationProvider, useTranslation } from './i18n' import { authApi } from './api/client' +import { usePermissionsStore, PermissionLevel } from './store/permissionsStore' interface ProtectedRouteProps { children: ReactNode @@ -21,7 +22,10 @@ interface ProtectedRouteProps { } function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) { - const { isAuthenticated, user, isLoading, appRequireMfa } = useAuthStore() + const isAuthenticated = useAuthStore((s) => s.isAuthenticated) + const user = useAuthStore((s) => s.user) + const isLoading = useAuthStore((s) => s.isLoading) + const appRequireMfa = useAuthStore((s) => s.appRequireMfa) const { t } = useTranslation() const location = useLocation() @@ -78,12 +82,13 @@ export default function App() { if (token) { loadUser() } - authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean }) => { + authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record }) => { if (config?.demo_mode) setDemoMode(true) if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key) if (config?.timezone) setServerTimezone(config.timezone) if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa) if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled) + if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions) if (config?.version) { const storedVersion = localStorage.getItem('trek_app_version') diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 0fd46f4..9c414d0 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -184,6 +184,8 @@ export const adminApi = { apiClient.get('/admin/audit-log', { params }).then(r => r.data), mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data), deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data), + getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data), + updatePermissions: (permissions: Record) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data), } export const addonsApi = { diff --git a/client/src/components/Admin/PermissionsPanel.tsx b/client/src/components/Admin/PermissionsPanel.tsx new file mode 100644 index 0000000..3b3af47 --- /dev/null +++ b/client/src/components/Admin/PermissionsPanel.tsx @@ -0,0 +1,169 @@ +import React, { useEffect, useState, useMemo } from 'react' +import { adminApi } from '../../api/client' +import { useTranslation } from '../../i18n' +import { usePermissionsStore, PermissionLevel } from '../../store/permissionsStore' +import { useToast } from '../shared/Toast' +import { Save, Loader2, RotateCcw } from 'lucide-react' +import CustomSelect from '../shared/CustomSelect' + +interface PermissionEntry { + key: string + level: PermissionLevel + defaultLevel: PermissionLevel + allowedLevels: PermissionLevel[] +} + +const LEVEL_LABELS: Record = { + admin: 'perm.level.admin', + trip_owner: 'perm.level.tripOwner', + trip_member: 'perm.level.tripMember', + everybody: 'perm.level.everybody', +} + +const CATEGORIES = [ + { id: 'trip', keys: ['trip_create', 'trip_edit', 'trip_delete', 'trip_archive', 'trip_cover_upload'] }, + { id: 'members', keys: ['member_manage'] }, + { id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] }, + { id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] }, + { id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] }, +] + +export default function PermissionsPanel(): React.ReactElement { + const { t } = useTranslation() + const toast = useToast() + const [entries, setEntries] = useState([]) + const [values, setValues] = useState>({}) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [dirty, setDirty] = useState(false) + + useEffect(() => { + loadPermissions() + }, []) + + const loadPermissions = async () => { + setLoading(true) + try { + const data = await adminApi.getPermissions() + setEntries(data.permissions) + const vals: Record = {} + for (const p of data.permissions) vals[p.key] = p.level + setValues(vals) + setDirty(false) + } catch { + toast.error(t('common.error')) + } finally { + setLoading(false) + } + } + + const handleChange = (key: string, level: PermissionLevel) => { + setValues(prev => ({ ...prev, [key]: level })) + setDirty(true) + } + + const handleSave = async () => { + setSaving(true) + try { + const data = await adminApi.updatePermissions(values) + if (data.permissions) { + usePermissionsStore.getState().setPermissions(data.permissions) + } + setDirty(false) + toast.success(t('perm.saved')) + } catch { + toast.error(t('common.error')) + } finally { + setSaving(false) + } + } + + const handleReset = () => { + const defaults: Record = {} + for (const p of entries) defaults[p.key] = p.defaultLevel + setValues(defaults) + setDirty(true) + } + + const entryMap = useMemo(() => new Map(entries.map(e => [e.key, e])), [entries]) + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+
+
+
+

{t('perm.title')}

+

{t('perm.subtitle')}

+
+
+ + +
+
+ +
+ {CATEGORIES.map(cat => ( +
+

+ {t(`perm.cat.${cat.id}`)} +

+
+ {cat.keys.map(key => { + const entry = entryMap.get(key) + if (!entry) return null + const currentLevel = values[key] || entry.defaultLevel + const isDefault = currentLevel === entry.defaultLevel + return ( +
+
+

{t(`perm.action.${key}`)}

+

{t(`perm.actionHint.${key}`)}

+
+
+ {!isDefault && ( + + {t('perm.customized')} + + )} + handleChange(key, val as PermissionLevel)} + options={entry.allowedLevels.map(l => ({ + value: l, + label: t(LEVEL_LABELS[l] || l), + }))} + /> +
+
+ ) + })} +
+
+ ))} +
+
+
+ ) +} diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index a6a8a9e..e95d990 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} />}
@@ -634,24 +650,27 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro ({ 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 }) @@ -692,7 +697,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) { transition: 'opacity .1s', ...(own ? { left: -6 } : { right: -6 }), }}> - - {own && ( -
@@ -780,23 +785,27 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
{/* Emoji button */} - + {canEdit && ( + + )}