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