Browse Source

HATEOS for users.

pull/363/head
Sebastian 7 years ago
parent
commit
ca77b20d6a
  1. 16
      src/Squidex.Domain.Users/UserManagerExtensions.cs
  2. 15
      src/Squidex.Web/Resource.cs
  3. 3
      src/Squidex.Web/ResourceLink.cs
  4. 4
      src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  5. 8
      src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs
  6. 26
      src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs
  7. 8
      src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs
  8. 26
      src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs
  9. 28
      src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs
  10. 8
      src/Squidex/Areas/Api/Controllers/Users/UsersController.cs
  11. 4
      src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts
  12. 14
      src/Squidex/app/features/administration/pages/users/user-page.component.html
  13. 10
      src/Squidex/app/features/administration/pages/users/user-page.component.ts
  14. 11
      src/Squidex/app/features/administration/pages/users/users-page.component.html
  15. 4
      src/Squidex/app/features/administration/pages/users/users-page.component.ts
  16. 139
      src/Squidex/app/features/administration/services/users.service.spec.ts
  17. 84
      src/Squidex/app/features/administration/services/users.service.ts
  18. 88
      src/Squidex/app/features/administration/state/users.state.spec.ts
  19. 25
      src/Squidex/app/features/administration/state/users.state.ts
  20. 23
      src/Squidex/app/framework/angular/http/hateos.pipes.ts
  21. 47
      src/Squidex/app/framework/utils/hateos.ts

16
src/Squidex.Domain.Users/UserManagerExtensions.cs

@ -115,7 +115,7 @@ namespace Squidex.Domain.Users
return result; return result;
} }
public static async Task<IdentityUser> CreateAsync(this UserManager<IdentityUser> userManager, IUserFactory factory, UserValues values) public static async Task<UserWithClaims> CreateAsync(this UserManager<IdentityUser> userManager, IUserFactory factory, UserValues values)
{ {
var user = factory.Create(values.Email); var user = factory.Create(values.Email);
@ -142,10 +142,10 @@ namespace Squidex.Domain.Users
throw; throw;
} }
return user; return await userManager.ResolveUserAsync(user);
} }
public static async Task UpdateAsync(this UserManager<IdentityUser> userManager, string id, UserValues values) public static async Task<UserWithClaims> UpdateAsync(this UserManager<IdentityUser> userManager, string id, UserValues values)
{ {
var user = await userManager.FindByIdAsync(id); var user = await userManager.FindByIdAsync(id);
@ -155,6 +155,8 @@ namespace Squidex.Domain.Users
} }
await UpdateAsync(userManager, user, values); await UpdateAsync(userManager, user, values);
return await userManager.ResolveUserAsync(user);
} }
public static async Task<IdentityResult> UpdateSafeAsync(this UserManager<IdentityUser> userManager, IdentityUser user, UserValues values) public static async Task<IdentityResult> UpdateSafeAsync(this UserManager<IdentityUser> userManager, IdentityUser user, UserValues values)
@ -193,7 +195,7 @@ namespace Squidex.Domain.Users
} }
} }
public static async Task LockAsync(this UserManager<IdentityUser> userManager, string id) public static async Task<UserWithClaims> LockAsync(this UserManager<IdentityUser> userManager, string id)
{ {
var user = await userManager.FindByIdAsync(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."); await DoChecked(() => userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)), "Cannot lock user.");
return await userManager.ResolveUserAsync(user);
} }
public static async Task UnlockAsync(this UserManager<IdentityUser> userManager, string id) public static async Task<UserWithClaims> UnlockAsync(this UserManager<IdentityUser> userManager, string id)
{ {
var user = await userManager.FindByIdAsync(id); var user = await userManager.FindByIdAsync(id);
@ -215,6 +219,8 @@ namespace Squidex.Domain.Users
} }
await DoChecked(() => userManager.SetLockoutEndDateAsync(user, null), "Cannot unlock user."); await DoChecked(() => userManager.SetLockoutEndDateAsync(user, null), "Cannot unlock user.");
return await userManager.ResolveUserAsync(user);
} }
private static async Task DoChecked(Func<Task<IdentityResult>> action, string message) private static async Task DoChecked(Func<Task<IdentityResult>> action, string message)

15
src/Squidex.Web/Resource.cs

@ -26,25 +26,30 @@ namespace Squidex.Web
public void AddGetLink(string rel, string href) 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) public void AddPostLink(string rel, string href)
{ {
AddLink(rel, HttpMethod.Post, href); AddLink(rel, "POST", href);
} }
public void AddPutLink(string rel, string href) public void AddPutLink(string rel, string href)
{ {
AddLink(rel, HttpMethod.Put, href); AddLink(rel, "PUT", href);
} }
public void AddDeleteLink(string rel, string 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 }; Links[rel] = new ResourceLink { Href = href, Method = method };
} }

3
src/Squidex.Web/ResourceLink.cs

@ -6,7 +6,6 @@
// ========================================================================== // ==========================================================================
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Net.Http;
namespace Squidex.Web namespace Squidex.Web
{ {
@ -18,6 +17,6 @@ namespace Squidex.Web
[Required] [Required]
[Display(Description = "The link method.")] [Display(Description = "The link method.")]
public HttpMethod Method { get; set; } public string Method { get; set; }
} }
} }

4
src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs

@ -278,9 +278,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
{ {
await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name); await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name);
var publishPermission = Permissions.ForApp(Permissions.AppContentsPublish, app, name); if (publish && !this.HasPermission(Permissions.AppContentsPublish, app, name))
if (publish && !User.Permissions().Includes(publishPermission))
{ {
return new StatusCodeResult(123); return new StatusCodeResult(123);
} }

8
src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs

@ -40,7 +40,13 @@ namespace Squidex.Areas.Api.Controllers.Users.Models
public UserValues ToValues() 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)
};
} }
} }
} }

26
src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs

@ -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
{
/// <summary>
/// The id of the user.
/// </summary>
[Required]
public string Id { get; set; }
/// <summary>
/// The display name (usually first name and last name) of the user.
/// </summary>
[Required]
public string DisplayName { get; set; }
}
}

8
src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs

@ -39,7 +39,13 @@ namespace Squidex.Areas.Api.Controllers.Users.Models
public UserValues ToValues() 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)
};
} }
} }
} }

26
src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs

@ -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
{
/// <summary>
/// The id of the user.
/// </summary>
[Required]
public string Id { get; set; }
/// <summary>
/// Additional permissions for the user.
/// </summary>
[Required]
public string[] Permissions { get; set; }
}
}

28
src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs

@ -32,6 +32,7 @@ namespace Squidex.Areas.Api.Controllers.Users
[HttpGet] [HttpGet]
[Route("user-management/")] [Route("user-management/")]
[ProducesResponseType(typeof(UsersDto), 200)]
[ApiPermission(Permissions.AdminUsersRead)] [ApiPermission(Permissions.AdminUsersRead)]
public async Task<IActionResult> GetUsers([FromQuery] string query = null, [FromQuery] int skip = 0, [FromQuery] int take = 10) public async Task<IActionResult> GetUsers([FromQuery] string query = null, [FromQuery] int skip = 0, [FromQuery] int take = 10)
{ {
@ -47,6 +48,7 @@ namespace Squidex.Areas.Api.Controllers.Users
[HttpGet] [HttpGet]
[Route("user-management/{id}/")] [Route("user-management/{id}/")]
[ProducesResponseType(typeof(UserDto), 201)]
[ApiPermission(Permissions.AdminUsersRead)] [ApiPermission(Permissions.AdminUsersRead)]
public async Task<IActionResult> GetUser(string id) public async Task<IActionResult> GetUser(string id)
{ {
@ -64,28 +66,33 @@ namespace Squidex.Areas.Api.Controllers.Users
[HttpPost] [HttpPost]
[Route("user-management/")] [Route("user-management/")]
[ProducesResponseType(typeof(UserDto), 201)]
[ApiPermission(Permissions.AdminUsersCreate)] [ApiPermission(Permissions.AdminUsersCreate)]
public async Task<IActionResult> PostUser([FromBody] CreateUserDto request) public async Task<IActionResult> 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); return Ok(response);
} }
[HttpPut] [HttpPut]
[Route("user-management/{id}/")] [Route("user-management/{id}/")]
[ProducesResponseType(typeof(UserDto), 201)]
[ApiPermission(Permissions.AdminUsersUpdate)] [ApiPermission(Permissions.AdminUsersUpdate)]
public async Task<IActionResult> PutUser(string id, [FromBody] UpdateUserDto request) public async Task<IActionResult> 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] [HttpPut]
[Route("user-management/{id}/lock/")] [Route("user-management/{id}/lock/")]
[ProducesResponseType(typeof(UserDto), 201)]
[ApiPermission(Permissions.AdminUsersLock)] [ApiPermission(Permissions.AdminUsersLock)]
public async Task<IActionResult> LockUser(string id) public async Task<IActionResult> 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.")); 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] [HttpPut]
[Route("user-management/{id}/unlock/")] [Route("user-management/{id}/unlock/")]
[ProducesResponseType(typeof(UserDto), 201)]
[ApiPermission(Permissions.AdminUsersUnlock)] [ApiPermission(Permissions.AdminUsersUnlock)]
public async Task<IActionResult> UnlockUser(string id) public async Task<IActionResult> 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.")); 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);
} }
} }
} }

8
src/Squidex/Areas/Api/Controllers/Users/UsersController.cs

@ -68,7 +68,7 @@ namespace Squidex.Areas.Api.Controllers.Users
/// </returns> /// </returns>
[HttpGet] [HttpGet]
[Route("users/")] [Route("users/")]
[ProducesResponseType(typeof(PublicUserDto[]), 200)] [ProducesResponseType(typeof(UserDto[]), 200)]
[ApiPermission] [ApiPermission]
public async Task<IActionResult> GetUsers(string query) public async Task<IActionResult> GetUsers(string query)
{ {
@ -76,9 +76,9 @@ namespace Squidex.Areas.Api.Controllers.Users
{ {
var entities = await userResolver.QueryByEmailAsync(query); 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) catch (Exception ex)
{ {
@ -100,7 +100,7 @@ namespace Squidex.Areas.Api.Controllers.Users
/// </returns> /// </returns>
[HttpGet] [HttpGet]
[Route("users/{id}/")] [Route("users/{id}/")]
[ProducesResponseType(typeof(PublicUserDto), 200)] [ProducesResponseType(typeof(UserDto), 200)]
[ApiPermission] [ApiPermission]
public async Task<IActionResult> GetUser(string id) public async Task<IActionResult> GetUser(string id)
{ {

4
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 { of } from 'rxjs';
import { IMock, Mock, Times } from 'typemoq'; 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'; import { UserMustExistGuard } from './user-must-exist.guard';
@ -32,7 +32,7 @@ describe('UserMustExistGuard', () => {
it('should load user and return true when found', () => { it('should load user and return true when found', () => {
usersState.setup(x => x.select('123')) usersState.setup(x => x.select('123'))
.returns(() => of(<SnapshotUser>{})); .returns(() => of(<UserDto>{}));
let result: boolean; let result: boolean;

14
src/Squidex/app/features/administration/pages/users/user-page.component.html

@ -15,12 +15,14 @@
</ng-container> </ng-container>
<ng-container menu> <ng-container menu>
<ng-container *ngIf="usersState.selectedUser | async; else noUserMenu"> <ng-container *ngIf="usersState.selectedUser | async; let user; else noUserMenu">
<button type="submit" class="btn btn-primary" title="CTRL + S"> <ng-container *ngIf="user | sqxHasLink: 'update'">
Save <button type="submit" class="btn btn-primary" title="CTRL + S">
</button> Save
</button>
<sqx-shortcut keys="ctrl+s" (trigger)="save()"></sqx-shortcut>
<sqx-shortcut keys="ctrl+s" (trigger)="save()"></sqx-shortcut>
</ng-container>
</ng-container> </ng-container>
<ng-template #noUserMenu> <ng-template #noUserMenu>

10
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 { FormBuilder } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { ResourceOwner } from '@app/shared'; import { hasLink, ResourceOwner } from '@app/shared';
import { import {
CreateUserDto, CreateUserDto,
@ -46,11 +46,19 @@ export class UserPageComponent extends ResourceOwner implements OnInit {
if (selectedUser) { if (selectedUser) {
this.userForm.load(selectedUser); this.userForm.load(selectedUser);
if (!hasLink(selectedUser, 'update')) {
this.userForm.form.disable();
}
} }
})); }));
} }
public save() { public save() {
if (this.userForm.form.disabled) {
return;
}
const value = this.userForm.submit(); const value = this.userForm.submit();
if (value) { if (value) {

11
src/Squidex/app/features/administration/pages/users/users-page.component.html

@ -12,15 +12,18 @@
<sqx-shortcut keys="ctrl+shift+r" (trigger)="reload()"></sqx-shortcut> <sqx-shortcut keys="ctrl+shift+r" (trigger)="reload()"></sqx-shortcut>
<sqx-shortcut keys="ctrl+shift+f" (trigger)="inputFind.focus()"></sqx-shortcut> <sqx-shortcut keys="ctrl+shift+f" (trigger)="inputFind.focus()"></sqx-shortcut>
<sqx-shortcut keys="ctrl+shift+n" (trigger)="buttonNew.click()"></sqx-shortcut>
<form class="form-inline mr-1" (ngSubmit)="search()"> <form class="form-inline mr-1" (ngSubmit)="search()">
<input class="form-control" #inputFind [formControl]="usersFilter" placeholder="Search for user" /> <input class="form-control" #inputFind [formControl]="usersFilter" placeholder="Search for user" />
</form> </form>
<button type="button" class="btn btn-success" #buttonNew routerLink="new" title="New User (CTRL + N)"> <ng-container *ngIf="usersState.links | async | sqxHasLink: 'create'">
<i class="icon-plus"></i> New <sqx-shortcut keys="ctrl+shift+n" (trigger)="buttonNew.click()"></sqx-shortcut>
</button>
<button type="button" class="btn btn-success" #buttonNew routerLink="new" title="New User (CTRL + N)">
<i class="icon-plus"></i> New
</button>
</ng-container>
</ng-container> </ng-container>
<ng-container content> <ng-container content>

4
src/Squidex/app/features/administration/pages/users/users-page.component.ts

@ -51,8 +51,8 @@ export class UsersPageComponent implements OnInit {
this.usersState.unlock(user); this.usersState.unlock(user);
} }
public trackByUser(index: number, userInfo: { user: UserDto }) { public trackByUser(index: number, user: UserDto) {
return userInfo.user.id; return user.id;
} }
} }

139
src/Squidex/app/features/administration/services/users.service.spec.ts

@ -8,7 +8,7 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing'; import { inject, TestBed } from '@angular/core/testing';
import { ApiUrlConfig } from '@app/framework'; import { ApiUrlConfig, Resource } from '@app/framework';
import { import {
UserDto, UserDto,
@ -50,27 +50,15 @@ describe('UsersService', () => {
req.flush({ req.flush({
total: 100, total: 100,
items: [ items: [
{ userResponse(12),
id: '123', userResponse(13)
email: 'mail1@domain.com',
displayName: 'User1',
permissions: ['Permission1'],
isLocked: true
},
{
id: '456',
email: 'mail2@domain.com',
displayName: 'User2',
permissions: ['Permission2'],
isLocked: true
}
] ]
}); });
expect(users!).toEqual( expect(users!).toEqual(
new UsersDto(100, [ new UsersDto(100, [
new UserDto('123', 'mail1@domain.com', 'User1', ['Permission1'], true), createUser(12),
new UserDto('456', 'mail2@domain.com', 'User2', ['Permission2'], true) createUser(13)
])); ]));
})); }));
@ -91,27 +79,15 @@ describe('UsersService', () => {
req.flush({ req.flush({
total: 100, total: 100,
items: [ items: [
{ userResponse(12),
id: '123', userResponse(13)
email: 'mail1@domain.com',
displayName: 'User1',
permissions: ['Permission1'],
isLocked: true
},
{
id: '456',
email: 'mail2@domain.com',
displayName: 'User2',
permissions: ['Permission2'],
isLocked: true
}
] ]
}); });
expect(users!).toEqual( expect(users!).toEqual(
new UsersDto(100, [ new UsersDto(100, [
new UserDto('123', 'mail1@domain.com', 'User1', ['Permission1'], true), createUser(12),
new UserDto('456', 'mail2@domain.com', 'User2', ['Permission2'], true) createUser(13)
])); ]));
})); }));
@ -129,15 +105,9 @@ describe('UsersService', () => {
expect(req.request.method).toEqual('GET'); expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({ req.flush(userResponse(12));
id: '123',
email: 'mail1@domain.com',
displayName: 'User1',
permissions: ['Permission1'],
isLocked: true
});
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', it('should make post request to create user',
@ -156,9 +126,9 @@ describe('UsersService', () => {
expect(req.request.method).toEqual('POST'); expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull(); 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', 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' }; 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'); const req = httpMock.expectOne('http://service/p/api/user-management/123');
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBeNull(); 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', it('should make put request to lock user',
inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { 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'); const req = httpMock.expectOne('http://service/p/api/user-management/123/lock');
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBeNull(); 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', it('should make put request to unlock user',
inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { 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'); const req = httpMock.expectOne('http://service/p/api/user-management/123/unlock');
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({}); req.flush(userResponse(12));
expect(user!).toEqual(createUser(12));
})); }));
});
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;
}

84
src/Squidex/app/features/administration/services/users.service.ts

@ -12,7 +12,6 @@ import { map } from 'rxjs/operators';
import { import {
ApiUrlConfig, ApiUrlConfig,
Model,
pretifyError, pretifyError,
Resource, Resource,
ResourceLinks, ResourceLinks,
@ -21,11 +20,11 @@ import {
} from '@app/shared'; } from '@app/shared';
export class UsersDto extends ResultSet<UserDto> { export class UsersDto extends ResultSet<UserDto> {
public _links: ResourceLinks; public readonly _links: ResourceLinks = {};
} }
export class UserDto extends Model<UserDto> { export class UserDto {
public _links: ResourceLinks; public readonly _links: ResourceLinks = {};
constructor( constructor(
public readonly id: string, public readonly id: string,
@ -34,11 +33,6 @@ export class UserDto extends Model<UserDto> {
public readonly permissions: string[] = [], public readonly permissions: string[] = [],
public readonly isLocked?: boolean public readonly isLocked?: boolean
) { ) {
super();
}
public with(value: Partial<UserDto>): UserDto {
return this.clone(value);
} }
} }
@ -69,15 +63,7 @@ export class UsersService {
return this.http.get<{ total: number, items: any[] } & Resource>(url).pipe( return this.http.get<{ total: number, items: any[] } & Resource>(url).pipe(
map(body => { map(body => {
const users = body.items.map(item => const users = body.items.map(item => parseUser(item));
withLinks(
new UserDto(
item.id,
item.email,
item.displayName,
item.permissions,
item.isLocked),
item));
return withLinks(new UsersDto(body.total, users), body); return withLinks(new UsersDto(body.total, users), body);
}), }),
@ -89,14 +75,7 @@ export class UsersService {
return this.http.get<any>(url).pipe( return this.http.get<any>(url).pipe(
map(body => { map(body => {
const user = new UserDto( return parseUser(body);
body.id,
body.email,
body.displayName,
body.permissions,
body.isLocked);
return user;
}), }),
pretifyError('Failed to load user. Please reload.')); pretifyError('Failed to load user. Please reload.'));
} }
@ -106,36 +85,55 @@ export class UsersService {
return this.http.post<any>(url, dto).pipe( return this.http.post<any>(url, dto).pipe(
map(body => { map(body => {
const user = new UserDto( return parseUser(body);
body.id,
dto.email,
dto.displayName,
dto.permissions,
false);
return user;
}), }),
pretifyError('Failed to create user. Please reload.')); pretifyError('Failed to create user. Please reload.'));
} }
public putUser(id: string, dto: UpdateUserDto): Observable<any> { public putUser(user: Resource, dto: UpdateUserDto): Observable<UserDto> {
const url = this.apiUrl.buildUrl(`api/user-management/${id}`); 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.')); pretifyError('Failed to update user. Please reload.'));
} }
public lockUser(id: string): Observable<any> { public lockUser(user: Resource): Observable<UserDto> {
const url = this.apiUrl.buildUrl(`api/user-management/${id}/lock`); 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.')); pretifyError('Failed to load users. Please retry.'));
} }
public unlockUser(id: string): Observable<any> { public unlockUser(user: Resource): Observable<UserDto> {
const url = this.apiUrl.buildUrl(`api/user-management/${id}/unlock`); 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.')); 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);
} }

88
src/Squidex/app/features/administration/state/users.state.spec.ts

@ -8,7 +8,7 @@
import { of, throwError } from 'rxjs'; import { of, throwError } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { AuthService, DialogService } from '@app/shared'; import { DialogService } from '@app/shared';
import { import {
UserDto, UserDto,
@ -16,15 +16,16 @@ import {
UsersService UsersService
} from '@app/features/administration/internal'; } from '@app/features/administration/internal';
import { UsersState } from './users.state'; import { UsersState } from './users.state';
import { createUser } from './../services/users.service.spec';
describe('UsersState', () => { describe('UsersState', () => {
const oldUsers = [ const user1 = createUser(1);
new UserDto('id1', 'mail1@mail.de', 'name1', ['Permission1'], false), const user2 = createUser(2);
new UserDto('id2', 'mail2@mail.de', 'name2', ['Permission2'], true)
];
const newUser = new UserDto('id3', 'mail3@mail.de', 'name3', ['Permission3'], false); const newUser = createUser(3);
let dialogs: IMock<DialogService>; let dialogs: IMock<DialogService>;
let usersService: IMock<UsersService>; let usersService: IMock<UsersService>;
@ -44,11 +45,11 @@ describe('UsersState', () => {
describe('Loading', () => { describe('Loading', () => {
it('should load users', () => { it('should load users', () => {
usersService.setup(x => x.getUsers(10, 0, undefined)) 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(); 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.usersPager.numberOfItems).toEqual(200);
expect(usersState.snapshot.isLoaded).toBeTruthy(); expect(usersState.snapshot.isLoaded).toBeTruthy();
@ -57,7 +58,7 @@ describe('UsersState', () => {
it('should show notification on load when reload is true', () => { it('should show notification on load when reload is true', () => {
usersService.setup(x => x.getUsers(10, 0, undefined)) 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(); usersState.load(true).subscribe();
@ -68,18 +69,18 @@ describe('UsersState', () => {
it('should replace selected user when reloading', () => { it('should replace selected user when reloading', () => {
const newUsers = [ const newUsers = [
new UserDto('id1', 'mail1@mail.de_new', 'name1_new', ['Permission1_New'], false), createUser(1, '_new'),
new UserDto('id2', 'mail2@mail.de_new', 'name2_new', ['Permission2_New'], true) createUser(2, '_new')
]; ];
usersService.setup(x => x.getUsers(10, 0, undefined)) 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)) usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of(new UsersDto(200, newUsers))); .returns(() => of(new UsersDto(200, newUsers)));
usersState.load().subscribe(); usersState.load().subscribe();
usersState.select('id1').subscribe(); usersState.select(user1.id).subscribe();
usersState.load().subscribe(); usersState.load().subscribe();
expect(usersState.snapshot.selectedUser).toEqual(newUsers[0]); expect(usersState.snapshot.selectedUser).toEqual(newUsers[0]);
@ -87,7 +88,7 @@ describe('UsersState', () => {
it('should load next page and prev page when paging', () => { it('should load next page and prev page when paging', () => {
usersService.setup(x => x.getUsers(10, 0, undefined)) 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)) usersService.setup(x => x.getUsers(10, 10, undefined))
.returns(() => of(new UsersDto(200, []))).verifiable(); .returns(() => of(new UsersDto(200, []))).verifiable();
@ -112,7 +113,7 @@ describe('UsersState', () => {
describe('Updates', () => { describe('Updates', () => {
beforeEach(() => { beforeEach(() => {
usersService.setup(x => x.getUsers(10, 0, undefined)) 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(); usersState.load().subscribe();
}); });
@ -120,12 +121,12 @@ describe('UsersState', () => {
it('should return user on select and not load when already loaded', () => { it('should return user on select and not load when already loaded', () => {
let selectedUser: UserDto; let selectedUser: UserDto;
usersState.select('id1').subscribe(x => { usersState.select(user1.id).subscribe(x => {
selectedUser = x!; selectedUser = x!;
}); });
expect(selectedUser!).toEqual(oldUsers[0]); expect(selectedUser!).toEqual(user1);
expect(usersState.snapshot.selectedUser).toEqual(oldUsers[0]); expect(usersState.snapshot.selectedUser).toEqual(user1);
}); });
it('should return user on select and load when not loaded', () => { it('should return user on select and load when not loaded', () => {
@ -168,46 +169,49 @@ describe('UsersState', () => {
}); });
it('should mark as locked when locked', () => { it('should mark as locked when locked', () => {
usersService.setup(x => x.lockUser('id1')) const updated = createUser(2, '_new');
.returns(() => of({})).verifiable();
usersService.setup(x => x.lockUser(user2))
.returns(() => of(updated)).verifiable();
usersState.select('id1').subscribe(); usersState.select(user2.id).subscribe();
usersState.lock(oldUsers[0]).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(userUser2).toBe(usersState.snapshot.selectedUser!);
expect(user_1).toBe(usersState.snapshot.selectedUser!);
}); });
it('should unmark as locked when unlocked', () => { it('should unmark as locked when unlocked', () => {
usersService.setup(x => x.unlockUser('id2')) const updated = createUser(2, '_new');
.returns(() => of({})).verifiable();
usersState.select('id2').subscribe(); usersService.setup(x => x.unlockUser(user2))
usersState.unlock(oldUsers[1]).subscribe(); .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(); const newUser2 = usersState.snapshot.users.at(1);
expect(user_1).toBe(usersState.snapshot.selectedUser!);
expect(newUser2).toEqual(updated);
expect(newUser2).toBe(usersState.snapshot.selectedUser!);
}); });
it('should update user properties when updated', () => { it('should update user properties when updated', () => {
const request = { email: 'new@mail.com', displayName: 'New', permissions: ['Permission1'] }; const request = { email: 'new@mail.com', displayName: 'New', permissions: ['Permission1'] };
usersService.setup(x => x.putUser('id1', request)) const updated = createUser(2, '_new');
.returns(() => of({})).verifiable();
usersService.setup(x => x.putUser(user2, request))
.returns(() => of(updated)).verifiable();
usersState.select('id1').subscribe(); usersState.select(user2.id).subscribe();
usersState.update(oldUsers[0], request).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(newUser2).toEqual(updated);
expect(user_1.displayName).toEqual(request.displayName); expect(newUser2).toBe(usersState.snapshot.selectedUser!);
expect(user_1.permissions).toEqual(request.permissions);
expect(user_1).toBe(usersState.snapshot.selectedUser!);
}); });
it('should add user to snapshot when created', () => { it('should add user to snapshot when created', () => {
@ -218,7 +222,7 @@ describe('UsersState', () => {
usersState.create(request).subscribe(); 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); expect(usersState.snapshot.usersPager.numberOfItems).toBe(201);
}); });
}); });

25
src/Squidex/app/features/administration/state/users.state.ts

@ -7,7 +7,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs'; 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'; import '@app/framework/utils/rxjs-extensions';
@ -15,6 +15,7 @@ import {
DialogService, DialogService,
ImmutableArray, ImmutableArray,
Pager, Pager,
ResourceLinks,
shareSubscribed, shareSubscribed,
State State
} from '@app/shared'; } from '@app/shared';
@ -36,6 +37,9 @@ interface Snapshot {
// The query to filter users. // The query to filter users.
usersQuery?: string; usersQuery?: string;
// The resource links.
links: ResourceLinks;
// Indicates if the users are loaded. // Indicates if the users are loaded.
isLoaded?: boolean; isLoaded?: boolean;
@ -56,6 +60,10 @@ export class UsersState extends State<Snapshot> {
this.changes.pipe(map(x => x.usersPager), this.changes.pipe(map(x => x.usersPager),
distinctUntilChanged()); distinctUntilChanged());
public links =
this.changes.pipe(map(x => x.links),
distinctUntilChanged());
public selectedUser = public selectedUser =
this.changes.pipe(map(x => x.selectedUser), this.changes.pipe(map(x => x.selectedUser),
distinctUntilChanged()); distinctUntilChanged());
@ -68,7 +76,7 @@ export class UsersState extends State<Snapshot> {
private readonly dialogs: DialogService, private readonly dialogs: DialogService,
private readonly usersService: UsersService 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<UserDto | null> { public select(id: string | null): Observable<UserDto | null> {
@ -108,7 +116,7 @@ export class UsersState extends State<Snapshot> {
this.snapshot.usersPager.pageSize, this.snapshot.usersPager.pageSize,
this.snapshot.usersPager.skip, this.snapshot.usersPager.skip,
this.snapshot.usersQuery).pipe( this.snapshot.usersQuery).pipe(
tap(({ total, items }) => { tap(({ total, items, _links: links }) => {
if (isReload) { if (isReload) {
this.dialogs.notifyInfo('Users reloaded.'); this.dialogs.notifyInfo('Users reloaded.');
} }
@ -123,7 +131,7 @@ export class UsersState extends State<Snapshot> {
selectedUser = users.find(x => x.id === selectedUser!.id) || selectedUser; 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)); shareSubscribed(this.dialogs));
@ -143,8 +151,7 @@ export class UsersState extends State<Snapshot> {
} }
public update(user: UserDto, request: UpdateUserDto): Observable<UserDto> { public update(user: UserDto, request: UpdateUserDto): Observable<UserDto> {
return this.usersService.putUser(user.id, request).pipe( return this.usersService.putUser(user, request).pipe(
switchMap(() => this.usersService.getUser(user.id)),
tap(updated => { tap(updated => {
this.replaceUser(updated); this.replaceUser(updated);
}), }),
@ -152,8 +159,7 @@ export class UsersState extends State<Snapshot> {
} }
public lock(user: UserDto): Observable<UserDto> { public lock(user: UserDto): Observable<UserDto> {
return this.usersService.lockUser(user.id).pipe( return this.usersService.lockUser(user).pipe(
switchMap(() => this.usersService.getUser(user.id)),
tap(updated => { tap(updated => {
this.replaceUser(updated); this.replaceUser(updated);
}), }),
@ -161,8 +167,7 @@ export class UsersState extends State<Snapshot> {
} }
public unlock(user: UserDto): Observable<UserDto> { public unlock(user: UserDto): Observable<UserDto> {
return this.usersService.unlockUser(user.id).pipe( return this.usersService.unlockUser(user).pipe(
switchMap(() => this.usersService.getUser(user.id)),
tap(updated => { tap(updated => {
this.replaceUser(updated); this.replaceUser(updated);
}), }),

23
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 { Pipe, PipeTransform } from '@angular/core';
import { Resource } from '@app/framework/internal'; import {
hasLink,
Resource,
ResourceLinks
} from '@app/framework/internal';
@Pipe({ @Pipe({
name: 'sqxHasLink', name: 'sqxHasLink',
pure: true pure: true
}) })
export class HasLinkPipe implements PipeTransform { export class HasLinkPipe implements PipeTransform {
public transform(value: Resource, rel: string) { public transform(value: Resource | ResourceLinks, rel: string) {
return value._links && !!value._links[rel]; return hasLink(value, rel);
} }
} }
@ -17,7 +28,7 @@ export class HasLinkPipe implements PipeTransform {
pure: true pure: true
}) })
export class HasNoLinkPipe implements PipeTransform { export class HasNoLinkPipe implements PipeTransform {
public transform(value: Resource, rel: string) { public transform(value: Resource | ResourceLinks, rel: string) {
return !value._links || !value._links[rel]; return !hasLink(value, rel);
} }
} }

47
src/Squidex/app/framework/utils/hateos.ts

@ -5,21 +5,40 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
export interface Resource { export interface Resource {
_links?: { [rel: string]: ResourceLink }; readonly _links: { [rel: string]: ResourceLink };
} }
export type ResourceLinks = { [rel: string]: ResourceLink }; export type ResourceLinks = { [rel: string]: ResourceLink };
export type ResourceLink = { href: string; method: ResourceMethod; }; export type ResourceLink = { href: string; method: ResourceMethod; };
export function withLinks<T extends Resource>(value: T, source: Resource) { export function withLinks<T extends Resource>(value: T, source: Resource) {
value._links = source._links; 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 = return value;
'get' | }
'post' |
'put' | export function hasLink(value: Resource | ResourceLinks, rel: string): boolean {
'delete'; 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';
Loading…
Cancel
Save