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. 4
      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. 5
      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. 137
      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. 21
      src/Squidex/app/framework/angular/http/hateos.pipes.ts
  21. 31
      src/Squidex/app/framework/utils/hateos.ts

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

@ -115,7 +115,7 @@ namespace Squidex.Domain.Users
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);
@ -142,10 +142,10 @@ namespace Squidex.Domain.Users
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);
@ -155,6 +155,8 @@ namespace Squidex.Domain.Users
}
await UpdateAsync(userManager, user, values);
return await userManager.ResolveUserAsync(user);
}
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);
@ -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<IdentityUser> userManager, string id)
public static async Task<UserWithClaims> UnlockAsync(this UserManager<IdentityUser> 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<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)
{
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 };
}

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

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

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

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()
{
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]
[Route("user-management/")]
[ProducesResponseType(typeof(UsersDto), 200)]
[ApiPermission(Permissions.AdminUsersRead)]
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]
[Route("user-management/{id}/")]
[ProducesResponseType(typeof(UserDto), 201)]
[ApiPermission(Permissions.AdminUsersRead)]
public async Task<IActionResult> 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<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);
}
[HttpPut]
[Route("user-management/{id}/")]
[ProducesResponseType(typeof(UserDto), 201)]
[ApiPermission(Permissions.AdminUsersUpdate)]
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]
[Route("user-management/{id}/lock/")]
[ProducesResponseType(typeof(UserDto), 201)]
[ApiPermission(Permissions.AdminUsersLock)]
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."));
}
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<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."));
}
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>
[HttpGet]
[Route("users/")]
[ProducesResponseType(typeof(PublicUserDto[]), 200)]
[ProducesResponseType(typeof(UserDto[]), 200)]
[ApiPermission]
public async Task<IActionResult> 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
/// </returns>
[HttpGet]
[Route("users/{id}/")]
[ProducesResponseType(typeof(PublicUserDto), 200)]
[ProducesResponseType(typeof(UserDto), 200)]
[ApiPermission]
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 { 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(<SnapshotUser>{}));
.returns(() => of(<UserDto>{}));
let result: boolean;

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

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

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 { 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) {

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

@ -12,16 +12,19 @@
<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+n" (trigger)="buttonNew.click()"></sqx-shortcut>
<form class="form-inline mr-1" (ngSubmit)="search()">
<input class="form-control" #inputFind [formControl]="usersFilter" placeholder="Search for user" />
</form>
<ng-container *ngIf="usersState.links | async | sqxHasLink: 'create'">
<sqx-shortcut keys="ctrl+shift+n" (trigger)="buttonNew.click()"></sqx-shortcut>
<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 content>
<div class="grid-header">

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);
}
public trackByUser(index: number, userInfo: { user: UserDto }) {
return userInfo.user.id;
public trackByUser(index: number, user: UserDto) {
return user.id;
}
}

137
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));
}));
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 {
ApiUrlConfig,
Model,
pretifyError,
Resource,
ResourceLinks,
@ -21,11 +20,11 @@ import {
} from '@app/shared';
export class UsersDto extends ResultSet<UserDto> {
public _links: ResourceLinks;
public readonly _links: ResourceLinks = {};
}
export class UserDto extends Model<UserDto> {
public _links: ResourceLinks;
export class UserDto {
public readonly _links: ResourceLinks = {};
constructor(
public readonly id: string,
@ -34,11 +33,6 @@ export class UserDto extends Model<UserDto> {
public readonly permissions: string[] = [],
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(
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<any>(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<any>(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<any> {
const url = this.apiUrl.buildUrl(`api/user-management/${id}`);
public putUser(user: Resource, dto: UpdateUserDto): Observable<UserDto> {
const link = user._links['update'];
return this.http.put(url, dto).pipe(
const url = this.apiUrl.buildUrl(link.href);
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<any> {
const url = this.apiUrl.buildUrl(`api/user-management/${id}/lock`);
public lockUser(user: Resource): Observable<UserDto> {
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<any> {
const url = this.apiUrl.buildUrl(`api/user-management/${id}/unlock`);
public unlockUser(user: Resource): Observable<UserDto> {
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);
}

88
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<DialogService>;
let usersService: IMock<UsersService>;
@ -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');
usersState.select('id1').subscribe();
usersState.lock(oldUsers[0]).subscribe();
usersService.setup(x => x.lockUser(user2))
.returns(() => of(updated)).verifiable();
const user_1 = usersState.snapshot.users.at(0);
usersState.select(user2.id).subscribe();
usersState.lock(user2).subscribe();
expect(user_1.isLocked).toBeTruthy();
expect(user_1).toBe(usersState.snapshot.selectedUser!);
const userUser2 = usersState.snapshot.users.at(1);
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');
usersService.setup(x => x.unlockUser(user2))
.returns(() => of(updated)).verifiable();
usersState.select('id2').subscribe();
usersState.unlock(oldUsers[1]).subscribe();
usersState.select(user2.id).subscribe();
usersState.unlock(user2).subscribe();
const user_1 = usersState.snapshot.users.at(1);
const newUser2 = usersState.snapshot.users.at(1);
expect(user_1.isLocked).toBeFalsy();
expect(user_1).toBe(usersState.snapshot.selectedUser!);
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);
});
});

25
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<Snapshot> {
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<Snapshot> {
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<UserDto | null> {
@ -108,7 +116,7 @@ export class UsersState extends State<Snapshot> {
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<Snapshot> {
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<Snapshot> {
}
public update(user: UserDto, request: UpdateUserDto): Observable<UserDto> {
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<Snapshot> {
}
public lock(user: UserDto): Observable<UserDto> {
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<Snapshot> {
}
public unlock(user: UserDto): Observable<UserDto> {
return this.usersService.unlockUser(user.id).pipe(
switchMap(() => this.usersService.getUser(user.id)),
return this.usersService.unlockUser(user).pipe(
tap(updated => {
this.replaceUser(updated);
}),

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

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

@ -6,20 +6,39 @@
*/
export interface Resource {
_links?: { [rel: string]: ResourceLink };
readonly _links: { [rel: string]: ResourceLink };
}
export type ResourceLinks = { [rel: string]: ResourceLink };
export type ResourceLink = { href: string; method: ResourceMethod; };
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];
}
}
Object.freeze(value._links);
}
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' |
'post' |
'put' |
'delete';
'GET' |
'DELETE' |
'PATCH' |
'POST' |
'PUT';
Loading…
Cancel
Save