Files
TREK/server/src/nest/admin/admin.controller.ts
T
Maurice 06e6e25b25 feat(tours): OpenHikingMap as a hiking base map of the tour planner (MAP-01)
While a hike is planned, the planner's map switch offers "Hiking" beside the
map, topo and satellite: OpenHikingMap, with marked trails over the terrain.
It is a choice of the planner's view alone, like topo: the global map
setting, the tour and its days stay as they are. With no tour open, over an
import viewed as it came in and for a paddle tour it is not offered, and a
choice made earlier falls back to the standard map there.

openmaps.fr's tile usage policy is followed: tiles load only as the map
shows them, with the browser's Referer, from the server's own caching; no
service worker rule keeps them and the 3D flyover's warm-up passes their
source by. Its credit and donation link are on the map; the 3D map, whose
attribution is folded away under the panel, shows them as a line of its own
above its controls.

The service is best effort. A run of failed tiles while it is shown marks
the switch, with a tooltip and a status for screen readers that say it is
not answering and that the tour is untouched; one tile arriving clears it.
The tile health the seamark overlay had is now useTileHealth, shared by both.

The source is free for non-commercial installs up to 500,000 tiles a month.
tours_hiking_map_enabled is an admin switch under Tours (admin tours-hiking-map,
audited), on by default, for an install that is commercial or larger to
switch off until it has an agreement with the operator. /api/addons carries
it as toursHikingMap, true only while Tours is on.

Leaflet, GL and the 3D map read one table of the planner's raster bases
(tourRasterBases), so topo and the hiking map are drawn the same way.
tile.openmaps.fr is on the CSP's connect-src for the GL maps.
2026-10-19 13:07:36 +02:00

574 lines
19 KiB
TypeScript

import { readEnv } from '../../app-config';
import type { User } from '../../types';
import { AddonsService } from '../addons/addons.service';
import { logInfo } from '../audit/audit-log.logger';
import { AuditService } from '../audit/audit.service';
import { getClientIp } from '../audit/client-ip';
import { AdminGuard } from '../auth-core/admin.guard';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { RegistrationInvitesService } from '../auth/registration-invites.service';
import { KitineraryExtractorService } from '../booking-import/kitinerary-extractor.service';
import { ManagedForbidden } from '../common/managed';
import { ResponseContract } from '../common/response-contract';
import { NotificationsService } from '../notifications/notifications.service';
import { OauthService } from '../oauth/oauth.service';
import { PluginRuntimeService } from '../plugins/plugin-runtime.service';
import { TokenService } from '../tokens/token.service';
import {
AdminUserCreateDto,
AdminUserUpdateDto,
AdminPermissionsDto,
AdminInviteCreateDto,
AdminFeatureToggleDto,
AdminAddonUpdateDto,
AdminCollabFeaturesDto,
AdminTestNotificationDto,
AdminTransitProviderDto,
} from './admin.dto';
import { AdminService } from './admin.service';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpException,
NotFoundException,
Param,
Post,
Put,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { adminFeatureToggleResponseSchema } from '@trek/shared';
import type { Request } from 'express';
/**
* /api/admin — admin-only control surface (users, stats, permissions, audit log,
* OIDC settings, invites, feature toggles, packing templates, addons, MCP/OAuth
* sessions, JWT rotation, default user settings).
*
* Byte-identical to the legacy Express route (server/src/routes/admin.ts):
* admin-gated, the {error,status} envelopes, the audit-log writes, the MCP
* session invalidation on addon/collab changes, create-201 vs the rest 200, and
* the dev-only test-notification endpoint (404 outside development).
*
* Bodies validate via admin.dto.ts (@trek/shared schemas through the global
* ZodValidationPipe) — malformed bodies now get the pipe's standard
* { error: 'field: message; …' } envelope instead of the old inline typeof
* checks (the sanctioned ratchet behavior); valid bodies behave identically,
* and every bespoke service-level 400 is untouched.
*/
@Controller('api/admin')
@UseGuards(JwtAuthGuard, AdminGuard)
export class AdminController {
constructor(
private readonly admin: AdminService,
private readonly addons: AddonsService,
private readonly pluginRuntime: PluginRuntimeService,
private readonly audit: AuditService,
private readonly notifications: NotificationsService,
private readonly tokens: TokenService,
private readonly invites: RegistrationInvitesService,
private readonly oauth: OauthService,
private readonly kitinerary: KitineraryExtractorService,
) {}
// ── Users ──
@Get('users')
async listUsers() {
return { users: await this.admin.listUsers() };
}
@Post('users')
@HttpCode(201)
async createUser(@CurrentUser() user: User, @Body() body: AdminUserCreateDto, @Req() req: Request) {
const result = await this.admin.createUser(body as Parameters<AdminService['createUser']>[0]);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.user_create',
resource: String(result.insertedId),
ip: getClientIp(req),
details: result.auditDetails,
});
return { user: result.user };
}
@Put('users/:id')
async updateUser(
@CurrentUser() user: User,
@Param('id') id: string,
@Body() body: AdminUserUpdateDto,
@Req() req: Request,
) {
const result = await this.admin.updateUser(id, body);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.user_update',
resource: String(id),
ip: getClientIp(req),
details: { targetUser: result.previousEmail, fields: result.changed },
});
logInfo(`Admin ${user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`);
return { user: result.user };
}
@Delete('users/:id')
async deleteUser(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = await this.admin.deleteUser(id, user.id);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.user_delete',
resource: String(id),
ip: getClientIp(req),
details: { targetUser: result.email },
});
logInfo(`Admin ${user.email} deleted user ${result.email}`);
return { success: true };
}
@Delete('users/:id/passkeys')
async resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = await this.admin.resetUserPasskeys(id);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.user_passkeys_reset',
resource: String(id),
ip: getClientIp(req),
details: { targetUser: result.email, deleted: result.deleted },
});
return { success: true, deleted: result.deleted };
}
@Delete('users/:id/mfa')
async resetUserMfa(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = await this.admin.resetUserMfa(id, user.id);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.user_mfa_reset',
resource: String(id),
ip: getClientIp(req),
details: { targetUser: result.email },
});
return { success: true };
}
// ── Stats / permissions / audit ──
@Get('stats')
stats() {
return this.admin.getStats();
}
@Get('permissions')
permissions() {
return this.admin.getPermissions();
}
@Put('permissions')
async savePermissions(@CurrentUser() user: User, @Body() body: AdminPermissionsDto, @Req() req: Request) {
const result = await this.admin.savePermissions(
body.permissions as unknown as Parameters<AdminService['savePermissions']>[0],
);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.permissions_update',
resource: 'permissions',
ip: getClientIp(req),
details: body.permissions as Record<string, unknown>,
});
return {
success: true,
permissions: result.permissions,
...(result.skipped.length ? { skipped: result.skipped } : {}),
};
}
@Get('audit-log')
auditLog(@Query() query: { limit?: string; offset?: string }) {
return this.admin.getAuditLog(query);
}
@Post('save-demo-baseline')
@HttpCode(200)
async saveDemoBaseline(@CurrentUser() user: User, @Req() req: Request) {
const result = await this.admin.saveDemoBaseline();
await this.audit.writeAudit({ userId: user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
return { success: true, message: result.message };
}
// ── GitHub / version ──
@Get('github-releases')
async githubReleases(@Query('per_page') perPage = '10', @Query('page') page = '1') {
return this.admin.getGithubReleases(String(perPage), String(page));
}
@Get('version-check')
async versionCheck() {
return this.admin.checkVersion();
}
/**
* Versions of the bundled tools an operator cannot see from the UI. Today the
* only one is the KItinerary extractor, whose age decides which booking
* providers parse at all — and a stale one is indistinguishable from an
* unsupported provider without this (#2261).
*/
@Get('system-info')
systemInfo() {
return { kitinerary: this.kitinerary.describe() };
}
// ── Invites ──
@Get('invites')
async listInvites() {
return { invites: await this.invites.listInvites() };
}
// Trips an admin can optionally bind a registration invite to (#1402).
@Get('invites/trips')
async listInviteTrips() {
return { trips: await this.invites.listTripsForInvite() };
}
@Post('invites')
@HttpCode(201)
async createInvite(@CurrentUser() user: User, @Body() body: AdminInviteCreateDto, @Req() req: Request) {
const result = await this.invites.createInvite(user.id, body);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.invite_create',
resource: String(result.inviteId),
ip: getClientIp(req),
details: { max_uses: result.uses, expires_in_days: result.expiresInDays, trip_id: result.tripId },
});
return { invite: result.invite };
}
@Delete('invites/:id')
async deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
await this.invites.deleteInvite(id);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.invite_delete',
resource: String(id),
ip: getClientIp(req),
});
return { success: true };
}
// ── Feature toggles ──
// Straight to AddonsService, which owns every one of these flags. They used to go
// through eleven-plus pass-through methods on AdminService, and the three places
// flags were raw app_settings SQL there even though the other flags lived in the
// addons domain. The routes stay HERE rather than moving next to the service: an
// admin-gated controller in AddonsModule would make that module import AuthModule,
// and PluginGuardsModule imports AddonsModule precisely because it is a leaf — the
// edge would close a cycle.
@Get('bag-tracking')
getBagTracking() {
return this.addons.getBagTracking();
}
@Put('bag-tracking')
async updateBagTracking(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = await this.addons.updateBagTracking(body.enabled);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.bag_tracking',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
return result;
}
@Get('tours-wanderer')
@ResponseContract(adminFeatureToggleResponseSchema)
getToursWanderer() {
return this.addons.getToursWanderer();
}
@Put('tours-wanderer')
@ResponseContract(adminFeatureToggleResponseSchema)
async updateToursWanderer(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = await this.addons.updateToursWanderer(body.enabled);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.tours_wanderer',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
return result;
}
@Get('tours-hiking-map')
@ResponseContract(adminFeatureToggleResponseSchema)
getToursHikingMap() {
return this.addons.getToursHikingMap();
}
@Put('tours-hiking-map')
@ResponseContract(adminFeatureToggleResponseSchema)
async updateToursHikingMap(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = await this.addons.updateToursHikingMap(body.enabled);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.tours_hiking_map',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
return result;
}
@Get('places-photos')
getPlacesPhotos() {
return this.addons.getPlacesPhotos();
}
@Put('places-photos')
async updatePlacesPhotos(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = await this.addons.updatePlacesPhotos(body.enabled);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.places_photos',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
return result;
}
@Get('places-autocomplete')
getPlacesAutocomplete() {
return this.addons.getPlacesAutocomplete();
}
@Put('places-autocomplete')
async updatePlacesAutocomplete(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = await this.addons.updatePlacesAutocomplete(body.enabled);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.places_autocomplete',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
return result;
}
@Get('places-details')
getPlacesDetails() {
return this.addons.getPlacesDetails();
}
@Put('places-details')
async updatePlacesDetails(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = await this.addons.updatePlacesDetails(body.enabled);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.places_details',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
return result;
}
@Get('place-shadow')
getPlaceShadow() {
return this.addons.getPlaceShadow();
}
@Put('place-shadow')
async updatePlaceShadow(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = await this.addons.updatePlaceShadow(body.enabled);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.place_shadow',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
return result;
}
@Get('places-google-only')
getPlacesGoogleOnly() {
return this.addons.getPlacesGoogleOnly();
}
@Put('places-google-only')
async updatePlacesGoogleOnly(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = await this.addons.updatePlacesGoogleOnly(body.enabled);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.places_google_only',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
return result;
}
@Get('places-enrich')
getPlacesEnrich() {
return this.addons.getPlacesEnrich();
}
@Put('places-enrich')
async updatePlacesEnrich(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = await this.addons.updatePlacesEnrich(body.enabled);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.places_enrich',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
return result;
}
@Get('collab-features')
getCollabFeatures() {
return this.addons.getCollabFeatures();
}
@Put('collab-features')
async updateCollabFeatures(@CurrentUser() user: User, @Body() body: AdminCollabFeaturesDto, @Req() req: Request) {
const { features, changed } = await this.addons.updateCollabFeatures(body);
// Collab flags gate MCP registration, but a no-op save must not tear down
// every live MCP session (#1414).
if (changed) this.admin.invalidateMcpSessions();
await this.audit.writeAudit({
userId: user.id,
action: 'admin.collab_features',
ip: getClientIp(req),
details: features,
});
return features;
}
// Which backend answers /api/transit (#1699). Transitous unless an admin
// says otherwise, and still Transitous if Google is picked without a key.
@Get('transit-provider')
getTransitProvider(@CurrentUser() user: User) {
return this.addons.getTransitProvider(user.id);
}
@Put('transit-provider')
async updateTransitProvider(@CurrentUser() user: User, @Body() body: AdminTransitProviderDto, @Req() req: Request) {
const result = await this.addons.updateTransitProvider(body.provider, user.id);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.transit_provider',
ip: getClientIp(req),
details: { provider: result.provider },
});
return result;
}
// ── Addons ──
@Get('addons')
async listAddons() {
return { addons: await this.admin.listAddons() };
}
@Put('addons/:id')
async updateAddon(
@CurrentUser() user: User,
@Param('id') id: string,
@Body() body: AdminAddonUpdateDto,
@Req() req: Request,
) {
const result = await this.admin.updateAddon(id, body);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.addon_update',
resource: String(id),
ip: getClientIp(req),
details: result.auditDetails,
});
// Sessions only need re-creating when the registered MCP surface can
// actually change — an enabled-flip of an MCP-relevant addon. Config-only
// saves and photo-provider toggles used to kill every session (#1414).
if (result.mcpAffected) this.admin.invalidateMcpSessions();
// Disabling an addon cascades to plugins that require it: stop them (and any
// plugin that transitively depends on them) so a plugin never runs against a
// disabled addon (#plugins dependencies).
if (result.addon && result.addon.enabled === false && (body as { enabled?: boolean })?.enabled === false) {
const stopped = await this.pluginRuntime.deactivateForDisabledAddon(id);
// A stopped plugin takes its MCP tools off the surface, and the addon that
// cascaded here is not necessarily an MCP-relevant one, so mcpAffected
// above does not cover this. Still conditional on something actually
// stopping, which keeps the #1414 rule that a no-op save kills nothing.
if (stopped.length && !result.mcpAffected) this.admin.invalidateMcpSessions();
}
return { addon: result.addon };
}
// ── MCP tokens / OAuth sessions ──
@Get('mcp-tokens')
async listMcpTokens() {
return { tokens: await this.tokens.listAllMcpTokens() };
}
@Delete('mcp-tokens/:id')
async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
await this.tokens.adminDeleteMcpToken(id);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.mcp_token_delete',
resource: String(id),
ip: getClientIp(req),
});
return { success: true };
}
@Get('oauth-sessions')
async listOAuthSessions() {
return { sessions: await this.oauth.listAllOAuthSessions() };
}
@Delete('oauth-sessions/:id')
async revokeOAuthSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
await this.oauth.adminRevokeOAuthSession(id);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.oauth_session_revoke',
resource: String(id),
ip: getClientIp(req),
});
return { success: true };
}
// ── JWT rotation ──
@ManagedForbidden('rotating the secret signs every user out and fixes nothing the admin can reach')
@Post('rotate-jwt-secret')
@HttpCode(200)
async rotateJwtSecret(@CurrentUser() user: User, @Req() req: Request) {
this.admin.rotateJwtSecret();
await this.audit.writeAudit({ userId: user.id, action: 'admin.rotate_jwt_secret', ip: getClientIp(req) });
return { success: true };
}
// ── Default user settings ──
// ── Dev-only: test notification (404 outside development, mirroring the conditional mount) ──
@Post('dev/test-notification')
@HttpCode(200)
async devTestNotification(@CurrentUser() user: User, @Body() body: AdminTestNotificationDto) {
if (!readEnv().app.isDevelopment) {
throw new NotFoundException();
}
try {
await this.notifications.send({
event: body.event ?? 'trip_reminder',
actorId: user.id,
scope: body.scope ?? 'user',
targetId: body.targetId ?? user.id,
params: { actor: user.email, ...(body.params ?? {}) },
inApp: body.inApp,
} as unknown as Parameters<NotificationsService['send']>[0]);
return { success: true };
} catch (err) {
throw new HttpException({ error: err instanceof Error ? err.message : String(err) }, 400);
}
}
}