New user features feature/93-user_endpoints #95
| @@ -1,13 +1,14 @@ | ||||
| import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers'; | ||||
| import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; | ||||
| import { getConnectionManager, Repository } from 'typeorm'; | ||||
| import { UserIdsNotMatchingError, UserNotFoundError } from '../errors/UserErrors'; | ||||
| import { UserIdsNotMatchingError, UsernameContainsIllegalCharacterError, UserNotFoundError } from '../errors/UserErrors'; | ||||
| import { UserGroupNotFoundError } from '../errors/UserGroupErrors'; | ||||
| import { CreateUser } from '../models/actions/create/CreateUser'; | ||||
| import { UpdateUser } from '../models/actions/update/UpdateUser'; | ||||
| import { User } from '../models/entities/User'; | ||||
| import { ResponseEmpty } from '../models/responses/ResponseEmpty'; | ||||
| import { ResponseUser } from '../models/responses/ResponseUser'; | ||||
| import { ResponseUserPermissions } from '../models/responses/ResponseUserPermissions'; | ||||
| import { PermissionController } from './PermissionController'; | ||||
|  | ||||
|  | ||||
| @@ -26,7 +27,7 @@ export class UserController { | ||||
| 	@Get() | ||||
| 	@Authorized("USER:GET") | ||||
| 	@ResponseSchema(ResponseUser, { isArray: true }) | ||||
| 	@OpenAPI({ description: 'Lists all users. <br> This includes their groups and permissions directly granted to them (if existing/associated).' }) | ||||
| 	@OpenAPI({ description: 'Lists all users. <br> This includes their groups and permissions granted to them.' }) | ||||
| 	async getAll() { | ||||
| 		let responseUsers: ResponseUser[] = new Array<ResponseUser>(); | ||||
| 		const users = await this.userRepository.find({ relations: ['permissions', 'groups', 'groups.permissions'] }); | ||||
| @@ -41,17 +42,30 @@ export class UserController { | ||||
| 	@ResponseSchema(ResponseUser) | ||||
| 	@ResponseSchema(UserNotFoundError, { statusCode: 404 }) | ||||
| 	@OnUndefined(UserNotFoundError) | ||||
| 	@OpenAPI({ description: 'Lists all information about the user whose id got provided. <br> Please remember that only permissions granted directly to the user will show up here, not permissions inherited from groups.' }) | ||||
| 	@OpenAPI({ description: 'Lists all information about the user whose id got provided. <br> Please remember that all permissions granted to the user will show up here.' }) | ||||
| 	async getOne(@Param('id') id: number) { | ||||
| 		let user = await this.userRepository.findOne({ id: id }, { relations: ['permissions', 'groups', 'groups.permissions'] }) | ||||
| 		if (!user) { throw new UserNotFoundError(); } | ||||
| 		return new ResponseUser(user); | ||||
| 	} | ||||
|  | ||||
| 	@Get('/:id/permissions') | ||||
| 	@Authorized("USER:GET") | ||||
| 	@ResponseSchema(ResponseUser) | ||||
| 	@ResponseSchema(UserNotFoundError, { statusCode: 404 }) | ||||
| 	@OnUndefined(UserNotFoundError) | ||||
| 	@OpenAPI({ description: 'Lists all permissions granted to the user sorted into directly granted and inherited as permission response objects.' }) | ||||
| 	async getPermissions(@Param('id') id: number) { | ||||
| 		let user = await this.userRepository.findOne({ id: id }, { relations: ['permissions', 'groups', 'groups.permissions', 'permissions.principal', 'groups.permissions.principal'] }) | ||||
| 		if (!user) { throw new UserNotFoundError(); } | ||||
| 		return new ResponseUserPermissions(user); | ||||
| 	} | ||||
|  | ||||
| 	@Post() | ||||
| 	@Authorized("USER:CREATE") | ||||
| 	@ResponseSchema(ResponseUser) | ||||
| 	@ResponseSchema(UserGroupNotFoundError) | ||||
| 	@ResponseSchema(UserGroupNotFoundError, { statusCode: 404 }) | ||||
| 	@ResponseSchema(UsernameContainsIllegalCharacterError, { statusCode: 406 }) | ||||
| 	@OpenAPI({ description: 'Create a new user. <br> If you want to grant permissions to the user you have to create them seperately by posting to /api/permissions after creating the user.' }) | ||||
| 	async post(@Body({ validate: true }) createUser: CreateUser) { | ||||
| 		let user; | ||||
| @@ -70,6 +84,7 @@ export class UserController { | ||||
| 	@ResponseSchema(ResponseUser) | ||||
| 	@ResponseSchema(UserNotFoundError, { statusCode: 404 }) | ||||
| 	@ResponseSchema(UserIdsNotMatchingError, { statusCode: 406 }) | ||||
| 	@ResponseSchema(UsernameContainsIllegalCharacterError, { statusCode: 406 }) | ||||
| 	@OpenAPI({ description: "Update the user whose id you provided. <br> To change the permissions directly granted to the user please use /api/permissions instead. <br> Please remember that ids can't be changed." }) | ||||
| 	async put(@Param('id') id: number, @Body({ validate: true }) updateUser: UpdateUser) { | ||||
| 		let oldUser = await this.userRepository.findOne({ id: id }); | ||||
|   | ||||
| @@ -4,7 +4,7 @@ import { NotAcceptableError, NotFoundError } from 'routing-controllers'; | ||||
|  | ||||
| /** | ||||
|  * Error to throw when no username or email is set. | ||||
|  * We somehow need to identify you :) | ||||
|  * We somehow need to identify you on login. | ||||
|  */ | ||||
| export class UsernameOrEmailNeededError extends NotFoundError { | ||||
| 	@IsString() | ||||
| @@ -14,6 +14,30 @@ export class UsernameOrEmailNeededError extends NotFoundError { | ||||
| 	message = "No username or email is set!" | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Error to throw when no username contains illegal characters. | ||||
|  * Right now the only one is "@" but this could change in the future. | ||||
|  */ | ||||
| export class UsernameContainsIllegalCharacterError extends NotAcceptableError { | ||||
| 	@IsString() | ||||
| 	name = "UsernameContainsIllegalCharacterError" | ||||
|  | ||||
| 	@IsString() | ||||
| 	message = "The provided username contains illegal characters! \n Right now the following characters are considered illegal: '@'" | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Error to throw when no email is set. | ||||
|  * We somehow need to identify you :) | ||||
|  */ | ||||
| export class UserEmailNeededError extends NotFoundError { | ||||
| 	@IsString() | ||||
| 	name = "UserEmailNeededError" | ||||
|  | ||||
| 	@IsString() | ||||
| 	message = "No email is set! \n You have to provide email addresses for users (used for password reset among others)." | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Error to throw when a user couldn't be found. | ||||
|  */ | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| import * as argon2 from "argon2"; | ||||
| import { IsBoolean, IsEmail, IsOptional, IsPhoneNumber, IsString, IsUrl } from 'class-validator'; | ||||
| import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl } from 'class-validator'; | ||||
| import { getConnectionManager } from 'typeorm'; | ||||
| import * as uuid from 'uuid'; | ||||
| import { config } from '../../../config'; | ||||
| import { UsernameOrEmailNeededError } from '../../../errors/UserErrors'; | ||||
| import { UserEmailNeededError, UsernameContainsIllegalCharacterError } from '../../../errors/UserErrors'; | ||||
| import { UserGroupNotFoundError } from '../../../errors/UserGroupErrors'; | ||||
| import { User } from '../../entities/User'; | ||||
| import { UserGroup } from '../../entities/UserGroup'; | ||||
| @@ -33,7 +33,7 @@ export class CreateUser { | ||||
|  | ||||
|     /** | ||||
|      * The new user's username. | ||||
|      * You have to provide at least one of: {email, username}. | ||||
|      * You have to provide a email addres, so this is optional. | ||||
|      */ | ||||
|     @IsOptional() | ||||
|     @IsString() | ||||
| @@ -41,12 +41,11 @@ export class CreateUser { | ||||
|  | ||||
|     /** | ||||
|      * The new user's email address. | ||||
|      * You have to provide at least one of: {email, username}. | ||||
|      */ | ||||
|     @IsEmail() | ||||
|     @IsString() | ||||
|     @IsOptional() | ||||
|     email?: string; | ||||
|     @IsNotEmpty() | ||||
|     email: string; | ||||
|  | ||||
|     /** | ||||
|      * The new user's phone number. | ||||
| @@ -92,9 +91,10 @@ export class CreateUser { | ||||
|     public async toEntity(): Promise<User> { | ||||
|         let newUser: User = new User(); | ||||
|  | ||||
|         if (this.email === undefined && this.username === undefined) { | ||||
|             throw new UsernameOrEmailNeededError(); | ||||
|         if (!this.email) { | ||||
|             throw new UserEmailNeededError(); | ||||
|         } | ||||
|         if (this.username.includes("@")) { throw new UsernameContainsIllegalCharacterError(); } | ||||
|  | ||||
|         newUser.email = this.email | ||||
|         newUser.username = this.username | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| import * as argon2 from "argon2"; | ||||
| import { IsBoolean, IsEmail, IsInt, IsOptional, IsPhoneNumber, IsString, IsUrl } from 'class-validator'; | ||||
| import { IsBoolean, IsEmail, IsInt, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl } from 'class-validator'; | ||||
| import { getConnectionManager } from 'typeorm'; | ||||
| import { config } from '../../../config'; | ||||
| import { UsernameOrEmailNeededError } from '../../../errors/AuthError'; | ||||
| import { UserEmailNeededError, UsernameContainsIllegalCharacterError } from '../../../errors/UserErrors'; | ||||
| import { UserGroupNotFoundError } from '../../../errors/UserGroupErrors'; | ||||
| import { User } from '../../entities/User'; | ||||
| import { UserGroup } from '../../entities/UserGroup'; | ||||
| @@ -40,7 +40,7 @@ export class UpdateUser { | ||||
|  | ||||
|     /** | ||||
|      * The updated user's username. | ||||
|      * You have to provide at least one of: {email, username}. | ||||
|      * You have to provide a email addres, so this is optional. | ||||
|      */ | ||||
|     @IsOptional() | ||||
|     @IsString() | ||||
| @@ -48,12 +48,11 @@ export class UpdateUser { | ||||
|  | ||||
|     /** | ||||
|      * The updated user's email address. | ||||
|      * You have to provide at least one of: {email, username}. | ||||
|      */ | ||||
|     @IsEmail() | ||||
|     @IsString() | ||||
|     @IsOptional() | ||||
|     email?: string; | ||||
|     @IsNotEmpty() | ||||
|     email: string; | ||||
|  | ||||
|     /** | ||||
|      * The updated user's phone number. | ||||
| @@ -99,16 +98,18 @@ export class UpdateUser { | ||||
|      * @param user The user that shall be updated. | ||||
|      */ | ||||
|     public async update(user: User): Promise<User> { | ||||
|         user.email = this.email; | ||||
|         user.username = this.username; | ||||
|         if ((user.email === undefined || user.email === null) && (user.username === undefined || user.username === null)) { | ||||
|             throw new UsernameOrEmailNeededError(); | ||||
|         if (!this.email) { | ||||
|             throw new UserEmailNeededError(); | ||||
|         } | ||||
|         if (this.username.includes("@")) { throw new UsernameContainsIllegalCharacterError(); } | ||||
|  | ||||
|         if (this.password) { | ||||
|             user.password = await argon2.hash(this.password + user.uuid); | ||||
|             user.refreshTokenCount = user.refreshTokenCount + 1; | ||||
|         } | ||||
|  | ||||
|         user.email = this.email; | ||||
|         user.username = this.username; | ||||
|         user.enabled = this.enabled; | ||||
|         user.firstname = this.firstname | ||||
|         user.middlename = this.middlename | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import { ChildEntity, Column, JoinTable, ManyToMany, OneToMany } from "typeorm"; | ||||
| import { config } from '../../config'; | ||||
| import { ResponsePrincipal } from '../responses/ResponsePrincipal'; | ||||
| import { ResponseUser } from '../responses/ResponseUser'; | ||||
| import { Permission } from './Permission'; | ||||
| import { Principal } from './Principal'; | ||||
| import { UserAction } from './UserAction'; | ||||
| import { UserGroup } from './UserGroup'; | ||||
| @@ -25,9 +26,10 @@ export class User extends Principal { | ||||
|   * The user's e-mail address. | ||||
|   * Either username or email has to be set (otherwise the user couldn't log in). | ||||
|   */ | ||||
|   @Column({ nullable: true, unique: true }) | ||||
|   @Column({ nullable: false, unique: true }) | ||||
|   @IsEmail() | ||||
|   email?: string; | ||||
|   @IsNotEmpty() | ||||
|   email: string; | ||||
|  | ||||
|   /** | ||||
|   * The user's phone number. | ||||
| @@ -128,8 +130,24 @@ export class User extends Principal { | ||||
|   @OneToMany(() => UserAction, action => action.user, { nullable: true }) | ||||
|   actions: UserAction[] | ||||
|  | ||||
|   /** | ||||
|    * Resolves all permissions granted to this user through groups. | ||||
|    */ | ||||
|   public get inheritedPermissions(): Permission[] { | ||||
|     let returnPermissions: Permission[] = new Array<Permission>(); | ||||
|  | ||||
|     if (!this.groups) { return returnPermissions; } | ||||
|     for (let group of this.groups) { | ||||
|       for (let permission of group.permissions) { | ||||
|         returnPermissions.push(permission); | ||||
|       } | ||||
|     } | ||||
|     return returnPermissions; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Resolves all permissions granted to this user through groups or directly to the string enum format. | ||||
|    * Also deduplicates the array. | ||||
|    */ | ||||
|   public get allPermissions(): string[] { | ||||
|     let returnPermissions: string[] = new Array<string>(); | ||||
|   | ||||
| @@ -70,6 +70,7 @@ export class ResponseUser extends ResponsePrincipal { | ||||
|  | ||||
|     /** | ||||
|      * The user's permissions. | ||||
|      * Directly granted or inherited converted to their string form and deduplicated. | ||||
|      */ | ||||
|     @IsArray() | ||||
|     @IsOptional() | ||||
|   | ||||
							
								
								
									
										40
									
								
								src/models/responses/ResponseUserPermissions.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/models/responses/ResponseUserPermissions.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| import { | ||||
|     IsArray, | ||||
|  | ||||
|  | ||||
|     IsOptional | ||||
| } from "class-validator"; | ||||
| import { User } from '../entities/User'; | ||||
| import { ResponsePermission } from './ResponsePermission'; | ||||
|  | ||||
| /** | ||||
|  * Defines the user permission response (get /api/users/:id/permissions). | ||||
| */ | ||||
| export class ResponseUserPermissions { | ||||
|     /** | ||||
|      * The permissions directly granted to the user. | ||||
|      */ | ||||
|     @IsArray() | ||||
|     @IsOptional() | ||||
|     directlyGranted: ResponsePermission[] = new Array<ResponsePermission>(); | ||||
|  | ||||
|     /** | ||||
|      * The permissions directly inherited the user. | ||||
|      */ | ||||
|     @IsArray() | ||||
|     @IsOptional() | ||||
|     inherited: ResponsePermission[] = new Array<ResponsePermission>(); | ||||
|  | ||||
|     /** | ||||
|      * Creates a ResponseUserPermissions object from a user. | ||||
|      * @param user The user the response shall be build for. | ||||
|      */ | ||||
|     public constructor(user: User) { | ||||
|         for (let permission of user.permissions) { | ||||
|             this.directlyGranted.push(permission.toResponse()); | ||||
|         } | ||||
|         for (let permission of user.inheritedPermissions) { | ||||
|             this.inherited.push(permission.toResponse()); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -33,6 +33,7 @@ export default class SeedUsers implements Seeder { | ||||
|         initialUser.lastname = "demo"; | ||||
|         initialUser.username = "demo"; | ||||
|         initialUser.password = "demo"; | ||||
|         initialUser.email = "demo@dev.lauf-fuer-kaya.de" | ||||
|         initialUser.groups = group; | ||||
|         return await connection.getRepository(User).save(await initialUser.toEntity()); | ||||
|     } | ||||
|   | ||||
| @@ -14,7 +14,8 @@ beforeAll(async () => { | ||||
|         "middlename": "demo_logout", | ||||
|         "lastname": "demo_logout", | ||||
|         "username": "demo_logout", | ||||
|         "password": "demo_logout" | ||||
|         "password": "demo_logout", | ||||
|         "email": "demo_logout@dev.lauf-fuer-kaya.de" | ||||
|     }, { | ||||
|         headers: { "authorization": "Bearer " + res_login.data["access_token"] }, | ||||
|         validateStatus: undefined | ||||
|   | ||||
| @@ -14,7 +14,8 @@ beforeAll(async () => { | ||||
|         "middlename": "demo_refresh", | ||||
|         "lastname": "demo_refresh", | ||||
|         "username": "demo_refresh", | ||||
|         "password": "demo_refresh" | ||||
|         "password": "demo_refresh", | ||||
|         "email": "demo_refresh@dev.lauf-fuer-kaya.de" | ||||
|     }, { | ||||
|         headers: { "authorization": "Bearer " + res_login.data["access_token"] }, | ||||
|         validateStatus: undefined | ||||
|   | ||||
| @@ -14,7 +14,8 @@ beforeAll(async () => { | ||||
|         "middlename": "demo_reset", | ||||
|         "lastname": "demo_reset", | ||||
|         "username": "demo_reset", | ||||
|         "password": "demo_reset" | ||||
|         "password": "demo_reset", | ||||
|         "email": "demo_reset@dev.lauf-fuer-kaya.de" | ||||
|     }, { | ||||
|         headers: { "authorization": "Bearer " + res_login.data["access_token"] }, | ||||
|         validateStatus: undefined | ||||
| @@ -24,7 +25,8 @@ beforeAll(async () => { | ||||
|         "middlename": "demo_reset2", | ||||
|         "lastname": "demo_reset2", | ||||
|         "username": "demo_reset2", | ||||
|         "password": "demo_reset2" | ||||
|         "password": "demo_reset2", | ||||
|         "email": "demo_reset1@dev.lauf-fuer-kaya.de" | ||||
|     }, { | ||||
|         headers: { "authorization": "Bearer " + res_login.data["access_token"] }, | ||||
|         validateStatus: undefined | ||||
|   | ||||
		Reference in New Issue
	
	Block a user