Browse Source

Task/deprecate admin user endpoints in favor of users endpoints (#8357)

* Deprecate admin user endpoints in favor of users endpoints

* Update changelog
pull/8366/head
Thomas Kaul 4 days ago
committed by GitHub
parent
commit
5f5184689a
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 36
      apps/api/src/app/__snapshots__/app.module.spec.ts.snap
  3. 2
      apps/api/src/app/admin/admin.controller.ts
  4. 4
      apps/api/src/app/app.module.ts
  5. 24
      apps/api/src/app/endpoints/users/user.controller.ts
  6. 83
      apps/api/src/app/endpoints/users/users.controller.ts
  7. 13
      apps/api/src/app/endpoints/users/users.module.ts
  8. 18
      apps/api/src/services/user/user.service.ts
  9. 4
      libs/ui/src/lib/services/admin.service.ts
  10. 4
      libs/ui/src/lib/services/data.service.ts

6
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/), 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). 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 ## 3.95.0 - 2026-10-31
### Changed ### Changed

36
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": [], "interceptors": [],
"permission": "deleteUser", "permission": "deleteUser",
}, },
"DELETE /users/:id": {
"allowDuringImpersonation": true,
"guards": [
"AuthGuard('jwt')",
"HasPermissionGuard",
],
"interceptors": [],
"permission": "deleteUser",
},
"DELETE /watchlist/:dataSource/:symbol": { "DELETE /watchlist/:dataSource/:symbol": {
"guards": [ "guards": [
"AuthGuard('jwt')", "AuthGuard('jwt')",
@ -891,6 +900,24 @@ exports[`AppModule should keep the security of each route 1`] = `
"RedactValuesInResponseInterceptor", "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": { "GET /watchlist": {
"guards": [ "guards": [
"AuthGuard('jwt')", "AuthGuard('jwt')",
@ -1307,6 +1334,15 @@ exports[`AppModule should keep the security of each route 1`] = `
"interceptors": [], "interceptors": [],
"permission": "updateOwnAccessToken", "permission": "updateOwnAccessToken",
}, },
"POST /users/:id/access-token": {
"allowDuringImpersonation": true,
"guards": [
"AuthGuard('jwt')",
"HasPermissionGuard",
],
"interceptors": [],
"permission": "accessAdminControl",
},
"POST /watchlist": { "POST /watchlist": {
"guards": [ "guards": [
"AuthGuard('jwt')", "AuthGuard('jwt')",

2
apps/api/src/app/admin/admin.controller.ts

@ -354,6 +354,7 @@ export class AdminController {
return this.adminService.putSetting(key, data.value); return this.adminService.putSetting(key, data.value);
} }
/** @deprecated */
@Get('user') @Get('user')
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -367,6 +368,7 @@ export class AdminController {
}); });
} }
/** @deprecated */
@Get('user/:id') @Get('user/:id')
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)

4
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 { SubscriptionModule } from './endpoints/subscription/subscription.module';
import { SymbolsModule } from './endpoints/symbols/symbols.module'; import { SymbolsModule } from './endpoints/symbols/symbols.module';
import { TagsModule } from './endpoints/tags/tags.module'; import { TagsModule } from './endpoints/tags/tags.module';
import { UsersModule } from './endpoints/users/users.module';
import { WatchlistModule } from './endpoints/watchlist/watchlist.module'; import { WatchlistModule } from './endpoints/watchlist/watchlist.module';
import { PortfolioModule } from './portfolio/portfolio.module'; import { PortfolioModule } from './portfolio/portfolio.module';
import { UserModule } from './user/user.module';
@Module({ @Module({
controllers: [AppController], controllers: [AppController],
@ -182,7 +182,7 @@ import { UserModule } from './user/user.module';
}; };
} }
}), }),
UserModule, UsersModule,
WatchlistModule WatchlistModule
], ],
providers: [ providers: [

24
apps/api/src/app/user/user.controller.ts → 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 { 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 { 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 { 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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { UserService } from '@ghostfolio/api/services/user/user.service'; import { UserService } from '@ghostfolio/api/services/user/user.service';
import { import {
@ -62,7 +61,6 @@ export class UserController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
@ -91,6 +89,7 @@ export class UserController {
}); });
} }
/** @deprecated */
@Delete(':id') @Delete(':id')
@HasPermission(permissions.deleteUser) @HasPermission(permissions.deleteUser)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -107,13 +106,14 @@ export class UserController {
}); });
} }
/** @deprecated */
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post(':id/access-token') @Post(':id/access-token')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateUserAccessToken( public async updateUserAccessToken(
@Param('id') id: string @Param('id') id: string
): Promise<AccessTokenResponse> { ): Promise<AccessTokenResponse> {
return this.rotateUserAccessToken(id); return this.userService.rotateAccessToken({ userId: id });
} }
@HasPermission(permissions.updateOwnAccessToken) @HasPermission(permissions.updateOwnAccessToken)
@ -127,7 +127,7 @@ export class UserController {
this.request.user.id this.request.user.id
); );
return this.rotateUserAccessToken(user.id); return this.userService.rotateAccessToken({ userId: user.id });
} }
@Get() @Get()
@ -251,22 +251,6 @@ export class UserController {
}); });
} }
private async rotateUserAccessToken(
userId: string
): Promise<AccessTokenResponse> {
const { accessToken, hashedAccessToken } =
this.userService.generateAccessToken({
userId
});
await this.prismaService.user.update({
data: { accessToken: hashedAccessToken },
where: { id: userId }
});
return { accessToken };
}
private async validateAccessToken( private async validateAccessToken(
accessToken: string, accessToken: string,
userId: string userId: string

83
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<void> {
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<AdminUserResponse> {
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<AdminUsersResponse> {
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<AccessTokenResponse> {
return this.userService.rotateAccessToken({ userId: id });
}
}

13
apps/api/src/app/user/user.module.ts → 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 { 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 { 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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.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 { 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 { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { UserController } from './user.controller'; import { UserController } from './user.controller';
import { UsersController } from './users.controller';
@Module({ @Module({
controllers: [UserController], controllers: [UserController, UsersController],
imports: [ imports: [
AdminModule,
ConfigurationModule, ConfigurationModule,
ImpersonationModule, ImpersonationModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}), }),
PrismaModule,
PropertyModule, PropertyModule,
RedactValuesInResponseModule, RedactValuesInResponseModule,
TransformDataSourceInResponseModule, TransformDataSourceInResponseModule,
UserServiceModule UserModule
] ]
}) })
export class UserModule {} export class UsersModule {}

18
apps/api/src/services/user/user.service.ts

@ -28,6 +28,7 @@ import {
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { resolveUserSettings } from '@ghostfolio/common/helper'; import { resolveUserSettings } from '@ghostfolio/common/helper';
import { import {
AccessTokenResponse,
User as IUser, User as IUser,
ReferralPartner, ReferralPartner,
SystemMessage, SystemMessage,
@ -689,6 +690,23 @@ export class UserService {
}); });
} }
public async rotateAccessToken({
userId
}: {
userId: string;
}): Promise<AccessTokenResponse> {
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 * 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 * counted only if the analytics of the user exist already. The country is

4
libs/ui/src/lib/services/admin.service.ts

@ -104,7 +104,7 @@ export class AdminService {
} }
public fetchUserById(id: string) { public fetchUserById(id: string) {
return this.http.get<AdminUserResponse>(`/api/v1/admin/user/${id}`); return this.http.get<AdminUserResponse>(`/api/v1/users/${id}`);
} }
public fetchUsers({ public fetchUsers({
@ -124,7 +124,7 @@ export class AdminService {
params = params.append('take', take); params = params.append('take', take);
} }
return this.http.get<AdminUsersResponse>('/api/v1/admin/user', { params }); return this.http.get<AdminUsersResponse>('/api/v1/users', { params });
} }
public gatherMax() { public gatherMax() {

4
libs/ui/src/lib/services/data.service.ts

@ -377,7 +377,7 @@ export class DataService {
} }
public deleteUser(aId: string) { public deleteUser(aId: string) {
return this.http.delete<UserModel>(`/api/v1/user/${aId}`); return this.http.delete<void>(`/api/v1/users/${aId}`);
} }
public deleteWatchlistItem({ dataSource, symbol }: AssetProfileIdentifier) { public deleteWatchlistItem({ dataSource, symbol }: AssetProfileIdentifier) {
@ -1020,7 +1020,7 @@ export class DataService {
public updateUserAccessToken(aUserId: string) { public updateUserAccessToken(aUserId: string) {
return this.http.post<AccessTokenResponse>( return this.http.post<AccessTokenResponse>(
`/api/v1/user/${aUserId}/access-token`, `/api/v1/users/${aUserId}/access-token`,
{} {}
); );
} }

Loading…
Cancel
Save