From 266645d08f794edca7fdae7ba446245d90be2172 Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Sat, 27 Aug 2022 13:53:37 +0300 Subject: [PATCH] start admin api module --- src/fe/scss/_authorize.scss | 2 + src/modules/api/admin/admin.module.ts | 9 ++- .../api/admin/privilege-admin.controller.ts | 44 +++++++++++ .../api/admin/user-admin.controller.ts | 73 +++++++++++++++++++ src/modules/oauth2/oauth2.service.ts | 10 ++- .../objects/privilege/privilege.service.ts | 19 +++++ src/modules/objects/user/user.service.ts | 52 ++++++++++++- .../utility/services/form-utility.service.ts | 48 ++++++++++++ .../utility/services/paginate.service.ts | 33 +++++++++ src/modules/utility/utility.module.ts | 10 ++- src/types/pagination.interfaces.ts | 12 +++ 11 files changed, 307 insertions(+), 5 deletions(-) create mode 100644 src/modules/api/admin/privilege-admin.controller.ts create mode 100644 src/modules/api/admin/user-admin.controller.ts create mode 100644 src/modules/utility/services/paginate.service.ts create mode 100644 src/types/pagination.interfaces.ts diff --git a/src/fe/scss/_authorize.scss b/src/fe/scss/_authorize.scss index 013d5fc..da27b57 100644 --- a/src/fe/scss/_authorize.scss +++ b/src/fe/scss/_authorize.scss @@ -93,6 +93,8 @@ display: block; width: 32px; height: 32px; + background-position: center; + background-repeat: no-repeat; margin: 4px; } diff --git a/src/modules/api/admin/admin.module.ts b/src/modules/api/admin/admin.module.ts index 56a8a3f..046815f 100644 --- a/src/modules/api/admin/admin.module.ts +++ b/src/modules/api/admin/admin.module.ts @@ -1,4 +1,11 @@ import { Module } from '@nestjs/common'; +import { OAuth2Module } from 'src/modules/oauth2/oauth2.module'; +import { ObjectsModule } from 'src/modules/objects/objects.module'; +import { PrivilegeAdminController } from './privilege-admin.controller'; +import { UserAdminController } from './user-admin.controller'; -@Module({}) +@Module({ + controllers: [UserAdminController, PrivilegeAdminController], + imports: [ObjectsModule, OAuth2Module], +}) export class AdminApiModule {} diff --git a/src/modules/api/admin/privilege-admin.controller.ts b/src/modules/api/admin/privilege-admin.controller.ts new file mode 100644 index 0000000..968578a --- /dev/null +++ b/src/modules/api/admin/privilege-admin.controller.ts @@ -0,0 +1,44 @@ +import { + BadRequestException, + Body, + Controller, + Get, + Post, + UseGuards, +} from '@nestjs/common'; +import { Privileges } from 'src/decorators/privileges.decorator'; +import { Scopes } from 'src/decorators/scopes.decorator'; +import { OAuth2Guard } from 'src/guards/oauth2.guard'; +import { PrivilegesGuard } from 'src/guards/privileges.guard'; +import { ScopesGuard } from 'src/guards/scopes.guard'; +import { PrivilegeService } from 'src/modules/objects/privilege/privilege.service'; +import { FormUtilityService } from 'src/modules/utility/services/form-utility.service'; +import { PaginationService } from 'src/modules/utility/services/paginate.service'; + +@Controller('/api/admin/privileges') +@UseGuards(OAuth2Guard, PrivilegesGuard, ScopesGuard) +export class PrivilegeAdminController { + constructor( + private _privilege: PrivilegeService, + private _paginate: PaginationService, + private _form: FormUtilityService, + ) {} + + @Get('') + @Scopes('management') + @Privileges('admin', 'admin:user', 'admin:user:privilege') + async privilegeList() { + return this._privilege.getAllPrivileges(); + } + + @Post('') + @Scopes('management') + @Privileges('admin', 'admin:user', 'admin:user:privilege') + async newPrivilege(@Body() body: { privilege: string }) { + if (!body.privilege) { + throw new BadRequestException('Privilege is required'); + } + + return this._privilege.createPrivilege(body.privilege); + } +} diff --git a/src/modules/api/admin/user-admin.controller.ts b/src/modules/api/admin/user-admin.controller.ts new file mode 100644 index 0000000..beaf167 --- /dev/null +++ b/src/modules/api/admin/user-admin.controller.ts @@ -0,0 +1,73 @@ +import { + Controller, + Get, + NotFoundException, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { Privileges } from 'src/decorators/privileges.decorator'; +import { Scopes } from 'src/decorators/scopes.decorator'; +import { OAuth2Guard } from 'src/guards/oauth2.guard'; +import { PrivilegesGuard } from 'src/guards/privileges.guard'; +import { ScopesGuard } from 'src/guards/scopes.guard'; +import { UserService } from 'src/modules/objects/user/user.service'; +import { FormUtilityService } from 'src/modules/utility/services/form-utility.service'; +import { PaginationService } from 'src/modules/utility/services/paginate.service'; +import { PageOptions } from 'src/types/pagination.interfaces'; + +const RELATIONS = ['picture', 'privileges']; + +@Controller('/api/admin/users') +@UseGuards(OAuth2Guard, PrivilegesGuard, ScopesGuard) +export class UserAdminController { + constructor( + private _user: UserService, + private _paginate: PaginationService, + private _form: FormUtilityService, + ) {} + + @Get('') + @Scopes('management') + @Privileges('admin', 'admin:user') + async userList(@Query() options: { q?: string } & PageOptions) { + const search = options.q ? decodeURIComponent(options.q) : null; + const resultCount = await this._user.searchUsersCount(search, RELATIONS); + + const pagination = this._paginate.paginate(options, resultCount); + + const [list] = await this._user.searchUsers( + pagination.pageSize, + pagination.offset, + search, + RELATIONS, + ); + + return { + pagination, + list: this._form.stripObjectArray(list, ['password']), + }; + } + + @Get(':id') + @Scopes('management') + @Privileges('admin', 'admin:user') + async user(@Param('id') id: string) { + const user = await this._user.getById(parseInt(id, 10), RELATIONS); + if (!user) { + throw new NotFoundException('User not found'); + } + return this._form.stripObject(user, ['password']); + } + + @Get(':id/privileges') + @Scopes('management') + @Privileges('admin', 'admin:user') + async userPrivileges(@Param('id') id: string) { + const user = await this._user.getById(parseInt(id, 10), ['privileges']); + if (!user) { + throw new NotFoundException('User not found'); + } + return user.privileges; + } +} diff --git a/src/modules/oauth2/oauth2.service.ts b/src/modules/oauth2/oauth2.service.ts index 32bf81d..5da25b7 100644 --- a/src/modules/oauth2/oauth2.service.ts +++ b/src/modules/oauth2/oauth2.service.ts @@ -12,6 +12,7 @@ import { RefreshTokenAdapter } from './adapter/refresh-token.adapter'; import { UserAdapter } from './adapter/user.adapter'; const SCOPE_DESCRIPTION: Record = { + management: 'Manage Icy Network on your behalf', email: 'Email address', image: 'Profile picture', }; @@ -34,7 +35,7 @@ export class OAuth2Service { async (req, res, client, scope) => { const fullClient = await this.clientService.getById(client.id as string); const allowedScopes = [...ALWAYS_AVAILABLE]; - const disallowedScopes = [...ALWAYS_UNAVAILABLE]; + let disallowedScopes = [...ALWAYS_UNAVAILABLE]; Object.keys(SCOPE_DESCRIPTION).forEach((item) => { if (scope.includes(item)) { @@ -44,6 +45,13 @@ export class OAuth2Service { } }); + if (scope.includes('management')) { + disallowedScopes = [ + 'THIS APPLICATION COULD ACCESS SENSITIVE INFORMATION!', + 'MAKE SURE YOU TRUST THE DEVELOPERS OF THIS APPLICATION', + ]; + } + res.render('authorize', { csrf: req.csrfToken(), user: req.user, diff --git a/src/modules/objects/privilege/privilege.service.ts b/src/modules/objects/privilege/privilege.service.ts index ebe4eb9..ce99307 100644 --- a/src/modules/objects/privilege/privilege.service.ts +++ b/src/modules/objects/privilege/privilege.service.ts @@ -8,4 +8,23 @@ export class PrivilegeService { @Inject('PRIVILEGE_REPOSITORY') private privilegeRepository: Repository, ) {} + + public getAllPrivileges(): Promise { + return this.privilegeRepository.find(); + } + + public async createPrivilege(name: string): Promise { + const privilege = new Privilege(); + privilege.name = name; + await this.privilegeRepository.save(privilege); + return privilege; + } + + public async getByName(name: string): Promise { + return this.privilegeRepository.findOne({ where: { name } }); + } + + public async getByID(id: number): Promise { + return this.privilegeRepository.findOne({ where: { id } }); + } } diff --git a/src/modules/objects/user/user.service.ts b/src/modules/objects/user/user.service.ts index ba78ea6..346c56a 100644 --- a/src/modules/objects/user/user.service.ts +++ b/src/modules/objects/user/user.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { Repository } from 'typeorm'; +import { ILike, Repository } from 'typeorm'; import { UserTokenType } from '../user-token/user-token.entity'; import { User } from './user.entity'; import { TokenService } from 'src/modules/utility/services/token.service'; @@ -48,6 +48,56 @@ export class UserService { return this.userRepository.findOne({ where: { email }, relations }); } + public async searchUsers( + limit = 50, + offset = 0, + search?: string, + relations?: string[], + ): Promise<[User[], number]> { + const searchTerm = `%${search}%`; + return this.userRepository.findAndCount({ + where: search + ? [ + { + display_name: ILike(searchTerm), + }, + { + username: ILike(searchTerm), + }, + { + email: ILike(searchTerm), + }, + ] + : undefined, + skip: offset, + take: limit, + relations, + }); + } + + public async searchUsersCount( + search?: string, + relations?: string[], + ): Promise { + const searchTerm = `%${search}%`; + return this.userRepository.count({ + where: search + ? [ + { + display_name: ILike(searchTerm), + }, + { + username: ILike(searchTerm), + }, + { + email: ILike(searchTerm), + }, + ] + : undefined, + relations, + }); + } + public async getByUsername( username: string, relations?: string[], diff --git a/src/modules/utility/services/form-utility.service.ts b/src/modules/utility/services/form-utility.service.ts index f4c512b..640fdd9 100644 --- a/src/modules/utility/services/form-utility.service.ts +++ b/src/modules/utility/services/form-utility.service.ts @@ -33,6 +33,54 @@ export class FormUtilityService { ); } + /** + * Strip keys from an object + * @param object Object to strip + * @param keys Keys to strip from the object + * @returns Stripped object + */ + public stripObject(object: T, keys: string[]): T { + return keys.reduce((obj, field) => { + delete obj[field]; + return obj; + }, object); + } + + /** + * Pluck keys from an object + * @param object Object to pluck + * @param keys Keys to pluck from the object + * @returns Plucked object + */ + public pluckObject(object: T, keys: string[]): Partial { + return Object.keys(object).reduce>((obj, field) => { + if (keys.includes(field)) { + obj[field] = object[field]; + } + return obj; + }, {}); + } + + /** + * Strip keys from an object array + * @param array Object array to strip + * @param keys Keys to strip from the object array + * @returns Stripped object + */ + public stripObjectArray(array: T[], keys: string[]): T[] { + return array.map((object) => this.stripObject(object, keys)); + } + + /** + * Pluck keys from an object array + * @param array Object array to pluck + * @param keys Keys to pluck from the object array + * @returns Plucked object + */ + public pluckObjectArray(array: T[], keys: string[]): Partial[] { + return array.map((object) => this.pluckObject(object, keys)); + } + /** * Include CSRF token, messages and prefilled form values for a template with a form * @param req Express request diff --git a/src/modules/utility/services/paginate.service.ts b/src/modules/utility/services/paginate.service.ts new file mode 100644 index 0000000..6fa0524 --- /dev/null +++ b/src/modules/utility/services/paginate.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { + PageOptions, + PaginationOptions, +} from 'src/types/pagination.interfaces'; + +@Injectable() +export class PaginationService { + public paginate(options: PageOptions, rowCount: number): PaginationOptions { + const paginationOptions: PaginationOptions = { + page: options.page || 1, + pageSize: options.pageSize || 50, + rowCount, + }; + + // Calculate page count from row count and page size + paginationOptions.pageCount = Math.ceil( + rowCount / paginationOptions.pageSize, + ); + + // Limit the page number to 1..pageCount + paginationOptions.page = Math.max( + Math.min(paginationOptions.page, paginationOptions.pageCount), + 1, + ); + + // Calculate the offset for the query + paginationOptions.offset = + (paginationOptions.page - 1) * paginationOptions.pageSize; + + return paginationOptions; + } +} diff --git a/src/modules/utility/utility.module.ts b/src/modules/utility/utility.module.ts index 3478933..d6a4042 100644 --- a/src/modules/utility/utility.module.ts +++ b/src/modules/utility/utility.module.ts @@ -1,11 +1,17 @@ import { Global, Module } from '@nestjs/common'; import { FormUtilityService } from './services/form-utility.service'; +import { PaginationService } from './services/paginate.service'; import { QRCodeService } from './services/qr-code.service'; import { TokenService } from './services/token.service'; @Global() @Module({ - providers: [TokenService, FormUtilityService, QRCodeService], - exports: [TokenService, FormUtilityService, QRCodeService], + providers: [ + TokenService, + FormUtilityService, + QRCodeService, + PaginationService, + ], + exports: [TokenService, FormUtilityService, QRCodeService, PaginationService], }) export class UtilityModule {} diff --git a/src/types/pagination.interfaces.ts b/src/types/pagination.interfaces.ts new file mode 100644 index 0000000..86eec59 --- /dev/null +++ b/src/types/pagination.interfaces.ts @@ -0,0 +1,12 @@ +export interface PageOptions { + pageSize?: number; + page?: number; +} + +export interface PaginationOptions { + page: number; + pageSize: number; + rowCount: number; + pageCount?: number; + offset?: number; +}