From ca77b20d6ad0029089bf250dad73b91cb89a156b Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 7 Jun 2019 15:56:49 +0200 Subject: [PATCH] HATEOS for users. --- .../UserManagerExtensions.cs | 16 +- src/Squidex.Web/Resource.cs | 15 +- src/Squidex.Web/ResourceLink.cs | 3 +- .../Contents/ContentsController.cs | 4 +- .../Controllers/Users/Models/CreateUserDto.cs | 8 +- .../Controllers/Users/Models/PublicUserDto.cs | 26 ---- .../Controllers/Users/Models/UpdateUserDto.cs | 8 +- .../Users/Models/UserCreatedDto.cs | 26 ---- .../Users/UserManagementController.cs | 28 +++- .../Api/Controllers/Users/UsersController.cs | 8 +- .../guards/user-must-exist.guard.spec.ts | 4 +- .../pages/users/user-page.component.html | 14 +- .../pages/users/user-page.component.ts | 10 +- .../pages/users/users-page.component.html | 11 +- .../pages/users/users-page.component.ts | 4 +- .../services/users.service.spec.ts | 139 +++++++++++------- .../administration/services/users.service.ts | 84 ++++++----- .../administration/state/users.state.spec.ts | 88 +++++------ .../administration/state/users.state.ts | 25 ++-- .../framework/angular/http/hateos.pipes.ts | 23 ++- src/Squidex/app/framework/utils/hateos.ts | 47 ++++-- 21 files changed, 330 insertions(+), 261 deletions(-) delete mode 100644 src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs delete mode 100644 src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs diff --git a/src/Squidex.Domain.Users/UserManagerExtensions.cs b/src/Squidex.Domain.Users/UserManagerExtensions.cs index 8fa0115a5..2db94237f 100644 --- a/src/Squidex.Domain.Users/UserManagerExtensions.cs +++ b/src/Squidex.Domain.Users/UserManagerExtensions.cs @@ -115,7 +115,7 @@ namespace Squidex.Domain.Users return result; } - public static async Task CreateAsync(this UserManager userManager, IUserFactory factory, UserValues values) + public static async Task CreateAsync(this UserManager userManager, IUserFactory factory, UserValues values) { var user = factory.Create(values.Email); @@ -142,10 +142,10 @@ namespace Squidex.Domain.Users throw; } - return user; + return await userManager.ResolveUserAsync(user); } - public static async Task UpdateAsync(this UserManager userManager, string id, UserValues values) + public static async Task UpdateAsync(this UserManager userManager, string id, UserValues values) { var user = await userManager.FindByIdAsync(id); @@ -155,6 +155,8 @@ namespace Squidex.Domain.Users } await UpdateAsync(userManager, user, values); + + return await userManager.ResolveUserAsync(user); } public static async Task UpdateSafeAsync(this UserManager userManager, IdentityUser user, UserValues values) @@ -193,7 +195,7 @@ namespace Squidex.Domain.Users } } - public static async Task LockAsync(this UserManager userManager, string id) + public static async Task LockAsync(this UserManager userManager, string id) { var user = await userManager.FindByIdAsync(id); @@ -203,9 +205,11 @@ namespace Squidex.Domain.Users } await DoChecked(() => userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)), "Cannot lock user."); + + return await userManager.ResolveUserAsync(user); } - public static async Task UnlockAsync(this UserManager userManager, string id) + public static async Task UnlockAsync(this UserManager userManager, string id) { var user = await userManager.FindByIdAsync(id); @@ -215,6 +219,8 @@ namespace Squidex.Domain.Users } await DoChecked(() => userManager.SetLockoutEndDateAsync(user, null), "Cannot unlock user."); + + return await userManager.ResolveUserAsync(user); } private static async Task DoChecked(Func> action, string message) diff --git a/src/Squidex.Web/Resource.cs b/src/Squidex.Web/Resource.cs index b62a5f361..a0e68197c 100644 --- a/src/Squidex.Web/Resource.cs +++ b/src/Squidex.Web/Resource.cs @@ -26,25 +26,30 @@ namespace Squidex.Web public void AddGetLink(string rel, string href) { - AddLink(rel, HttpMethod.Get, href); + AddLink(rel, "GET", href); + } + + public void AddPatchLink(string rel, string href) + { + AddLink(rel, "PATCH", href); } public void AddPostLink(string rel, string href) { - AddLink(rel, HttpMethod.Post, href); + AddLink(rel, "POST", href); } public void AddPutLink(string rel, string href) { - AddLink(rel, HttpMethod.Put, href); + AddLink(rel, "PUT", href); } public void AddDeleteLink(string rel, string href) { - AddLink(rel, HttpMethod.Delete, href); + AddLink(rel, "DELETE", href); } - public void AddLink(string rel, HttpMethod method, string href) + public void AddLink(string rel, string method, string href) { Links[rel] = new ResourceLink { Href = href, Method = method }; } diff --git a/src/Squidex.Web/ResourceLink.cs b/src/Squidex.Web/ResourceLink.cs index 48627ac3a..964610e7d 100644 --- a/src/Squidex.Web/ResourceLink.cs +++ b/src/Squidex.Web/ResourceLink.cs @@ -6,7 +6,6 @@ // ========================================================================== using System.ComponentModel.DataAnnotations; -using System.Net.Http; namespace Squidex.Web { @@ -18,6 +17,6 @@ namespace Squidex.Web [Required] [Display(Description = "The link method.")] - public HttpMethod Method { get; set; } + public string Method { get; set; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index fc1e9e63f..bd374a986 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -278,9 +278,7 @@ namespace Squidex.Areas.Api.Controllers.Contents { await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name); - var publishPermission = Permissions.ForApp(Permissions.AppContentsPublish, app, name); - - if (publish && !User.Permissions().Includes(publishPermission)) + if (publish && !this.HasPermission(Permissions.AppContentsPublish, app, name)) { return new StatusCodeResult(123); } diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs index b59f83b53..9dbd2feac 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs @@ -40,7 +40,13 @@ namespace Squidex.Areas.Api.Controllers.Users.Models public UserValues ToValues() { - return new UserValues { Email = Email, DisplayName = DisplayName, Password = Password, Permissions = new PermissionSet(Permissions) }; + return new UserValues + { + Email = Email, + DisplayName = DisplayName, + Password = Password, + Permissions = new PermissionSet(Permissions) + }; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs deleted file mode 100644 index e398c88be..000000000 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs +++ /dev/null @@ -1,26 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.ComponentModel.DataAnnotations; - -namespace Squidex.Areas.Api.Controllers.Users.Models -{ - public sealed class PublicUserDto - { - /// - /// The id of the user. - /// - [Required] - public string Id { get; set; } - - /// - /// The display name (usually first name and last name) of the user. - /// - [Required] - public string DisplayName { get; set; } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs index 77b41f567..d6391da83 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs @@ -39,7 +39,13 @@ namespace Squidex.Areas.Api.Controllers.Users.Models public UserValues ToValues() { - return new UserValues { Email = Email, DisplayName = DisplayName, Password = Password, Permissions = new PermissionSet(Permissions) }; + return new UserValues + { + Email = Email, + DisplayName = DisplayName, + Password = Password, + Permissions = new PermissionSet(Permissions) + }; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs deleted file mode 100644 index 090d81023..000000000 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs +++ /dev/null @@ -1,26 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.ComponentModel.DataAnnotations; - -namespace Squidex.Areas.Api.Controllers.Users.Models -{ - public sealed class UserCreatedDto - { - /// - /// The id of the user. - /// - [Required] - public string Id { get; set; } - - /// - /// Additional permissions for the user. - /// - [Required] - public string[] Permissions { get; set; } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs b/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs index 16862daab..6157b120b 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs @@ -32,6 +32,7 @@ namespace Squidex.Areas.Api.Controllers.Users [HttpGet] [Route("user-management/")] + [ProducesResponseType(typeof(UsersDto), 200)] [ApiPermission(Permissions.AdminUsersRead)] public async Task GetUsers([FromQuery] string query = null, [FromQuery] int skip = 0, [FromQuery] int take = 10) { @@ -47,6 +48,7 @@ namespace Squidex.Areas.Api.Controllers.Users [HttpGet] [Route("user-management/{id}/")] + [ProducesResponseType(typeof(UserDto), 201)] [ApiPermission(Permissions.AdminUsersRead)] public async Task GetUser(string id) { @@ -64,28 +66,33 @@ namespace Squidex.Areas.Api.Controllers.Users [HttpPost] [Route("user-management/")] + [ProducesResponseType(typeof(UserDto), 201)] [ApiPermission(Permissions.AdminUsersCreate)] public async Task PostUser([FromBody] CreateUserDto request) { - var user = await userManager.CreateAsync(userFactory, request.ToValues()); + var entity = await userManager.CreateAsync(userFactory, request.ToValues()); - var response = new UserCreatedDto { Id = user.Id }; + var response = UserDto.FromUser(entity, this); return Ok(response); } [HttpPut] [Route("user-management/{id}/")] + [ProducesResponseType(typeof(UserDto), 201)] [ApiPermission(Permissions.AdminUsersUpdate)] public async Task PutUser(string id, [FromBody] UpdateUserDto request) { - await userManager.UpdateAsync(id, request.ToValues()); + var entity = await userManager.UpdateAsync(id, request.ToValues()); + + var response = UserDto.FromUser(entity, this); - return NoContent(); + return Ok(response); } [HttpPut] [Route("user-management/{id}/lock/")] + [ProducesResponseType(typeof(UserDto), 201)] [ApiPermission(Permissions.AdminUsersLock)] public async Task LockUser(string id) { @@ -94,13 +101,16 @@ namespace Squidex.Areas.Api.Controllers.Users throw new ValidationException("Locking user failed.", new ValidationError("You cannot lock yourself.")); } - await userManager.LockAsync(id); + var entity = await userManager.LockAsync(id); + + var response = UserDto.FromUser(entity, this); - return NoContent(); + return Ok(response); } [HttpPut] [Route("user-management/{id}/unlock/")] + [ProducesResponseType(typeof(UserDto), 201)] [ApiPermission(Permissions.AdminUsersUnlock)] public async Task UnlockUser(string id) { @@ -109,9 +119,11 @@ namespace Squidex.Areas.Api.Controllers.Users throw new ValidationException("Unlocking user failed.", new ValidationError("You cannot unlock yourself.")); } - await userManager.UnlockAsync(id); + var entity = await userManager.UnlockAsync(id); - return NoContent(); + var response = UserDto.FromUser(entity, this); + + return Ok(response); } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs b/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs index 5311ff642..a5cf31c74 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs @@ -68,7 +68,7 @@ namespace Squidex.Areas.Api.Controllers.Users /// [HttpGet] [Route("users/")] - [ProducesResponseType(typeof(PublicUserDto[]), 200)] + [ProducesResponseType(typeof(UserDto[]), 200)] [ApiPermission] public async Task GetUsers(string query) { @@ -76,9 +76,9 @@ namespace Squidex.Areas.Api.Controllers.Users { var entities = await userResolver.QueryByEmailAsync(query); - var models = entities.Where(x => !x.IsHidden()).Select(x => UserDto.FromUser(x, this)).ToArray(); + var response = entities.Where(x => !x.IsHidden()).Select(x => UserDto.FromUser(x, this)).ToArray(); - return Ok(models); + return Ok(response); } catch (Exception ex) { @@ -100,7 +100,7 @@ namespace Squidex.Areas.Api.Controllers.Users /// [HttpGet] [Route("users/{id}/")] - [ProducesResponseType(typeof(PublicUserDto), 200)] + [ProducesResponseType(typeof(UserDto), 200)] [ApiPermission] public async Task GetUser(string id) { diff --git a/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts b/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts index a5c03338f..a12487077 100644 --- a/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts +++ b/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts @@ -9,7 +9,7 @@ import { Router } from '@angular/router'; import { of } from 'rxjs'; import { IMock, Mock, Times } from 'typemoq'; -import { SnapshotUser, UsersState } from '@app/features/administration/internal'; +import { UserDto, UsersState } from '@app/features/administration/internal'; import { UserMustExistGuard } from './user-must-exist.guard'; @@ -32,7 +32,7 @@ describe('UserMustExistGuard', () => { it('should load user and return true when found', () => { usersState.setup(x => x.select('123')) - .returns(() => of({})); + .returns(() => of({})); let result: boolean; diff --git a/src/Squidex/app/features/administration/pages/users/user-page.component.html b/src/Squidex/app/features/administration/pages/users/user-page.component.html index d23721240..44a638173 100644 --- a/src/Squidex/app/features/administration/pages/users/user-page.component.html +++ b/src/Squidex/app/features/administration/pages/users/user-page.component.html @@ -15,12 +15,14 @@ - - - - + + + + + + diff --git a/src/Squidex/app/features/administration/pages/users/user-page.component.ts b/src/Squidex/app/features/administration/pages/users/user-page.component.ts index 9a7e9b71a..53a9ac15e 100644 --- a/src/Squidex/app/features/administration/pages/users/user-page.component.ts +++ b/src/Squidex/app/features/administration/pages/users/user-page.component.ts @@ -9,7 +9,7 @@ import { Component, OnInit } from '@angular/core'; import { FormBuilder } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; -import { ResourceOwner } from '@app/shared'; +import { hasLink, ResourceOwner } from '@app/shared'; import { CreateUserDto, @@ -46,11 +46,19 @@ export class UserPageComponent extends ResourceOwner implements OnInit { if (selectedUser) { this.userForm.load(selectedUser); + + if (!hasLink(selectedUser, 'update')) { + this.userForm.form.disable(); + } } })); } public save() { + if (this.userForm.form.disabled) { + return; + } + const value = this.userForm.submit(); if (value) { diff --git a/src/Squidex/app/features/administration/pages/users/users-page.component.html b/src/Squidex/app/features/administration/pages/users/users-page.component.html index 167ee3eb9..f71733085 100644 --- a/src/Squidex/app/features/administration/pages/users/users-page.component.html +++ b/src/Squidex/app/features/administration/pages/users/users-page.component.html @@ -12,15 +12,18 @@ -
- + + + + +
diff --git a/src/Squidex/app/features/administration/pages/users/users-page.component.ts b/src/Squidex/app/features/administration/pages/users/users-page.component.ts index 065b20858..f029712b4 100644 --- a/src/Squidex/app/features/administration/pages/users/users-page.component.ts +++ b/src/Squidex/app/features/administration/pages/users/users-page.component.ts @@ -51,8 +51,8 @@ export class UsersPageComponent implements OnInit { this.usersState.unlock(user); } - public trackByUser(index: number, userInfo: { user: UserDto }) { - return userInfo.user.id; + public trackByUser(index: number, user: UserDto) { + return user.id; } } diff --git a/src/Squidex/app/features/administration/services/users.service.spec.ts b/src/Squidex/app/features/administration/services/users.service.spec.ts index ee74d079f..03b9b68fb 100644 --- a/src/Squidex/app/features/administration/services/users.service.spec.ts +++ b/src/Squidex/app/features/administration/services/users.service.spec.ts @@ -8,7 +8,7 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { inject, TestBed } from '@angular/core/testing'; -import { ApiUrlConfig } from '@app/framework'; +import { ApiUrlConfig, Resource } from '@app/framework'; import { UserDto, @@ -50,27 +50,15 @@ describe('UsersService', () => { req.flush({ total: 100, items: [ - { - id: '123', - email: 'mail1@domain.com', - displayName: 'User1', - permissions: ['Permission1'], - isLocked: true - }, - { - id: '456', - email: 'mail2@domain.com', - displayName: 'User2', - permissions: ['Permission2'], - isLocked: true - } + userResponse(12), + userResponse(13) ] }); expect(users!).toEqual( new UsersDto(100, [ - new UserDto('123', 'mail1@domain.com', 'User1', ['Permission1'], true), - new UserDto('456', 'mail2@domain.com', 'User2', ['Permission2'], true) + createUser(12), + createUser(13) ])); })); @@ -91,27 +79,15 @@ describe('UsersService', () => { req.flush({ total: 100, items: [ - { - id: '123', - email: 'mail1@domain.com', - displayName: 'User1', - permissions: ['Permission1'], - isLocked: true - }, - { - id: '456', - email: 'mail2@domain.com', - displayName: 'User2', - permissions: ['Permission2'], - isLocked: true - } + userResponse(12), + userResponse(13) ] }); expect(users!).toEqual( new UsersDto(100, [ - new UserDto('123', 'mail1@domain.com', 'User1', ['Permission1'], true), - new UserDto('456', 'mail2@domain.com', 'User2', ['Permission2'], true) + createUser(12), + createUser(13) ])); })); @@ -129,15 +105,9 @@ describe('UsersService', () => { expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({ - id: '123', - email: 'mail1@domain.com', - displayName: 'User1', - permissions: ['Permission1'], - isLocked: true - }); + req.flush(userResponse(12)); - expect(user!).toEqual(new UserDto('123', 'mail1@domain.com', 'User1', ['Permission1'], true)); + expect(user!).toEqual(createUser(12)); })); it('should make post request to create user', @@ -156,9 +126,9 @@ describe('UsersService', () => { expect(req.request.method).toEqual('POST'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({ id: '123', pictureUrl: 'path/to/image1' }); + req.flush(userResponse(12)); - expect(user!).toEqual(new UserDto('123', dto.email, dto.displayName, dto.permissions, false)); + expect(user!).toEqual(createUser(12)); })); it('should make put request to update user', @@ -166,39 +136,108 @@ describe('UsersService', () => { const dto = { email: 'mail@squidex.io', displayName: 'Squidex User', permissions: ['Permission1'], password: 'password' }; - userManagementService.putUser('123', dto).subscribe(); + const resource: Resource = { + _links: { + update: { method: 'PUT', href: 'api/user-management/123' } + } + }; + + let user: UserDto; + + userManagementService.putUser(resource, dto).subscribe(result => { + user = result; + }); const req = httpMock.expectOne('http://service/p/api/user-management/123'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({}); + req.flush(userResponse(12)); + + expect(user!).toEqual(createUser(12)); })); it('should make put request to lock user', inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { - userManagementService.lockUser('123').subscribe(); + const resource: Resource = { + _links: { + lock: { method: 'PUT', href: 'api/user-management/123/lock' } + } + }; + + let user: UserDto; + + userManagementService.lockUser(resource).subscribe(result => { + user = result; + }); const req = httpMock.expectOne('http://service/p/api/user-management/123/lock'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({}); + req.flush(userResponse(12)); + + expect(user!).toEqual(createUser(12)); })); it('should make put request to unlock user', inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { - userManagementService.unlockUser('123').subscribe(); + const resource: Resource = { + _links: { + unlock: { method: 'PUT', href: 'api/user-management/123/unlock' } + } + }; + + let user: UserDto; + + userManagementService.unlockUser(resource).subscribe(result => { + user = result; + }); const req = httpMock.expectOne('http://service/p/api/user-management/123/unlock'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({}); + req.flush(userResponse(12)); + + expect(user!).toEqual(createUser(12)); })); -}); \ No newline at end of file + + function userResponse(id: number) { + return { + id: `${id}`, + email: `user${id}@domain.com`, + displayName: `user${id}`, + permissions: [ + `Permission${id}` + ], + isLocked: true, + _links: { + update: { + method: 'PUT', href: `/users/${id}` + } + } + }; + } +}); + +export function createUser(id: number, suffix = '') { + const result = new UserDto(`${id}`, + `user${id}${suffix}@domain.com`, + `user${id}${suffix}`, + [ + `Permission${id}${suffix}` + ], + true); + + result._links['update'] = { + method: 'PUT', href: `/users/${id}` + }; + + return result; +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/services/users.service.ts b/src/Squidex/app/features/administration/services/users.service.ts index b42fe46a2..a1eb3e9c8 100644 --- a/src/Squidex/app/features/administration/services/users.service.ts +++ b/src/Squidex/app/features/administration/services/users.service.ts @@ -12,7 +12,6 @@ import { map } from 'rxjs/operators'; import { ApiUrlConfig, - Model, pretifyError, Resource, ResourceLinks, @@ -21,11 +20,11 @@ import { } from '@app/shared'; export class UsersDto extends ResultSet { - public _links: ResourceLinks; + public readonly _links: ResourceLinks = {}; } -export class UserDto extends Model { - public _links: ResourceLinks; +export class UserDto { + public readonly _links: ResourceLinks = {}; constructor( public readonly id: string, @@ -34,11 +33,6 @@ export class UserDto extends Model { public readonly permissions: string[] = [], public readonly isLocked?: boolean ) { - super(); - } - - public with(value: Partial): UserDto { - return this.clone(value); } } @@ -69,15 +63,7 @@ export class UsersService { return this.http.get<{ total: number, items: any[] } & Resource>(url).pipe( map(body => { - const users = body.items.map(item => - withLinks( - new UserDto( - item.id, - item.email, - item.displayName, - item.permissions, - item.isLocked), - item)); + const users = body.items.map(item => parseUser(item)); return withLinks(new UsersDto(body.total, users), body); }), @@ -89,14 +75,7 @@ export class UsersService { return this.http.get(url).pipe( map(body => { - const user = new UserDto( - body.id, - body.email, - body.displayName, - body.permissions, - body.isLocked); - - return user; + return parseUser(body); }), pretifyError('Failed to load user. Please reload.')); } @@ -106,36 +85,55 @@ export class UsersService { return this.http.post(url, dto).pipe( map(body => { - const user = new UserDto( - body.id, - dto.email, - dto.displayName, - dto.permissions, - false); - - return user; + return parseUser(body); }), pretifyError('Failed to create user. Please reload.')); } - public putUser(id: string, dto: UpdateUserDto): Observable { - const url = this.apiUrl.buildUrl(`api/user-management/${id}`); + public putUser(user: Resource, dto: UpdateUserDto): Observable { + const link = user._links['update']; + + const url = this.apiUrl.buildUrl(link.href); - return this.http.put(url, dto).pipe( + return this.http.request(link.method, url, { body: dto }).pipe( + map(body => { + return parseUser(body); + }), pretifyError('Failed to update user. Please reload.')); } - public lockUser(id: string): Observable { - const url = this.apiUrl.buildUrl(`api/user-management/${id}/lock`); + public lockUser(user: Resource): Observable { + const link = user._links['lock']; + + const url = this.apiUrl.buildUrl(link.href); - return this.http.put(url, {}).pipe( + return this.http.request(link.method, url).pipe( + map(body => { + return parseUser(body); + }), pretifyError('Failed to load users. Please retry.')); } - public unlockUser(id: string): Observable { - const url = this.apiUrl.buildUrl(`api/user-management/${id}/unlock`); + public unlockUser(user: Resource): Observable { + const link = user._links['unlock']; - return this.http.put(url, {}).pipe( + const url = this.apiUrl.buildUrl(link.href); + + return this.http.request(link.method, url).pipe( + map(body => { + return parseUser(body); + }), pretifyError('Failed to load users. Please retry.')); } +} + +function parseUser(response: any) { + return withLinks( + new UserDto( + response.id, + response.email, + response.displayName, + response.permissions, + response.isLocked), + response); } \ No newline at end of file diff --git a/src/Squidex/app/features/administration/state/users.state.spec.ts b/src/Squidex/app/features/administration/state/users.state.spec.ts index e735870a5..b9e4971db 100644 --- a/src/Squidex/app/features/administration/state/users.state.spec.ts +++ b/src/Squidex/app/features/administration/state/users.state.spec.ts @@ -8,7 +8,7 @@ import { of, throwError } from 'rxjs'; import { IMock, It, Mock, Times } from 'typemoq'; -import { AuthService, DialogService } from '@app/shared'; +import { DialogService } from '@app/shared'; import { UserDto, @@ -16,15 +16,16 @@ import { UsersService } from '@app/features/administration/internal'; + import { UsersState } from './users.state'; +import { createUser } from './../services/users.service.spec'; + describe('UsersState', () => { - const oldUsers = [ - new UserDto('id1', 'mail1@mail.de', 'name1', ['Permission1'], false), - new UserDto('id2', 'mail2@mail.de', 'name2', ['Permission2'], true) - ]; + const user1 = createUser(1); + const user2 = createUser(2); - const newUser = new UserDto('id3', 'mail3@mail.de', 'name3', ['Permission3'], false); + const newUser = createUser(3); let dialogs: IMock; let usersService: IMock; @@ -44,11 +45,11 @@ describe('UsersState', () => { describe('Loading', () => { it('should load users', () => { usersService.setup(x => x.getUsers(10, 0, undefined)) - .returns(() => of(new UsersDto(200, oldUsers))).verifiable(); + .returns(() => of(new UsersDto(200, [user1, user2]))).verifiable(); usersState.load().subscribe(); - expect(usersState.snapshot.users.values).toEqual(oldUsers); + expect(usersState.snapshot.users.values).toEqual([user1, user2]); expect(usersState.snapshot.usersPager.numberOfItems).toEqual(200); expect(usersState.snapshot.isLoaded).toBeTruthy(); @@ -57,7 +58,7 @@ describe('UsersState', () => { it('should show notification on load when reload is true', () => { usersService.setup(x => x.getUsers(10, 0, undefined)) - .returns(() => of(new UsersDto(200, oldUsers))).verifiable(); + .returns(() => of(new UsersDto(200, [user1, user2]))).verifiable(); usersState.load(true).subscribe(); @@ -68,18 +69,18 @@ describe('UsersState', () => { it('should replace selected user when reloading', () => { const newUsers = [ - new UserDto('id1', 'mail1@mail.de_new', 'name1_new', ['Permission1_New'], false), - new UserDto('id2', 'mail2@mail.de_new', 'name2_new', ['Permission2_New'], true) + createUser(1, '_new'), + createUser(2, '_new') ]; usersService.setup(x => x.getUsers(10, 0, undefined)) - .returns(() => of(new UsersDto(200, oldUsers))).verifiable(Times.exactly(2)); + .returns(() => of(new UsersDto(200, [user1, user2]))).verifiable(Times.exactly(2)); usersService.setup(x => x.getUsers(10, 0, undefined)) .returns(() => of(new UsersDto(200, newUsers))); usersState.load().subscribe(); - usersState.select('id1').subscribe(); + usersState.select(user1.id).subscribe(); usersState.load().subscribe(); expect(usersState.snapshot.selectedUser).toEqual(newUsers[0]); @@ -87,7 +88,7 @@ describe('UsersState', () => { it('should load next page and prev page when paging', () => { usersService.setup(x => x.getUsers(10, 0, undefined)) - .returns(() => of(new UsersDto(200, oldUsers))).verifiable(Times.exactly(2)); + .returns(() => of(new UsersDto(200, [user1, user2]))).verifiable(Times.exactly(2)); usersService.setup(x => x.getUsers(10, 10, undefined)) .returns(() => of(new UsersDto(200, []))).verifiable(); @@ -112,7 +113,7 @@ describe('UsersState', () => { describe('Updates', () => { beforeEach(() => { usersService.setup(x => x.getUsers(10, 0, undefined)) - .returns(() => of(new UsersDto(200, oldUsers))).verifiable(); + .returns(() => of(new UsersDto(200, [user1, user2]))).verifiable(); usersState.load().subscribe(); }); @@ -120,12 +121,12 @@ describe('UsersState', () => { it('should return user on select and not load when already loaded', () => { let selectedUser: UserDto; - usersState.select('id1').subscribe(x => { + usersState.select(user1.id).subscribe(x => { selectedUser = x!; }); - expect(selectedUser!).toEqual(oldUsers[0]); - expect(usersState.snapshot.selectedUser).toEqual(oldUsers[0]); + expect(selectedUser!).toEqual(user1); + expect(usersState.snapshot.selectedUser).toEqual(user1); }); it('should return user on select and load when not loaded', () => { @@ -168,46 +169,49 @@ describe('UsersState', () => { }); it('should mark as locked when locked', () => { - usersService.setup(x => x.lockUser('id1')) - .returns(() => of({})).verifiable(); + const updated = createUser(2, '_new'); + + usersService.setup(x => x.lockUser(user2)) + .returns(() => of(updated)).verifiable(); - usersState.select('id1').subscribe(); - usersState.lock(oldUsers[0]).subscribe(); + usersState.select(user2.id).subscribe(); + usersState.lock(user2).subscribe(); - const user_1 = usersState.snapshot.users.at(0); + const userUser2 = usersState.snapshot.users.at(1); - expect(user_1.isLocked).toBeTruthy(); - expect(user_1).toBe(usersState.snapshot.selectedUser!); + expect(userUser2).toBe(usersState.snapshot.selectedUser!); }); it('should unmark as locked when unlocked', () => { - usersService.setup(x => x.unlockUser('id2')) - .returns(() => of({})).verifiable(); + const updated = createUser(2, '_new'); - usersState.select('id2').subscribe(); - usersState.unlock(oldUsers[1]).subscribe(); + usersService.setup(x => x.unlockUser(user2)) + .returns(() => of(updated)).verifiable(); - const user_1 = usersState.snapshot.users.at(1); + usersState.select(user2.id).subscribe(); + usersState.unlock(user2).subscribe(); - expect(user_1.isLocked).toBeFalsy(); - expect(user_1).toBe(usersState.snapshot.selectedUser!); + const newUser2 = usersState.snapshot.users.at(1); + + expect(newUser2).toEqual(updated); + expect(newUser2).toBe(usersState.snapshot.selectedUser!); }); it('should update user properties when updated', () => { const request = { email: 'new@mail.com', displayName: 'New', permissions: ['Permission1'] }; - usersService.setup(x => x.putUser('id1', request)) - .returns(() => of({})).verifiable(); + const updated = createUser(2, '_new'); + + usersService.setup(x => x.putUser(user2, request)) + .returns(() => of(updated)).verifiable(); - usersState.select('id1').subscribe(); - usersState.update(oldUsers[0], request).subscribe(); + usersState.select(user2.id).subscribe(); + usersState.update(user2, request).subscribe(); - const user_1 = usersState.snapshot.users.at(0); + const newUser2 = usersState.snapshot.users.at(1); - expect(user_1.email).toEqual(request.email); - expect(user_1.displayName).toEqual(request.displayName); - expect(user_1.permissions).toEqual(request.permissions); - expect(user_1).toBe(usersState.snapshot.selectedUser!); + expect(newUser2).toEqual(updated); + expect(newUser2).toBe(usersState.snapshot.selectedUser!); }); it('should add user to snapshot when created', () => { @@ -218,7 +222,7 @@ describe('UsersState', () => { usersState.create(request).subscribe(); - expect(usersState.snapshot.users.values).toEqual([newUser, ...oldUsers]); + expect(usersState.snapshot.users.values).toEqual([newUser, user1, user2]); expect(usersState.snapshot.usersPager.numberOfItems).toBe(201); }); }); diff --git a/src/Squidex/app/features/administration/state/users.state.ts b/src/Squidex/app/features/administration/state/users.state.ts index 49c5163cc..04a4bc80a 100644 --- a/src/Squidex/app/features/administration/state/users.state.ts +++ b/src/Squidex/app/features/administration/state/users.state.ts @@ -7,7 +7,7 @@ import { Injectable } from '@angular/core'; import { Observable, of } from 'rxjs'; -import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'; +import { catchError, distinctUntilChanged, map, tap } from 'rxjs/operators'; import '@app/framework/utils/rxjs-extensions'; @@ -15,6 +15,7 @@ import { DialogService, ImmutableArray, Pager, + ResourceLinks, shareSubscribed, State } from '@app/shared'; @@ -36,6 +37,9 @@ interface Snapshot { // The query to filter users. usersQuery?: string; + // The resource links. + links: ResourceLinks; + // Indicates if the users are loaded. isLoaded?: boolean; @@ -56,6 +60,10 @@ export class UsersState extends State { this.changes.pipe(map(x => x.usersPager), distinctUntilChanged()); + public links = + this.changes.pipe(map(x => x.links), + distinctUntilChanged()); + public selectedUser = this.changes.pipe(map(x => x.selectedUser), distinctUntilChanged()); @@ -68,7 +76,7 @@ export class UsersState extends State { private readonly dialogs: DialogService, private readonly usersService: UsersService ) { - super({ users: ImmutableArray.empty(), usersPager: new Pager(0) }); + super({ users: ImmutableArray.empty(), usersPager: new Pager(0), links: {} }); } public select(id: string | null): Observable { @@ -108,7 +116,7 @@ export class UsersState extends State { this.snapshot.usersPager.pageSize, this.snapshot.usersPager.skip, this.snapshot.usersQuery).pipe( - tap(({ total, items }) => { + tap(({ total, items, _links: links }) => { if (isReload) { this.dialogs.notifyInfo('Users reloaded.'); } @@ -123,7 +131,7 @@ export class UsersState extends State { selectedUser = users.find(x => x.id === selectedUser!.id) || selectedUser; } - return { ...s, users, usersPager, selectedUser, isLoaded: true }; + return { ...s, users, usersPager, links, selectedUser, isLoaded: true }; }); }), shareSubscribed(this.dialogs)); @@ -143,8 +151,7 @@ export class UsersState extends State { } public update(user: UserDto, request: UpdateUserDto): Observable { - return this.usersService.putUser(user.id, request).pipe( - switchMap(() => this.usersService.getUser(user.id)), + return this.usersService.putUser(user, request).pipe( tap(updated => { this.replaceUser(updated); }), @@ -152,8 +159,7 @@ export class UsersState extends State { } public lock(user: UserDto): Observable { - return this.usersService.lockUser(user.id).pipe( - switchMap(() => this.usersService.getUser(user.id)), + return this.usersService.lockUser(user).pipe( tap(updated => { this.replaceUser(updated); }), @@ -161,8 +167,7 @@ export class UsersState extends State { } public unlock(user: UserDto): Observable { - return this.usersService.unlockUser(user.id).pipe( - switchMap(() => this.usersService.getUser(user.id)), + return this.usersService.unlockUser(user).pipe( tap(updated => { this.replaceUser(updated); }), diff --git a/src/Squidex/app/framework/angular/http/hateos.pipes.ts b/src/Squidex/app/framework/angular/http/hateos.pipes.ts index 979c95205..f580b5bb0 100644 --- a/src/Squidex/app/framework/angular/http/hateos.pipes.ts +++ b/src/Squidex/app/framework/angular/http/hateos.pipes.ts @@ -1,14 +1,25 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + import { Pipe, PipeTransform } from '@angular/core'; -import { Resource } from '@app/framework/internal'; +import { + hasLink, + Resource, + ResourceLinks +} from '@app/framework/internal'; @Pipe({ name: 'sqxHasLink', pure: true }) export class HasLinkPipe implements PipeTransform { - public transform(value: Resource, rel: string) { - return value._links && !!value._links[rel]; + public transform(value: Resource | ResourceLinks, rel: string) { + return hasLink(value, rel); } } @@ -17,7 +28,7 @@ export class HasLinkPipe implements PipeTransform { pure: true }) export class HasNoLinkPipe implements PipeTransform { - public transform(value: Resource, rel: string) { - return !value._links || !value._links[rel]; + public transform(value: Resource | ResourceLinks, rel: string) { + return !hasLink(value, rel); } -} \ No newline at end of file +} diff --git a/src/Squidex/app/framework/utils/hateos.ts b/src/Squidex/app/framework/utils/hateos.ts index 14e5357d8..c1c2099c7 100644 --- a/src/Squidex/app/framework/utils/hateos.ts +++ b/src/Squidex/app/framework/utils/hateos.ts @@ -5,21 +5,40 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ - export interface Resource { - _links?: { [rel: string]: ResourceLink }; - } +export interface Resource { + readonly _links: { [rel: string]: ResourceLink }; +} - export type ResourceLinks = { [rel: string]: ResourceLink }; - export type ResourceLink = { href: string; method: ResourceMethod; }; +export type ResourceLinks = { [rel: string]: ResourceLink }; +export type ResourceLink = { href: string; method: ResourceMethod; }; - export function withLinks(value: T, source: Resource) { - value._links = source._links; +export function withLinks(value: T, source: Resource) { + if (value._links && source._links) { + for (let key in source._links) { + if (source._links.hasOwnProperty(key)) { + value._links[key] = source._links[key]; + } + } - return value; - } + Object.freeze(value._links); + } - export type ResourceMethod = - 'get' | - 'post' | - 'put' | - 'delete'; \ No newline at end of file + return value; +} + +export function hasLink(value: Resource | ResourceLinks, rel: string): boolean { + const link = getLink(value, rel); + + return !!(link && link.method && link.href); +} + +export function getLink(value: Resource | ResourceLinks, rel: string): ResourceLink { + return value ? (value._links ? value._links[rel] : value[rel]) : undefined; +} + +export type ResourceMethod = + 'GET' | + 'DELETE' | + 'PATCH' | + 'POST' | + 'PUT'; \ No newline at end of file