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
+50 -14
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 });