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:
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
145
server/src/services/permissions.ts
Normal file
145
server/src/services/permissions.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user