start admin api module

This commit is contained in:
Evert Prants 2022-08-27 13:53:37 +03:00
parent bb86a25ad4
commit 266645d08f
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
11 changed files with 307 additions and 5 deletions

View File

@ -93,6 +93,8 @@
display: block;
width: 32px;
height: 32px;
background-position: center;
background-repeat: no-repeat;
margin: 4px;
}

View File

@ -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 {}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -12,6 +12,7 @@ import { RefreshTokenAdapter } from './adapter/refresh-token.adapter';
import { UserAdapter } from './adapter/user.adapter';
const SCOPE_DESCRIPTION: Record<string, string> = {
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,

View File

@ -8,4 +8,23 @@ export class PrivilegeService {
@Inject('PRIVILEGE_REPOSITORY')
private privilegeRepository: Repository<Privilege>,
) {}
public getAllPrivileges(): Promise<Privilege[]> {
return this.privilegeRepository.find();
}
public async createPrivilege(name: string): Promise<Privilege> {
const privilege = new Privilege();
privilege.name = name;
await this.privilegeRepository.save(privilege);
return privilege;
}
public async getByName(name: string): Promise<Privilege> {
return this.privilegeRepository.findOne({ where: { name } });
}
public async getByID(id: number): Promise<Privilege> {
return this.privilegeRepository.findOne({ where: { id } });
}
}

View File

@ -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<number> {
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[],

View File

@ -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<T>(object: T, keys: string[]): T {
return keys.reduce<T>((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<T>(object: T, keys: string[]): Partial<T> {
return Object.keys(object).reduce<Partial<T>>((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<T>(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<T>(array: T[], keys: string[]): Partial<T>[] {
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

View File

@ -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;
}
}

View File

@ -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 {}

View File

@ -0,0 +1,12 @@
export interface PageOptions {
pageSize?: number;
page?: number;
}
export interface PaginationOptions {
page: number;
pageSize: number;
rowCount: number;
pageCount?: number;
offset?: number;
}