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

View File

@@ -128,4 +128,9 @@ function isOwner(tripId: number | string, userId: number): boolean {
return !!_db!.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
}
export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner };
function getTripOwnerId(tripId: number | string): number | undefined {
const row = _db!.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined;
return row?.user_id;
}
export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner, getTripOwnerId };

View File

@@ -8,6 +8,7 @@ import { db } from '../db/database';
import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest, User, Addon } from '../types';
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
import { getAllPermissions, savePermissions, PERMISSION_ACTIONS } from '../services/permissions';
import { revokeUserSessions } from '../mcp';
const router = express.Router();
@@ -158,6 +159,35 @@ router.get('/stats', (_req: Request, res: Response) => {
res.json({ totalUsers, totalTrips, totalPlaces, totalFiles });
});
// Permissions management
router.get('/permissions', (_req: Request, res: Response) => {
const current = getAllPermissions();
const actions = PERMISSION_ACTIONS.map(a => ({
key: a.key,
level: current[a.key],
defaultLevel: a.defaultLevel,
allowedLevels: a.allowedLevels,
}));
res.json({ permissions: actions });
});
router.put('/permissions', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { permissions } = req.body;
if (!permissions || typeof permissions !== 'object') {
return res.status(400).json({ error: 'permissions object required' });
}
savePermissions(permissions);
writeAudit({
userId: authReq.user.id,
action: 'admin.permissions_update',
resource: 'permissions',
ip: getClientIp(req),
details: permissions,
});
res.json({ success: true, permissions: getAllPermissions() });
});
router.get('/audit-log', (req: Request, res: Response) => {
const limitRaw = parseInt(String(req.query.limit || '100'), 10);
const offsetRaw = parseInt(String(req.query.offset || '0'), 10);

View File

@@ -4,6 +4,7 @@ import { authenticate } from '../middleware/auth';
import { requireTripAccess } from '../middleware/tripAccess';
import { broadcast } from '../websocket';
import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from '../services/queryHelpers';
import { checkPermission } from '../services/permissions';
import { AuthRequest, AssignmentRow, DayAssignment, Tag, Participant } from '../types';
const router = express.Router({ mergeParams: true });
@@ -110,6 +111,10 @@ router.get('/trips/:tripId/days/:dayId/assignments', authenticate, requireTripAc
});
router.post('/trips/:tripId/days/:dayId/assignments', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { tripId, dayId } = req.params;
const { place_id, notes } = req.body;
@@ -132,6 +137,10 @@ router.post('/trips/:tripId/days/:dayId/assignments', authenticate, requireTripA
});
router.delete('/trips/:tripId/days/:dayId/assignments/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { tripId, dayId, id } = req.params;
const assignment = db.prepare(
@@ -146,6 +155,10 @@ router.delete('/trips/:tripId/days/:dayId/assignments/:id', authenticate, requir
});
router.put('/trips/:tripId/days/:dayId/assignments/reorder', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { tripId, dayId } = req.params;
const { orderedIds } = req.body;
@@ -168,6 +181,10 @@ router.put('/trips/:tripId/days/:dayId/assignments/reorder', authenticate, requi
});
router.put('/trips/:tripId/assignments/:id/move', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { tripId, id } = req.params;
const { new_day_id, order_index } = req.body;
@@ -204,6 +221,10 @@ router.get('/trips/:tripId/assignments/:id/participants', authenticate, requireT
});
router.put('/trips/:tripId/assignments/:id/time', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { tripId, id } = req.params;
const assignment = db.prepare(`
@@ -223,6 +244,10 @@ router.put('/trips/:tripId/assignments/:id/time', authenticate, requireTripAcces
});
router.put('/trips/:tripId/assignments/:id/participants', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { tripId, id } = req.params;
const { user_ids } = req.body;

View File

@@ -13,6 +13,7 @@ import { db } from '../db/database';
import { authenticate, demoUploadBlock } from '../middleware/auth';
import { JWT_SECRET } from '../config';
import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto';
import { getAllPermissions } from '../services/permissions';
import { randomBytes, createHash } from 'crypto';
import { revokeUserSessions } from '../mcp';
import { AuthRequest, User } from '../types';
@@ -209,6 +210,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
notification_channel: notifChannel,
trip_reminders_enabled: tripRemindersEnabled,
permissions: getAllPermissions(),
});
});

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, BudgetItem, BudgetItemMember } from '../types';
const router = express.Router({ mergeParams: true });
@@ -83,6 +84,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('budget_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: 'Name is required' });
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?').get(tripId) as { max: number | null };
@@ -115,6 +121,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('budget_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 budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Budget item not found' });
@@ -150,6 +161,11 @@ router.put('/:id/members', authenticate, (req: Request, res: Response) => {
const { tripId, id } = req.params;
if (!canAccessTrip(Number(tripId), authReq.user.id)) 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('budget_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 budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Budget item not found' });
@@ -180,6 +196,11 @@ router.put('/:id/members/:userId/paid', authenticate, (req: Request, res: Respon
const { tripId, id, userId } = req.params;
if (!canAccessTrip(Number(tripId), authReq.user.id)) 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('budget_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { paid } = req.body;
db.prepare('UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?')
.run(paid ? 1 : 0, id, userId);
@@ -273,6 +294,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('budget_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 budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Budget item not found' });

View File

@@ -3,10 +3,11 @@ import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
import { db, canAccessTrip } from '../db/database';
import { db, canAccessTrip, getTripOwnerId } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { validateStringLengths } from '../middleware/validate';
import { checkPermission } from '../services/permissions';
import { AuthRequest, CollabNote, CollabPoll, CollabMessage, TripFile } from '../types';
interface ReactionRow {
@@ -112,6 +113,10 @@ router.post('/notes', authenticate, (req: Request, res: Response) => {
const { tripId } = req.params;
const { title, content, category, color, website } = req.body;
if (!verifyTripAccess(tripId, authReq.user.id)) 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('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!title) return res.status(400).json({ error: 'Title is required' });
const result = db.prepare(`
@@ -138,6 +143,10 @@ router.put('/notes/:id', authenticate, (req: Request, res: Response) => {
const { tripId, id } = req.params;
const { title, content, category, color, pinned, website } = req.body;
if (!verifyTripAccess(tripId, authReq.user.id)) 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('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const existing = db.prepare('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!existing) return res.status(404).json({ error: 'Note not found' });
@@ -175,6 +184,10 @@ router.delete('/notes/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
if (!verifyTripAccess(tripId, authReq.user.id)) 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('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const existing = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!existing) return res.status(404).json({ error: 'Note not found' });
@@ -195,6 +208,10 @@ router.post('/notes/:id/files', authenticate, noteUpload.single('file'), (req: R
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
if (!verifyTripAccess(Number(tripId), authReq.user.id)) 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('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const note = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
@@ -213,6 +230,10 @@ router.delete('/notes/:id/files/:fileId', authenticate, (req: Request, res: Resp
const authReq = req as AuthRequest;
const { tripId, id, fileId } = req.params;
if (!verifyTripAccess(Number(tripId), authReq.user.id)) 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('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND note_id = ?').get(fileId, id) as TripFile | undefined;
if (!file) return res.status(404).json({ error: 'File not found' });
@@ -278,6 +299,10 @@ router.post('/polls', authenticate, (req: Request, res: Response) => {
const { tripId } = req.params;
const { question, options, multiple, multiple_choice, deadline } = req.body;
if (!verifyTripAccess(tripId, authReq.user.id)) 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('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!question) return res.status(400).json({ error: 'Question is required' });
if (!Array.isArray(options) || options.length < 2) {
return res.status(400).json({ error: 'At least 2 options are required' });
@@ -300,6 +325,10 @@ router.post('/polls/:id/vote', authenticate, (req: Request, res: Response) => {
const { tripId, id } = req.params;
const { option_index } = req.body;
if (!verifyTripAccess(tripId, authReq.user.id)) 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('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId) as CollabPoll | undefined;
if (!poll) return res.status(404).json({ error: 'Poll not found' });
@@ -332,6 +361,10 @@ router.put('/polls/:id/close', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
if (!verifyTripAccess(tripId, authReq.user.id)) 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('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
@@ -347,6 +380,10 @@ router.delete('/polls/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
if (!verifyTripAccess(tripId, authReq.user.id)) 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('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const poll = db.prepare('SELECT id FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
@@ -401,6 +438,10 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r
const { tripId } = req.params;
const { text, reply_to } = req.body;
if (!verifyTripAccess(tripId, authReq.user.id)) 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('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!text || !text.trim()) return res.status(400).json({ error: 'Message text is required' });
if (reply_to) {
@@ -439,6 +480,10 @@ router.post('/messages/:id/react', authenticate, (req: Request, res: Response) =
const { tripId, id } = req.params;
const { emoji } = req.body;
if (!verifyTripAccess(Number(tripId), authReq.user.id)) 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('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!emoji) return res.status(400).json({ error: 'Emoji is required' });
const msg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(id, tripId);
@@ -460,6 +505,10 @@ router.delete('/messages/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
if (!verifyTripAccess(tripId, authReq.user.id)) 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('collab_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const message = db.prepare('SELECT * FROM collab_messages WHERE id = ? AND trip_id = ?').get(id, tripId) as CollabMessage | undefined;
if (!message) return res.status(404).json({ error: 'Message not found' });

View File

@@ -1,8 +1,9 @@
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 { validateStringLengths } from '../middleware/validate';
import { checkPermission } from '../services/permissions';
import { AuthRequest, DayNote } from '../types';
const router = express.Router({ mergeParams: true });
@@ -28,6 +29,11 @@ router.post('/', authenticate, validateStringLengths({ text: 500, time: 150 }),
const { tripId, dayId } = req.params;
if (!verifyAccess(tripId, authReq.user.id)) 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('day_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
if (!day) return res.status(404).json({ error: 'Day not found' });
@@ -48,6 +54,11 @@ router.put('/:id', authenticate, validateStringLengths({ text: 500, time: 150 })
const { tripId, dayId, id } = req.params;
if (!verifyAccess(tripId, authReq.user.id)) 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('day_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const note = db.prepare('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId) as DayNote | undefined;
if (!note) return res.status(404).json({ error: 'Note not found' });
@@ -72,6 +83,11 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const { tripId, dayId, id } = req.params;
if (!verifyAccess(tripId, authReq.user.id)) 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('day_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const note = db.prepare('SELECT id FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId);
if (!note) return res.status(404).json({ error: 'Note not found' });

View File

@@ -4,6 +4,7 @@ import { authenticate } from '../middleware/auth';
import { requireTripAccess } from '../middleware/tripAccess';
import { broadcast } from '../websocket';
import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from '../services/queryHelpers';
import { checkPermission } from '../services/permissions';
import { AuthRequest, AssignmentRow, Day, DayNote } from '../types';
const router = express.Router({ mergeParams: true });
@@ -126,6 +127,10 @@ router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) =
});
router.post('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { tripId } = req.params;
const { date, notes } = req.body;
@@ -144,6 +149,10 @@ router.post('/', authenticate, requireTripAccess, (req: Request, res: Response)
});
router.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { tripId, id } = req.params;
const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId) as Day | undefined;
@@ -161,6 +170,10 @@ router.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response
});
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { tripId, id } = req.params;
const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId);
@@ -199,6 +212,10 @@ accommodationsRouter.get('/', authenticate, requireTripAccess, (req: Request, re
});
accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { tripId } = req.params;
const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = req.body;
@@ -243,6 +260,10 @@ accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, r
});
accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { tripId, id } = req.params;
interface DayAccommodation { id: number; trip_id: number; place_id: number; start_day_id: number; end_day_id: number; check_in: string | null; check_out: string | null; confirmation: string | null; notes: string | null; }
@@ -294,6 +315,10 @@ accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request,
});
accommodationsRouter.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { tripId, id } = req.params;
const existing = db.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(id, tripId);

View File

@@ -10,6 +10,7 @@ import { authenticate, demoUploadBlock } from '../middleware/auth';
import { requireTripAccess } from '../middleware/tripAccess';
import { broadcast } from '../websocket';
import { AuthRequest, TripFile } from '../types';
import { checkPermission } from '../services/permissions';
const router = express.Router({ mergeParams: true });
@@ -157,6 +158,9 @@ router.get('/', authenticate, (req: Request, res: Response) => {
router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single('file'), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { user_id: tripOwnerId } = authReq.trip!;
if (!checkPermission('file_upload', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission to upload files' });
const { place_id, description, reservation_id } = req.body;
if (!req.file) {
@@ -189,8 +193,10 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const { tripId, id } = req.params;
const { description, place_id, reservation_id } = req.body;
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const access = canAccessTrip(tripId, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('file_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission to edit files' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined;
if (!file) return res.status(404).json({ error: 'File not found' });
@@ -237,8 +243,10 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const access = canAccessTrip(tripId, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('file_delete', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission to delete files' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined;
if (!file) return res.status(404).json({ error: 'File not found' });

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) => {

View File

@@ -7,6 +7,7 @@ import { requireTripAccess } from '../middleware/tripAccess';
import { broadcast } from '../websocket';
import { loadTagsByPlaceIds } from '../services/queryHelpers';
import { validateStringLengths } from '../middleware/validate';
import { checkPermission } from '../services/permissions';
import { AuthRequest, Place } from '../types';
const gpxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
@@ -76,7 +77,11 @@ router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) =
});
router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
const { tripId } = req.params
const authReq = req as AuthRequest;
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { tripId } = req.params
const {
name, description, lat, lng, address, category_id, price, currency,
@@ -117,6 +122,10 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
// Import places from GPX file with full track geometry (must be before /:id)
router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('file'), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { tripId } = req.params;
const file = (req as any).file;
if (!file) return res.status(400).json({ error: 'No file uploaded' });
@@ -259,7 +268,11 @@ router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, r
});
router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
const { tripId, id } = req.params
const authReq = req as AuthRequest;
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { tripId, id } = req.params
const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined;
if (!existingPlace) {
@@ -331,7 +344,11 @@ router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name
});
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, id } = req.params
const authReq = req as AuthRequest;
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { tripId, id } = req.params
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!place) {

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, Reservation } from '../types';
const router = express.Router({ mergeParams: true });
@@ -40,6 +41,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('reservation_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!title) return res.status(400).json({ error: 'Title is required' });
// Auto-create accommodation for hotel reservations
@@ -118,6 +124,11 @@ router.put('/positions', 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('reservation_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' });
const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?');
@@ -140,6 +151,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('reservation_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as Reservation | undefined;
if (!reservation) return res.status(404).json({ error: 'Reservation not found' });
@@ -236,6 +252,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('reservation_edit', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const reservation = db.prepare('SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as { id: number; title: string; type: string; accommodation_id: number | null } | undefined;
if (!reservation) return res.status(404).json({ error: 'Reservation not found' });

View File

@@ -1,7 +1,8 @@
import express, { Request, Response } from 'express';
import crypto from 'crypto';
import { db, canAccessTrip } from '../db/database';
import { db, canAccessTrip, getTripOwnerId } from '../db/database';
import { authenticate } from '../middleware/auth';
import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
import { loadTagsByPlaceIds } from '../services/queryHelpers';
@@ -12,6 +13,10 @@ router.post('/trips/:tripId/share-link', authenticate, (req: Request, res: Respo
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(tripId, authReq.user.id)) 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('share_manage', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { share_map = true, share_bookings = true, share_packing = false, share_budget = false, share_collab = false } = req.body || {};
@@ -45,6 +50,10 @@ router.delete('/trips/:tripId/share-link', authenticate, (req: Request, res: Res
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(tripId, authReq.user.id)) 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('share_manage', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId);
res.json({ success: true });

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 });

View File

@@ -0,0 +1,145 @@
import { db } from '../db/database';
/**
* Permission levels (hierarchical, higher includes lower):
* admin > trip_owner > trip_member > everybody
*
* "everybody" means any authenticated user with trip access.
* For trip_create, "everybody" means any authenticated user (no trip context).
*/
export type PermissionLevel = 'admin' | 'trip_owner' | 'trip_member' | 'everybody';
export interface PermissionAction {
key: string;
defaultLevel: PermissionLevel;
allowedLevels: PermissionLevel[];
}
// All configurable actions with their defaults matching upstream behavior
export const PERMISSION_ACTIONS: PermissionAction[] = [
// Trip management
{ key: 'trip_create', defaultLevel: 'everybody', allowedLevels: ['admin', 'everybody'] },
{ key: 'trip_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
{ key: 'trip_delete', defaultLevel: 'trip_owner', allowedLevels: ['admin', 'trip_owner'] },
{ key: 'trip_archive', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] },
{ key: 'trip_cover_upload', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] },
// Member management
{ key: 'member_manage', defaultLevel: 'trip_member', allowedLevels: ['admin', 'trip_owner', 'trip_member'] },
// Files
{ key: 'file_upload', defaultLevel: 'trip_member', allowedLevels: ['admin', 'trip_owner', 'trip_member'] },
{ key: 'file_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
{ key: 'file_delete', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
// Places
{ key: 'place_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
// Budget
{ key: 'budget_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
// Packing
{ key: 'packing_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
// Reservations
{ key: 'reservation_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
// Day notes & schedule
{ key: 'day_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
// Collaboration (notes, polls, messages)
{ key: 'collab_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
// Share link management
{ key: 'share_manage', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] },
];
const ACTIONS_MAP = new Map(PERMISSION_ACTIONS.map(a => [a.key, a]));
// In-memory cache, invalidated on save
let cache: Map<string, PermissionLevel> | null = null;
function loadPermissions(): Map<string, PermissionLevel> {
if (cache) return cache;
cache = new Map<string, PermissionLevel>();
try {
const rows = db.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'perm_%'").all() as { key: string; value: string }[];
for (const row of rows) {
const actionKey = row.key.replace('perm_', '');
if (ACTIONS_MAP.has(actionKey)) {
cache.set(actionKey, row.value as PermissionLevel);
}
}
} catch { /* table might not exist yet during init */ }
return cache;
}
export function invalidatePermissionsCache(): void {
cache = null;
}
export function getPermissionLevel(actionKey: string): PermissionLevel {
const perms = loadPermissions();
const stored = perms.get(actionKey);
if (stored) return stored;
const action = ACTIONS_MAP.get(actionKey);
return action?.defaultLevel ?? 'trip_owner';
}
export function getAllPermissions(): Record<string, PermissionLevel> {
const perms = loadPermissions();
const result: Record<string, PermissionLevel> = {};
for (const action of PERMISSION_ACTIONS) {
result[action.key] = perms.get(action.key) ?? action.defaultLevel;
}
return result;
}
export function savePermissions(settings: Record<string, string>): void {
const upsert = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
const txn = db.transaction(() => {
for (const [actionKey, level] of Object.entries(settings)) {
const action = ACTIONS_MAP.get(actionKey);
if (!action) continue;
if (!action.allowedLevels.includes(level as PermissionLevel)) continue;
upsert.run(`perm_${actionKey}`, level);
}
});
txn();
invalidatePermissionsCache();
}
/**
* Check if a user passes the permission check for a given action.
*
* @param actionKey - The permission action key
* @param userRole - 'admin' | 'user'
* @param tripUserId - The trip owner's user ID (null for non-trip actions like trip_create)
* @param userId - The requesting user's ID
* @param isMember - Whether the user is a trip member (not owner)
*/
export function checkPermission(
actionKey: string,
userRole: string,
tripUserId: number | null,
userId: number,
isMember: boolean
): boolean {
// Admins always pass
if (userRole === 'admin') return true;
const required = getPermissionLevel(actionKey);
switch (required) {
case 'admin':
return false; // already checked above
case 'trip_owner':
return tripUserId !== null && tripUserId === userId;
case 'trip_member':
return (tripUserId !== null && tripUserId === userId) || isMember;
case 'everybody':
return true;
default:
return false;
}
}