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
+49 -1
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) => {