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