diff --git a/CHANGELOG.md b/CHANGELOG.md index fc48098a05..7e99ba6b3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Deprecated the `api/v1/admin/user`, `api/v1/user/:id` and `api/v1/user/:id/access-token` endpoints in favor of the `api/v1/users` endpoints + ## 3.95.0 - 2026-10-31 ### Changed diff --git a/apps/api/src/app/__snapshots__/app.module.spec.ts.snap b/apps/api/src/app/__snapshots__/app.module.spec.ts.snap index 2c022018ca..7701f430eb 100644 --- a/apps/api/src/app/__snapshots__/app.module.spec.ts.snap +++ b/apps/api/src/app/__snapshots__/app.module.spec.ts.snap @@ -230,6 +230,15 @@ exports[`AppModule should keep the security of each route 1`] = ` "interceptors": [], "permission": "deleteUser", }, + "DELETE /users/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "deleteUser", + }, "DELETE /watchlist/:dataSource/:symbol": { "guards": [ "AuthGuard('jwt')", @@ -891,6 +900,24 @@ exports[`AppModule should keep the security of each route 1`] = ` "RedactValuesInResponseInterceptor", ], }, + "GET /users": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "GET /users/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, "GET /watchlist": { "guards": [ "AuthGuard('jwt')", @@ -1307,6 +1334,15 @@ exports[`AppModule should keep the security of each route 1`] = ` "interceptors": [], "permission": "updateOwnAccessToken", }, + "POST /users/:id/access-token": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, "POST /watchlist": { "guards": [ "AuthGuard('jwt')", diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index f315580ca2..7bb64250f3 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -354,6 +354,7 @@ export class AdminController { return this.adminService.putSetting(key, data.value); } + /** @deprecated */ @Get('user') @HasPermission(permissions.accessAdminControl) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -367,6 +368,7 @@ export class AdminController { }); } + /** @deprecated */ @Get('user/:id') @HasPermission(permissions.accessAdminControl) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index e7ead89675..e4e6d7368b 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -63,9 +63,9 @@ import { SitemapModule } from './endpoints/sitemap/sitemap.module'; import { SubscriptionModule } from './endpoints/subscription/subscription.module'; import { SymbolsModule } from './endpoints/symbols/symbols.module'; import { TagsModule } from './endpoints/tags/tags.module'; +import { UsersModule } from './endpoints/users/users.module'; import { WatchlistModule } from './endpoints/watchlist/watchlist.module'; import { PortfolioModule } from './portfolio/portfolio.module'; -import { UserModule } from './user/user.module'; @Module({ controllers: [AppController], @@ -182,7 +182,7 @@ import { UserModule } from './user/user.module'; }; } }), - UserModule, + UsersModule, WatchlistModule ], providers: [ diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/endpoints/users/user.controller.ts similarity index 93% rename from apps/api/src/app/user/user.controller.ts rename to apps/api/src/app/endpoints/users/user.controller.ts index 00f7b046e4..326e38bb41 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/endpoints/users/user.controller.ts @@ -8,7 +8,6 @@ import { decodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { UserService } from '@ghostfolio/api/services/user/user.service'; import { @@ -62,7 +61,6 @@ export class UserController { public constructor( private readonly configurationService: ConfigurationService, private readonly jwtService: JwtService, - private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly userService: UserService @@ -91,6 +89,7 @@ export class UserController { }); } + /** @deprecated */ @Delete(':id') @HasPermission(permissions.deleteUser) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -107,13 +106,14 @@ export class UserController { }); } + /** @deprecated */ @HasPermission(permissions.accessAdminControl) @Post(':id/access-token') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async updateUserAccessToken( @Param('id') id: string ): Promise { - return this.rotateUserAccessToken(id); + return this.userService.rotateAccessToken({ userId: id }); } @HasPermission(permissions.updateOwnAccessToken) @@ -127,7 +127,7 @@ export class UserController { this.request.user.id ); - return this.rotateUserAccessToken(user.id); + return this.userService.rotateAccessToken({ userId: user.id }); } @Get() @@ -251,22 +251,6 @@ export class UserController { }); } - private async rotateUserAccessToken( - userId: string - ): Promise { - const { accessToken, hashedAccessToken } = - this.userService.generateAccessToken({ - userId - }); - - await this.prismaService.user.update({ - data: { accessToken: hashedAccessToken }, - where: { id: userId } - }); - - return { accessToken }; - } - private async validateAccessToken( accessToken: string, userId: string diff --git a/apps/api/src/app/endpoints/users/users.controller.ts b/apps/api/src/app/endpoints/users/users.controller.ts new file mode 100644 index 0000000000..bccc37166f --- /dev/null +++ b/apps/api/src/app/endpoints/users/users.controller.ts @@ -0,0 +1,83 @@ +import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { UserService } from '@ghostfolio/api/services/user/user.service'; +import { + AccessTokenResponse, + AdminUserResponse, + AdminUsersResponse +} from '@ghostfolio/common/interfaces'; +import { permissions } from '@ghostfolio/common/permissions'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { + Controller, + Delete, + Get, + HttpException, + Inject, + Param, + ParseIntPipe, + Post, + Query, + UseGuards +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +@AllowDuringImpersonation() +@Controller('users') +export class UsersController { + public constructor( + private readonly adminService: AdminService, + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly userService: UserService + ) {} + + @Delete(':id') + @HasPermission(permissions.deleteUser) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async deleteUser(@Param('id') id: string): Promise { + if (id === this.request.user.id) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + await this.userService.deleteUser({ + id + }); + } + + @Get(':id') + @HasPermission(permissions.accessAdminControl) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getUser(@Param('id') id: string): Promise { + return this.adminService.getUser(id); + } + + @Get() + @HasPermission(permissions.accessAdminControl) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getUsers( + @Query('skip', new ParseIntPipe({ optional: true })) skip?: number, + @Query('take', new ParseIntPipe({ optional: true })) take?: number + ): Promise { + return this.adminService.getUsers({ + skip, + take + }); + } + + @HasPermission(permissions.accessAdminControl) + @Post(':id/access-token') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updateUserAccessToken( + @Param('id') id: string + ): Promise { + return this.userService.rotateAccessToken({ userId: id }); + } +} diff --git a/apps/api/src/app/user/user.module.ts b/apps/api/src/app/endpoints/users/users.module.ts similarity index 76% rename from apps/api/src/app/user/user.module.ts rename to apps/api/src/app/endpoints/users/users.module.ts index 4e9965091c..8148406a12 100644 --- a/apps/api/src/app/user/user.module.ts +++ b/apps/api/src/app/endpoints/users/users.module.ts @@ -1,30 +1,31 @@ +import { AdminModule } from '@ghostfolio/api/app/admin/admin.module'; import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; -import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; -import { UserModule as UserServiceModule } from '@ghostfolio/api/services/user/user.module'; +import { UserModule } from '@ghostfolio/api/services/user/user.module'; import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { UserController } from './user.controller'; +import { UsersController } from './users.controller'; @Module({ - controllers: [UserController], + controllers: [UserController, UsersController], imports: [ + AdminModule, ConfigurationModule, ImpersonationModule, JwtModule.register({ secret: process.env.JWT_SECRET_KEY, signOptions: { expiresIn: '30 days' } }), - PrismaModule, PropertyModule, RedactValuesInResponseModule, TransformDataSourceInResponseModule, - UserServiceModule + UserModule ] }) -export class UserModule {} +export class UsersModule {} diff --git a/apps/api/src/services/user/user.service.ts b/apps/api/src/services/user/user.service.ts index 55cd9186b5..3db52a9284 100644 --- a/apps/api/src/services/user/user.service.ts +++ b/apps/api/src/services/user/user.service.ts @@ -28,6 +28,7 @@ import { import { SubscriptionType } from '@ghostfolio/common/enums'; import { resolveUserSettings } from '@ghostfolio/common/helper'; import { + AccessTokenResponse, User as IUser, ReferralPartner, SystemMessage, @@ -689,6 +690,23 @@ export class UserService { }); } + public async rotateAccessToken({ + userId + }: { + userId: string; + }): Promise { + const { accessToken, hashedAccessToken } = this.generateAccessToken({ + userId + }); + + await this.prismaService.user.update({ + data: { accessToken: hashedAccessToken }, + where: { id: userId } + }); + + return { accessToken }; + } + /** * Keeps the date and the client of the request of the user. The request is * counted only if the analytics of the user exist already. The country is diff --git a/libs/ui/src/lib/services/admin.service.ts b/libs/ui/src/lib/services/admin.service.ts index 131caf5e17..a57274f78f 100644 --- a/libs/ui/src/lib/services/admin.service.ts +++ b/libs/ui/src/lib/services/admin.service.ts @@ -104,7 +104,7 @@ export class AdminService { } public fetchUserById(id: string) { - return this.http.get(`/api/v1/admin/user/${id}`); + return this.http.get(`/api/v1/users/${id}`); } public fetchUsers({ @@ -124,7 +124,7 @@ export class AdminService { params = params.append('take', take); } - return this.http.get('/api/v1/admin/user', { params }); + return this.http.get('/api/v1/users', { params }); } public gatherMax() { diff --git a/libs/ui/src/lib/services/data.service.ts b/libs/ui/src/lib/services/data.service.ts index 1d363f75e7..d081771ef7 100644 --- a/libs/ui/src/lib/services/data.service.ts +++ b/libs/ui/src/lib/services/data.service.ts @@ -377,7 +377,7 @@ export class DataService { } public deleteUser(aId: string) { - return this.http.delete(`/api/v1/user/${aId}`); + return this.http.delete(`/api/v1/users/${aId}`); } public deleteWatchlistItem({ dataSource, symbol }: AssetProfileIdentifier) { @@ -1020,7 +1020,7 @@ export class DataService { public updateUserAccessToken(aUserId: string) { return this.http.post( - `/api/v1/user/${aUserId}/access-token`, + `/api/v1/users/${aUserId}/access-token`, {} ); }