mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
b53130ca0d
A sign-in with another subject of the same provider is refused, so an account whose provider re-issued the subject (a migrated or rebuilt IdP, a changed sub format) was locked out: editing the email, as the wiki advised, never touched the stored link. The edit dialog now offers "Remove SSO link" for other accounts while SSO is set up (DELETE /api/admin/users/:id/sso, audit admin.user_sso_unlink). The next SSO sign-in links again under the rules of a first one, so it still needs a verified, not self-set email. The log line names the remedy, and the wiki tells the two refusals apart. Fixes #3065
631 lines
21 KiB
TypeScript
631 lines
21 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 };
|
|
}
|
|
|
|
@Delete('users/:id/sso')
|
|
async unlinkUserSso(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
|
const result = await this.admin.unlinkUserSso(id, user.id);
|
|
if (result.unlinked) {
|
|
await this.audit.writeAudit({
|
|
userId: user.id,
|
|
action: 'admin.user_sso_unlink',
|
|
resource: String(id),
|
|
ip: getClientIp(req),
|
|
details: { targetUser: result.email },
|
|
});
|
|
logInfo(`Admin ${user.email} removed the SSO link of user ${result.email}`);
|
|
}
|
|
return { success: true, unlinked: result.unlinked };
|
|
}
|
|
|
|
// ── 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)
|
|
@ManagedForbidden('wanderer sends searches to a server the user picks, which the operator has not agreed to')
|
|
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('tours-cycle-map')
|
|
@ResponseContract(adminFeatureToggleResponseSchema)
|
|
getToursCycleMap() {
|
|
return this.addons.getToursCycleMap();
|
|
}
|
|
|
|
@Put('tours-cycle-map')
|
|
@ResponseContract(adminFeatureToggleResponseSchema)
|
|
async updateToursCycleMap(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
|
|
const result = await this.addons.updateToursCycleMap(body.enabled);
|
|
await this.audit.writeAudit({
|
|
userId: user.id,
|
|
action: 'admin.tours_cycle_map',
|
|
ip: getClientIp(req),
|
|
details: { enabled: result.enabled },
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@Get('tours-ski-routing')
|
|
@ResponseContract(adminFeatureToggleResponseSchema)
|
|
getToursSkiRouting() {
|
|
return this.addons.getToursSkiRouting();
|
|
}
|
|
|
|
@Put('tours-ski-routing')
|
|
@ResponseContract(adminFeatureToggleResponseSchema)
|
|
async updateToursSkiRouting(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
|
|
const result = await this.addons.updateToursSkiRouting(body.enabled);
|
|
await this.audit.writeAudit({
|
|
userId: user.id,
|
|
action: 'admin.tours_ski_routing',
|
|
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')
|
|
@ManagedForbidden('place search belongs to the operator on a managed install')
|
|
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')
|
|
@ManagedForbidden('routing belongs to the operator on a managed install')
|
|
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.username, ...(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);
|
|
}
|
|
}
|
|
}
|