Spaces:
Sleeping
Sleeping
| import { Body, Controller, Delete, Get, HttpCode, HttpException, NotFoundException, Param, Post, Put, Query, Req, UseGuards } from '@nestjs/common'; | |
| import type { Request } from 'express'; | |
| import { AdminService } from './admin.service'; | |
| import { JwtAuthGuard } from '../auth/jwt-auth.guard'; | |
| import { AdminGuard } from '../auth/admin.guard'; | |
| import { CurrentUser } from '../auth/current-user.decorator'; | |
| import { writeAudit, getClientIp, logInfo } from '../../services/auditLog'; | |
| import { send as sendNotification } from '../../services/notificationService'; | |
| import type { User } from '../../types'; | |
| /** Throw the legacy {error,status} envelope when a service call reports failure. */ | |
| function ok<T>(result: T): Exclude<T, { error: string }> { | |
| if (result && typeof result === 'object' && 'error' in (result as Record<string, unknown>)) { | |
| const r = result as unknown as { error: string; status?: number }; | |
| throw new HttpException({ error: r.error }, r.status ?? 400); | |
| } | |
| return result as Exclude<T, { error: string }>; | |
| } | |
| /** | |
| * /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). | |
| */ | |
| ('api/admin') | |
| (JwtAuthGuard, AdminGuard) | |
| export class AdminController { | |
| constructor(private readonly admin: AdminService) {} | |
| // ββ Users ββ | |
| ('users') | |
| listUsers() { return { users: this.admin.listUsers() }; } | |
| ('users') | |
| (201) | |
| createUser(() user: User, () body: unknown, () req: Request) { | |
| const result = ok(this.admin.createUser(body)); | |
| writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails }); | |
| return { user: result.user }; | |
| } | |
| ('users/:id') | |
| updateUser(() user: User, ('id') id: string, () body: unknown, () req: Request) { | |
| const result = ok(this.admin.updateUser(id, body)); | |
| 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 }; | |
| } | |
| ('users/:id') | |
| deleteUser(() user: User, ('id') id: string, () req: Request) { | |
| const result = ok(this.admin.deleteUser(id, user.id)); | |
| 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 }; | |
| } | |
| ('users/:id/passkeys') | |
| resetUserPasskeys(() user: User, ('id') id: string, () req: Request) { | |
| const result = ok(this.admin.resetUserPasskeys(id)); | |
| 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 }; | |
| } | |
| // ββ Stats / permissions / audit ββ | |
| ('stats') | |
| stats() { return this.admin.getStats(); } | |
| ('permissions') | |
| permissions() { return this.admin.getPermissions(); } | |
| ('permissions') | |
| savePermissions(() user: User, () body: { permissions?: unknown }, () req: Request) { | |
| if (!body.permissions || typeof body.permissions !== 'object') { | |
| throw new HttpException({ error: 'permissions object required' }, 400); | |
| } | |
| const result = this.admin.savePermissions(body.permissions as unknown as Parameters<AdminService['savePermissions']>[0]); | |
| 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 } : {}) }; | |
| } | |
| ('audit-log') | |
| auditLog(() query: { limit?: string; offset?: string }) { return this.admin.getAuditLog(query); } | |
| // ββ OIDC ββ | |
| ('oidc') | |
| getOidc() { return this.admin.getOidcSettings(); } | |
| ('oidc') | |
| updateOidc(() user: User, () body: { issuer?: string } & Record<string, unknown>, () req: Request) { | |
| const result = this.admin.updateOidcSettings(body); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status || 400); | |
| } | |
| writeAudit({ userId: user.id, action: 'admin.oidc_update', ip: getClientIp(req), details: { issuer_set: !!body.issuer } }); | |
| return { success: true }; | |
| } | |
| ('save-demo-baseline') | |
| (200) | |
| saveDemoBaseline(() user: User, () req: Request) { | |
| const result = this.admin.saveDemoBaseline(); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| writeAudit({ userId: user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) }); | |
| return { success: true, message: result.message }; | |
| } | |
| // ββ GitHub / version ββ | |
| ('github-releases') | |
| async githubReleases(('per_page') perPage = '10', ('page') page = '1') { | |
| return this.admin.getGithubReleases(String(perPage), String(page)); | |
| } | |
| ('version-check') | |
| async versionCheck() { return this.admin.checkVersion(); } | |
| // ββ Admin notification preferences ββ | |
| ('notification-preferences') | |
| getNotificationPrefs(() user: User) { return this.admin.getPreferencesMatrix(user.id, user.role); } | |
| ('notification-preferences') | |
| setNotificationPrefs(() user: User, () body: unknown) { | |
| this.admin.setAdminPreferences(user.id, body); | |
| return this.admin.getPreferencesMatrix(user.id, user.role); | |
| } | |
| // ββ Invites ββ | |
| ('invites') | |
| listInvites() { return { invites: this.admin.listInvites() }; } | |
| ('invites') | |
| (201) | |
| createInvite(() user: User, () body: unknown, () req: Request) { | |
| const result = this.admin.createInvite(user.id, body); | |
| 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 } }); | |
| return { invite: result.invite }; | |
| } | |
| ('invites/:id') | |
| deleteInvite(() user: User, ('id') id: string, () req: Request) { | |
| ok(this.admin.deleteInvite(id)); | |
| writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) }); | |
| return { success: true }; | |
| } | |
| // ββ Feature toggles ββ | |
| ('bag-tracking') | |
| getBagTracking() { return this.admin.getBagTracking(); } | |
| ('bag-tracking') | |
| updateBagTracking(() user: User, () body: { enabled?: unknown }, () req: Request) { | |
| const result = this.admin.updateBagTracking(body.enabled); | |
| writeAudit({ userId: user.id, action: 'admin.bag_tracking', ip: getClientIp(req), details: { enabled: result.enabled } }); | |
| return result; | |
| } | |
| ('places-photos') | |
| getPlacesPhotos() { return this.admin.getPlacesPhotos(); } | |
| ('places-photos') | |
| updatePlacesPhotos(() user: User, () body: { enabled?: unknown }, () req: Request) { | |
| if (typeof body.enabled !== 'boolean') throw new HttpException({ error: 'enabled must be a boolean' }, 400); | |
| const result = this.admin.updatePlacesPhotos(body.enabled); | |
| writeAudit({ userId: user.id, action: 'admin.places_photos', ip: getClientIp(req), details: { enabled: result.enabled } }); | |
| return result; | |
| } | |
| ('places-autocomplete') | |
| getPlacesAutocomplete() { return this.admin.getPlacesAutocomplete(); } | |
| ('places-autocomplete') | |
| updatePlacesAutocomplete(() user: User, () body: { enabled?: unknown }, () req: Request) { | |
| if (typeof body.enabled !== 'boolean') throw new HttpException({ error: 'enabled must be a boolean' }, 400); | |
| const result = this.admin.updatePlacesAutocomplete(body.enabled); | |
| writeAudit({ userId: user.id, action: 'admin.places_autocomplete', ip: getClientIp(req), details: { enabled: result.enabled } }); | |
| return result; | |
| } | |
| ('places-details') | |
| getPlacesDetails() { return this.admin.getPlacesDetails(); } | |
| ('places-details') | |
| updatePlacesDetails(() user: User, () body: { enabled?: unknown }, () req: Request) { | |
| if (typeof body.enabled !== 'boolean') throw new HttpException({ error: 'enabled must be a boolean' }, 400); | |
| const result = this.admin.updatePlacesDetails(body.enabled); | |
| writeAudit({ userId: user.id, action: 'admin.places_details', ip: getClientIp(req), details: { enabled: result.enabled } }); | |
| return result; | |
| } | |
| ('collab-features') | |
| getCollabFeatures() { return this.admin.getCollabFeatures(); } | |
| ('collab-features') | |
| updateCollabFeatures(() user: User, () body: unknown, () req: Request) { | |
| const result = this.admin.updateCollabFeatures(body); | |
| this.admin.invalidateMcpSessions(); | |
| writeAudit({ userId: user.id, action: 'admin.collab_features', ip: getClientIp(req), details: result }); | |
| return result; | |
| } | |
| // ββ Packing templates ββ | |
| ('packing-templates') | |
| listPackingTemplates() { return { templates: this.admin.listPackingTemplates() }; } | |
| ('packing-templates/:id') | |
| getPackingTemplate(('id') id: string) { return ok(this.admin.getPackingTemplate(id)); } | |
| ('packing-templates') | |
| (201) | |
| createPackingTemplate(() user: User, () body: { name?: unknown }) { | |
| return ok(this.admin.createPackingTemplate(body.name, user.id)); | |
| } | |
| ('packing-templates/:id') | |
| updatePackingTemplate(('id') id: string, () body: unknown) { return ok(this.admin.updatePackingTemplate(id, body)); } | |
| ('packing-templates/:id') | |
| deletePackingTemplate(() user: User, ('id') id: string, () req: Request) { | |
| const result = ok(this.admin.deletePackingTemplate(id)); | |
| writeAudit({ userId: user.id, action: 'admin.packing_template_delete', resource: String(id), ip: getClientIp(req), details: { name: result.name } }); | |
| return { success: true }; | |
| } | |
| ('packing-templates/:id/categories') | |
| (201) | |
| createTemplateCategory(('id') id: string, () body: { name?: unknown }) { | |
| return ok(this.admin.createTemplateCategory(id, body.name)); | |
| } | |
| ('packing-templates/:templateId/categories/:catId') | |
| updateTemplateCategory(('templateId') templateId: string, ('catId') catId: string, () body: unknown) { | |
| return ok(this.admin.updateTemplateCategory(templateId, catId, body)); | |
| } | |
| ('packing-templates/:templateId/categories/:catId') | |
| deleteTemplateCategory(('templateId') templateId: string, ('catId') catId: string) { | |
| ok(this.admin.deleteTemplateCategory(templateId, catId)); | |
| return { success: true }; | |
| } | |
| ('packing-templates/:templateId/categories/:catId/items') | |
| (201) | |
| createTemplateItem(('templateId') templateId: string, ('catId') catId: string, () body: { name?: unknown }) { | |
| return ok(this.admin.createTemplateItem(templateId, catId, body.name)); | |
| } | |
| ('packing-templates/:templateId/items/:itemId') | |
| updateTemplateItem(('itemId') itemId: string, () body: unknown) { return ok(this.admin.updateTemplateItem(itemId, body)); } | |
| ('packing-templates/:templateId/items/:itemId') | |
| deleteTemplateItem(('itemId') itemId: string) { | |
| ok(this.admin.deleteTemplateItem(itemId)); | |
| return { success: true }; | |
| } | |
| // ββ Addons ββ | |
| ('addons') | |
| listAddons() { return { addons: this.admin.listAddons() }; } | |
| ('addons/:id') | |
| updateAddon(() user: User, ('id') id: string, () body: unknown, () req: Request) { | |
| const result = ok(this.admin.updateAddon(id, body)); | |
| writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails }); | |
| this.admin.invalidateMcpSessions(); | |
| return { addon: result.addon }; | |
| } | |
| // ββ MCP tokens / OAuth sessions ββ | |
| ('mcp-tokens') | |
| listMcpTokens() { return { tokens: this.admin.listMcpTokens() }; } | |
| ('mcp-tokens/:id') | |
| deleteMcpToken(('id') id: string) { | |
| ok(this.admin.deleteMcpToken(id)); | |
| return { success: true }; | |
| } | |
| ('oauth-sessions') | |
| listOAuthSessions() { return { sessions: this.admin.listOAuthSessions() }; } | |
| ('oauth-sessions/:id') | |
| revokeOAuthSession(() user: User, ('id') id: string, () req: Request) { | |
| ok(this.admin.revokeOAuthSession(id)); | |
| writeAudit({ userId: user.id, action: 'admin.oauth_session.revoke', resource: String(id), ip: getClientIp(req) }); | |
| return { success: true }; | |
| } | |
| // ββ JWT rotation ββ | |
| ('rotate-jwt-secret') | |
| (200) | |
| rotateJwtSecret(() user: User, () req: Request) { | |
| const result = this.admin.rotateJwtSecret(); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| writeAudit({ userId: user.id, action: 'admin.rotate_jwt_secret', ip: getClientIp(req) }); | |
| return { success: true }; | |
| } | |
| // ββ Default user settings ββ | |
| ('default-user-settings') | |
| getDefaultUserSettings() { return this.admin.getAdminUserDefaults(); } | |
| ('default-user-settings') | |
| setDefaultUserSettings(() user: User, () body: unknown, () req: Request) { | |
| if (!body || typeof body !== 'object' || Array.isArray(body)) { | |
| throw new HttpException({ error: 'Object body required' }, 400); | |
| } | |
| try { | |
| this.admin.setAdminUserDefaults(body as unknown as Record<string, unknown>); | |
| writeAudit({ userId: user.id, action: 'admin.default_user_settings_update', ip: getClientIp(req), details: body as Record<string, unknown> }); | |
| return this.admin.getAdminUserDefaults(); | |
| } catch (err) { | |
| throw new HttpException({ error: err instanceof Error ? err.message : String(err) }, 400); | |
| } | |
| } | |
| // ββ Dev-only: test notification (404 outside development, mirroring the conditional mount) ββ | |
| ('dev/test-notification') | |
| (200) | |
| async devTestNotification(() user: User, () body: { event?: string; scope?: string; targetId?: number; params?: Record<string, unknown>; inApp?: boolean }) { | |
| if (process.env.NODE_ENV?.toLowerCase() !== 'development') { | |
| throw new NotFoundException(); | |
| } | |
| try { | |
| await sendNotification({ | |
| 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<typeof sendNotification>[0]); | |
| return { success: true }; | |
| } catch (err) { | |
| throw new HttpException({ error: err instanceof Error ? err.message : String(err) }, 400); | |
| } | |
| } | |
| } | |