Files
TREK/server/src/routes/admin.ts
T
Julien G. 1f5deeba6c Bug fixes - April 27th 2026 (#907)
* fix: clean up dangling FK references before deleting a user

Resolves FOREIGN KEY constraint failed (500) on DELETE /api/admin/users/:id
and DELETE /api/auth/me when the target user had rows in trip_members.invited_by,
share_tokens.created_by, budget_items.paid_by_user_id, journeys.user_id,
journey_entries.author_id, journey_contributors.user_id, or
journey_share_tokens.created_by — none of which had ON DELETE clauses.

Introduces deleteUserCompletely() in userCleanupService.ts which wraps all
cleanup and the final DELETE FROM users in a single transaction. Both
adminService.deleteUser and authService.deleteAccount now call it instead of
the bare DELETE. Tests ADMIN-005b and AUTH-040 cover all reference types
including notification sender/recipient and notice dismissals.

* test: extend FK deletion tests to cover journeys, files, and photos

ADMIN-005b and AUTH-040 now also seed and assert:
- owned journey with entries (cascade-deleted via journeys.user_id cleanup)
- trip_files.uploaded_by (SET NULL — file survives, attribution cleared)
- trek_photos.owner_id (SET NULL — photo record survives, owner cleared)
- trip_photos.user_id (CASCADE — photo association removed)

* test: extend user deletion tests to cover all FK relationships

ADMIN-005b and AUTH-040 now seed and assert every user FK relationship:

CASCADE (row deleted): trips, trip_members, tags, mcp_tokens, oauth_tokens,
oauth_consents, vacay_plans, vacay_plan_members, bucket_list,
visited_countries, visited_regions, packing_templates, invite_tokens,
collab_notes, settings, password_reset_tokens, notification_channel_preferences

SET NULL (row survives, column nulled): categories, todo_items.assigned_user_id,
packing_bags, audit_log

Caught and fixed: notification_preferences was dropped in migration 72;
correct table is notification_channel_preferences.

* fix: preserve URL hash and OIDC redirect target through login flow

- Include location.hash in redirect param at all three producer sites
  (ProtectedRoute, axios 401 interceptor, OAuthAuthorizePage) so
  hash fragments survive the login bounce
- Stash redirectTarget in sessionStorage before any OIDC provider
  redirect and restore it after the code exchange, since the IdP
  strips the original ?redirect= param during the roundtrip
- Clear sessionStorage on OIDC error to avoid stale state
- Add tests covering sessionStorage stash on mount, navigate to saved
  redirect after OIDC exchange, fallback to /dashboard, and cleanup
  on error

* fix: use day position instead of ID for accommodation date range clamping

Math.min/Math.max over raw day IDs breaks the start/end picker when a
trip's day IDs are non-monotonic relative to day_number (normal after
repeated generateDays extend/shrink cycles). Replaced with findIndex
lookups so clamping is always based on positional order.

Closes #889

* fix: normalize env var comparisons to be case-insensitive

All NODE_ENV, DEMO_MODE, OIDC_ONLY, FORCE_HTTPS, COOKIE_SECURE, and
ALLOW_INTERNAL_NETWORK checks now use .toLowerCase() so values like
'Production' or 'True' behave identically to their lowercase forms.
Also adds APP_VERSION to the startup banner.

* fix: delete surplus days when shortening a trip

When shrinking a trip's date range, surplus days are now deleted along
with their assignments, notes, and accommodations (cascade). Places
remain in the trip pool; reservations keep their day reference nulled
by the existing ON DELETE SET NULL constraint (issue #909).

Updates TRIP-SVC-011 to reflect the new behaviour; adds TRIP-SVC-016
as a regression test for the empty-day case.

* fix: auto-backup retention deletes itself and manual backups on Docker

Two bugs in cleanupOldBackups:
1. Filter was .endsWith('.zip') — swept manual backup-*.zip files too.
   Now restricted to auto-backup-* prefix.
2. Age was derived from stat.birthtimeMs, which is 0 on overlayfs
   (Docker default), making every backup appear epoch-old and get
   deleted immediately. Age is now parsed from the filename timestamp
   and falls back to mtimeMs (reliable on overlayfs).

Also converts inline require('./services/auditLog') calls to a static
import throughout scheduler.ts, and adds 8 unit tests covering the
fixed retention logic including the overlayfs regression case.

* test: update TRIP-024 to match delete behavior on trip shrink

* feat: add bypass-branch-check label to skip branch enforcement
2026-04-28 05:17:20 +02:00

476 lines
19 KiB
TypeScript

import express, { Request, Response } from 'express';
import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest } from '../types';
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
import * as svc from '../services/adminService';
import { getAdminUserDefaults, setAdminUserDefaults } from '../services/settingsService';
import { invalidateMcpSessions } from '../mcp';
import { getPreferencesMatrix, setAdminPreferences } from '../services/notificationPreferencesService';
const router = express.Router();
router.use(authenticate, adminOnly);
// ── User CRUD ──────────────────────────────────────────────────────────────
router.get('/users', (_req: Request, res: Response) => {
res.json({ users: svc.listUsers() });
});
router.post('/users', (req: Request, res: Response) => {
const result = svc.createUser(req.body);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.user_create',
resource: String(result.insertedId),
ip: getClientIp(req),
details: result.auditDetails,
});
res.status(201).json({ user: result.user });
});
router.put('/users/:id', (req: Request, res: Response) => {
const result = svc.updateUser(req.params.id, req.body);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.user_update',
resource: String(req.params.id),
ip: getClientIp(req),
details: { targetUser: result.previousEmail, fields: result.changed },
});
logInfo(`Admin ${authReq.user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`);
res.json({ user: result.user });
});
router.delete('/users/:id', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.deleteUser(req.params.id, authReq.user.id);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
writeAudit({
userId: authReq.user.id,
action: 'admin.user_delete',
resource: String(req.params.id),
ip: getClientIp(req),
details: { targetUser: result.email },
});
logInfo(`Admin ${authReq.user.email} deleted user ${result.email}`);
res.json({ success: true });
});
// ── Stats ──────────────────────────────────────────────────────────────────
router.get('/stats', (_req: Request, res: Response) => {
res.json(svc.getStats());
});
// ── Permissions ────────────────────────────────────────────────────────────
router.get('/permissions', (_req: Request, res: Response) => {
res.json(svc.getPermissions());
});
router.put('/permissions', (req: Request, res: Response) => {
const { permissions } = req.body;
if (!permissions || typeof permissions !== 'object') {
return res.status(400).json({ error: 'permissions object required' });
}
const authReq = req as AuthRequest;
const result = svc.savePermissions(permissions);
writeAudit({
userId: authReq.user.id,
action: 'admin.permissions_update',
resource: 'permissions',
ip: getClientIp(req),
details: permissions,
});
res.json({ success: true, permissions: result.permissions, ...(result.skipped.length ? { skipped: result.skipped } : {}) });
});
// ── Audit Log ──────────────────────────────────────────────────────────────
router.get('/audit-log', (req: Request, res: Response) => {
res.json(svc.getAuditLog(req.query as { limit?: string; offset?: string }));
});
// ── OIDC Settings ──────────────────────────────────────────────────────────
router.get('/oidc', (_req: Request, res: Response) => {
res.json(svc.getOidcSettings());
});
router.put('/oidc', (req: Request, res: Response) => {
const result = svc.updateOidcSettings(req.body);
if (result.error) {
return res.status(result.status || 400).json({ error: result.error });
}
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.oidc_update',
ip: getClientIp(req),
details: { issuer_set: !!req.body.issuer },
});
res.json({ success: true });
});
// ── Demo Baseline ──────────────────────────────────────────────────────────
router.post('/save-demo-baseline', (req: Request, res: Response) => {
const result = svc.saveDemoBaseline();
if (result.error) return res.status(result.status!).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({ userId: authReq.user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
res.json({ success: true, message: result.message });
});
// ── GitHub / Version ───────────────────────────────────────────────────────
router.get('/github-releases', async (req: Request, res: Response) => {
const { per_page = '10', page = '1' } = req.query;
res.json(await svc.getGithubReleases(String(per_page), String(page)));
});
router.get('/version-check', async (_req: Request, res: Response) => {
res.json(await svc.checkVersion());
});
// ── Admin notification preferences ────────────────────────────────────────
router.get('/notification-preferences', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'admin'));
});
router.put('/notification-preferences', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
setAdminPreferences(authReq.user.id, req.body);
res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'admin'));
});
// ── Invite Tokens ──────────────────────────────────────────────────────────
router.get('/invites', (_req: Request, res: Response) => {
res.json({ invites: svc.listInvites() });
});
router.post('/invites', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.createInvite(authReq.user.id, req.body);
writeAudit({
userId: authReq.user.id,
action: 'admin.invite_create',
resource: String(result.inviteId),
ip: getClientIp(req),
details: { max_uses: result.uses, expires_in_days: result.expiresInDays },
});
res.status(201).json({ invite: result.invite });
});
router.delete('/invites/:id', (req: Request, res: Response) => {
const result = svc.deleteInvite(req.params.id);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.invite_delete',
resource: String(req.params.id),
ip: getClientIp(req),
});
res.json({ success: true });
});
// ── Bag Tracking ───────────────────────────────────────────────────────────
router.get('/bag-tracking', (_req: Request, res: Response) => {
res.json(svc.getBagTracking());
});
router.put('/bag-tracking', (req: Request, res: Response) => {
const result = svc.updateBagTracking(req.body.enabled);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.bag_tracking',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
res.json(result);
});
// ── Places Photos ───────────────────────────────────────────────────────
router.get('/places-photos', (_req: Request, res: Response) => {
res.json(svc.getPlacesPhotos());
});
router.put('/places-photos', (req: Request, res: Response) => {
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
const result = svc.updatePlacesPhotos(req.body.enabled);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.places_photos',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
res.json(result);
});
// ── Places Autocomplete ──────────────────────────────────────────────────
router.get('/places-autocomplete', (_req: Request, res: Response) => {
res.json(svc.getPlacesAutocomplete());
});
router.put('/places-autocomplete', (req: Request, res: Response) => {
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
const result = svc.updatePlacesAutocomplete(req.body.enabled);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.places_autocomplete',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
res.json(result);
});
// ── Places Details ───────────────────────────────────────────────────────
router.get('/places-details', (_req: Request, res: Response) => {
res.json(svc.getPlacesDetails());
});
router.put('/places-details', (req: Request, res: Response) => {
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
const result = svc.updatePlacesDetails(req.body.enabled);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.places_details',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
res.json(result);
});
// ── Collab Features ───────────────────────────────────────────────────────
router.get('/collab-features', (_req: Request, res: Response) => {
res.json(svc.getCollabFeatures());
});
router.put('/collab-features', (req: Request, res: Response) => {
const result = svc.updateCollabFeatures(req.body);
invalidateMcpSessions();
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.collab_features',
ip: getClientIp(req),
details: result,
});
res.json(result);
});
// ── Packing Templates ──────────────────────────────────────────────────────
router.get('/packing-templates', (_req: Request, res: Response) => {
res.json({ templates: svc.listPackingTemplates() });
});
router.get('/packing-templates/:id', (req: Request, res: Response) => {
const result = svc.getPackingTemplate(req.params.id);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
res.json(result);
});
router.post('/packing-templates', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.createPackingTemplate(req.body.name, authReq.user.id);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
res.status(201).json(result);
});
router.put('/packing-templates/:id', (req: Request, res: Response) => {
const result = svc.updatePackingTemplate(req.params.id, req.body);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
res.json(result);
});
router.delete('/packing-templates/:id', (req: Request, res: Response) => {
const result = svc.deletePackingTemplate(req.params.id);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.packing_template_delete',
resource: String(req.params.id),
ip: getClientIp(req),
details: { name: result.name },
});
res.json({ success: true });
});
// Template categories
router.post('/packing-templates/:id/categories', (req: Request, res: Response) => {
const result = svc.createTemplateCategory(req.params.id, req.body.name);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
res.status(201).json(result);
});
router.put('/packing-templates/:templateId/categories/:catId', (req: Request, res: Response) => {
const result = svc.updateTemplateCategory(req.params.templateId, req.params.catId, req.body);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
res.json(result);
});
router.delete('/packing-templates/:templateId/categories/:catId', (req: Request, res: Response) => {
const result = svc.deleteTemplateCategory(req.params.templateId, req.params.catId);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
res.json({ success: true });
});
// Template items
router.post('/packing-templates/:templateId/categories/:catId/items', (req: Request, res: Response) => {
const result = svc.createTemplateItem(req.params.templateId, req.params.catId, req.body.name);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
res.status(201).json(result);
});
router.put('/packing-templates/:templateId/items/:itemId', (req: Request, res: Response) => {
const result = svc.updateTemplateItem(req.params.itemId, req.body);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
res.json(result);
});
router.delete('/packing-templates/:templateId/items/:itemId', (req: Request, res: Response) => {
const result = svc.deleteTemplateItem(req.params.itemId);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
res.json({ success: true });
});
// ── Addons ─────────────────────────────────────────────────────────────────
router.get('/addons', (_req: Request, res: Response) => {
res.json({ addons: svc.listAddons() });
});
router.put('/addons/:id', (req: Request, res: Response) => {
const result = svc.updateAddon(req.params.id, req.body);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.addon_update',
resource: String(req.params.id),
ip: getClientIp(req),
details: result.auditDetails,
});
// Invalidate all MCP sessions so they re-create with the updated addon tool set
invalidateMcpSessions();
res.json({ addon: result.addon });
});
// ── MCP Tokens ─────────────────────────────────────────────────────────────
router.get('/mcp-tokens', (_req: Request, res: Response) => {
res.json({ tokens: svc.listMcpTokens() });
});
router.delete('/mcp-tokens/:id', (req: Request, res: Response) => {
const result = svc.deleteMcpToken(req.params.id);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
res.json({ success: true });
});
// ── OAuth Sessions ─────────────────────────────────────────────────────────
router.get('/oauth-sessions', (_req: Request, res: Response) => {
res.json({ sessions: svc.listOAuthSessions() });
});
router.delete('/oauth-sessions/:id', (req: Request, res: Response) => {
const result = svc.revokeOAuthSession(req.params.id);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.oauth_session.revoke',
resource: String(req.params.id),
ip: getClientIp(req),
});
res.json({ success: true });
});
// ── JWT Rotation ───────────────────────────────────────────────────────────
router.post('/rotate-jwt-secret', (req: Request, res: Response) => {
const result = svc.rotateJwtSecret();
if (result.error) return res.status(result.status!).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.rotate_jwt_secret',
ip: getClientIp(req),
});
res.json({ success: true });
});
// ── Default User Settings ──────────────────────────────────────────────────────
router.get('/default-user-settings', (_req: Request, res: Response) => {
res.json(getAdminUserDefaults());
});
router.put('/default-user-settings', (req: Request, res: Response) => {
if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
return res.status(400).json({ error: 'Object body required' });
}
try {
setAdminUserDefaults(req.body);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.default_user_settings_update',
ip: getClientIp(req),
details: req.body,
});
res.json(getAdminUserDefaults());
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// ── Dev-only: test notification endpoints ──────────────────────────────────────
if (process.env.NODE_ENV?.toLowerCase() === 'development') {
const { send } = require('../services/notificationService');
router.post('/dev/test-notification', async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { event = 'trip_reminder', scope = 'user', targetId, params = {}, inApp } = req.body;
try {
await send({
event,
actorId: authReq.user.id,
scope,
targetId: targetId ?? authReq.user.id,
params: { actor: authReq.user.email, ...params },
inApp,
});
res.json({ success: true });
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
}
export default router;