From 6c6494c1369d6ef4f247b6309afedabe3257f971 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 7 Jun 2019 08:57:16 +0200 Subject: [PATCH 01/64] Test --- src/Squidex.Web/Extensions.cs | 8 ++ src/Squidex.Web/PermissionExtensions.cs | 77 +++++++++++++++++++ src/Squidex.Web/Resource.cs | 52 +++++++++++++ src/Squidex.Web/ResourceLink.cs | 23 ++++++ src/Squidex.Web/UrlHelperExtensions.cs | 46 +++++++++++ .../Api/Controllers/Users/Models/UserDto.cs | 53 ++++++++++++- .../Api/Controllers/Users/Models/UsersDto.cs | 34 +++++++- .../Users/UserManagementController.cs | 22 +----- .../Api/Controllers/Users/UsersController.cs | 4 +- .../pages/users/user-page.component.html | 2 +- .../pages/users/user-page.component.ts | 6 +- .../pages/users/users-page.component.html | 30 +++----- .../administration/services/users.service.ts | 31 +++++--- .../administration/state/users.state.spec.ts | 49 +++++------- .../administration/state/users.state.ts | 57 ++++---------- .../framework/angular/http/hateos.pipes.ts | 23 ++++++ src/Squidex/app/framework/declarations.ts | 1 + src/Squidex/app/framework/internal.ts | 1 + src/Squidex/app/framework/module.ts | 6 ++ src/Squidex/app/framework/utils/hateos.ts | 25 ++++++ 20 files changed, 419 insertions(+), 131 deletions(-) create mode 100644 src/Squidex.Web/PermissionExtensions.cs create mode 100644 src/Squidex.Web/Resource.cs create mode 100644 src/Squidex.Web/ResourceLink.cs create mode 100644 src/Squidex.Web/UrlHelperExtensions.cs create mode 100644 src/Squidex/app/framework/angular/http/hateos.pipes.ts create mode 100644 src/Squidex/app/framework/utils/hateos.ts diff --git a/src/Squidex.Web/Extensions.cs b/src/Squidex.Web/Extensions.cs index b7f7594bf..4ab57d830 100644 --- a/src/Squidex.Web/Extensions.cs +++ b/src/Squidex.Web/Extensions.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Security.Claims; using Squidex.Infrastructure.Security; @@ -40,5 +41,12 @@ namespace Squidex.Web return (null, null); } + + public static bool IsUser(this ApiController controller, string userId) + { + var subject = controller.User.OpenIdSubject(); + + return string.Equals(subject, userId, StringComparison.OrdinalIgnoreCase); + } } } diff --git a/src/Squidex.Web/PermissionExtensions.cs b/src/Squidex.Web/PermissionExtensions.cs new file mode 100644 index 000000000..6dc7d0610 --- /dev/null +++ b/src/Squidex.Web/PermissionExtensions.cs @@ -0,0 +1,77 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.AspNetCore.Http; +using Squidex.Infrastructure.Security; +using Squidex.Shared; +using Squidex.Shared.Identity; + +namespace Squidex.Web +{ + public static class PermissionExtensions + { + private sealed class PermissionFeature + { + public PermissionSet Permissions { get; } + + public PermissionFeature(PermissionSet permissions) + { + Permissions = permissions; + } + } + + public static PermissionSet GetPermissions(this HttpContext httpContext) + { + var feature = httpContext.Features.Get(); + + if (feature == null) + { + feature = new PermissionFeature(httpContext.User.Permissions()); + + httpContext.Features.Set(feature); + } + + return feature.Permissions; + } + + public static bool HasPermission(this HttpContext httpContext, Permission permission) + { + return httpContext.GetPermissions().Includes(permission); + } + + public static bool HasPermission(this HttpContext httpContext, string id, string app = "*", string schema = "*") + { + return httpContext.GetPermissions().Includes(Permissions.ForApp(id, app, schema)); + } + + public static bool HasPermission(this ApiController controller, Permission permission) + { + return controller.HttpContext.GetPermissions().Includes(permission); + } + + public static bool HasPermission(this ApiController controller, string id, string app = "*", string schema = "*") + { + if (app == "*") + { + if (controller.RouteData.Values.TryGetValue("app", out var value) && value is string s) + { + app = s; + } + } + + if (schema == "*") + { + if (controller.RouteData.Values.TryGetValue("name", out var value) && value is string s) + { + schema = s; + } + } + + return controller.HttpContext.GetPermissions().Includes(Permissions.ForApp(id, app, schema)); + } + } +} diff --git a/src/Squidex.Web/Resource.cs b/src/Squidex.Web/Resource.cs new file mode 100644 index 000000000..b62a5f361 --- /dev/null +++ b/src/Squidex.Web/Resource.cs @@ -0,0 +1,52 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Newtonsoft.Json; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Net.Http; + +namespace Squidex.Web +{ + public abstract class Resource + { + [JsonProperty("_links")] + [Required] + [Display(Description = "The links.")] + public Dictionary Links { get; } = new Dictionary(); + + public void AddSelfLink(string href) + { + AddGetLink("self", href); + } + + public void AddGetLink(string rel, string href) + { + AddLink(rel, HttpMethod.Get, href); + } + + public void AddPostLink(string rel, string href) + { + AddLink(rel, HttpMethod.Post, href); + } + + public void AddPutLink(string rel, string href) + { + AddLink(rel, HttpMethod.Put, href); + } + + public void AddDeleteLink(string rel, string href) + { + AddLink(rel, HttpMethod.Delete, href); + } + + public void AddLink(string rel, HttpMethod 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 new file mode 100644 index 000000000..48627ac3a --- /dev/null +++ b/src/Squidex.Web/ResourceLink.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using System.Net.Http; + +namespace Squidex.Web +{ + public class ResourceLink + { + [Required] + [Display(Description = "The link url.")] + public string Href { get; set; } + + [Required] + [Display(Description = "The link method.")] + public HttpMethod Method { get; set; } + } +} diff --git a/src/Squidex.Web/UrlHelperExtensions.cs b/src/Squidex.Web/UrlHelperExtensions.cs new file mode 100644 index 000000000..486d48a76 --- /dev/null +++ b/src/Squidex.Web/UrlHelperExtensions.cs @@ -0,0 +1,46 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.AspNetCore.Mvc; +using System; +using System.Linq.Expressions; +using System.Reflection; + +namespace Squidex.Web +{ + public static class UrlHelperExtensions + { + private static class NameOf + { + public static readonly string Controller; + + static NameOf() + { + const string suffix = "Controller"; + + var name = typeof(T).Name; + + if (name.EndsWith(suffix)) + { + name = name.Substring(0, name.Length - suffix.Length); + } + + Controller = name; + } + } + + public static string Url(this IUrlHelper urlHelper, Func action, object values = null) where T : Controller + { + return urlHelper.Action(action(null), NameOf.Controller, values); + } + + public static string Url(this Controller controller, Func action, object values = null) where T : Controller + { + return controller.Url.Url(action, values); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs index 8a2a5a2d4..2481f73c8 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs @@ -8,12 +8,18 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.Security; using Squidex.Shared.Users; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Users.Models { - public sealed class UserDto + public sealed class UserDto : Resource { + private static readonly Permission LockPermission = new Permission(Shared.Permissions.AdminUsersLock); + private static readonly Permission UnlockPermission = new Permission(Shared.Permissions.AdminUsersUnlock); + private static readonly Permission UpdatePermission = new Permission(Shared.Permissions.AdminUsersUpdate); + /// /// The id of the user. /// @@ -44,11 +50,50 @@ namespace Squidex.Areas.Api.Controllers.Users.Models [Required] public IEnumerable Permissions { get; set; } - public static UserDto FromUser(IUser user) + public static UserDto FromUser(IUser user, ApiController controller) + { + var userPermssions = user.Permissions().ToIds(); + var userName = user.DisplayName(); + + var result = SimpleMapper.Map(user, new UserDto { DisplayName = userName, Permissions = userPermssions }); + + return CreateLinks(result, controller); + } + + private static UserDto CreateLinks(UserDto result, ApiController controller) { - var permissions = user.Permissions().ToIds(); + var values = new { id = result.Id }; + + if (controller is UserManagementController) + { + result.AddSelfLink(controller.Url(c => nameof(c.GetUser), values)); + } + else + { + result.AddSelfLink(controller.Url(c => nameof(c.GetUser), values)); + } + + if (!controller.IsUser(result.Id)) + { + if (controller.HasPermission(LockPermission) && !result.IsLocked) + { + result.AddPutLink("lock", controller.Url(c => nameof(c.LockUser), values)); + } + + if (controller.HasPermission(UnlockPermission) && result.IsLocked) + { + result.AddPutLink("unlock", controller.Url(c => nameof(c.UnlockUser), values)); + } + } + + if (controller.HasPermission(UpdatePermission)) + { + result.AddPutLink("update", controller.Url(c => nameof(c.PutUser), values)); + } + + result.AddGetLink("picture", controller.Url(c => nameof(c.GetUserPicture), values)); - return SimpleMapper.Map(user, new UserDto { DisplayName = user.DisplayName(), Permissions = permissions }); + return result; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/UsersDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/UsersDto.cs index 2d83ff47d..e866c8af4 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/UsersDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/Models/UsersDto.cs @@ -5,10 +5,19 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; +using System.Linq; +using Squidex.Domain.Users; +using Squidex.Infrastructure.Security; +using Squidex.Shared; +using Squidex.Web; + namespace Squidex.Areas.Api.Controllers.Users.Models { - public sealed class UsersDto + public sealed class UsersDto : Resource { + private static readonly Permission CreatePermissions = new Permission(Permissions.AdminUsersCreate); + /// /// The total number of users. /// @@ -18,5 +27,28 @@ namespace Squidex.Areas.Api.Controllers.Users.Models /// The users. /// public UserDto[] Items { get; set; } + + public static UsersDto FromResults(IEnumerable items, long total, ApiController controller) + { + var result = new UsersDto + { + Total = total, + Items = items.Select(x => UserDto.FromUser(x, controller)).ToArray() + }; + + return CreateLinks(result, controller); + } + + private static UsersDto CreateLinks(UsersDto result, ApiController controller) + { + result.AddSelfLink(controller.Url(c => nameof(c.GetUsers))); + + if (controller.HasPermission(CreatePermissions)) + { + result.AddPostLink("create", controller.Url(c => nameof(c.PostUser))); + } + + return result; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs b/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs index 7d547be14..16862daab 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs @@ -5,8 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; -using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -14,7 +12,6 @@ using Squidex.Areas.Api.Controllers.Users.Models; using Squidex.Domain.Users; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Security; using Squidex.Shared; using Squidex.Web; @@ -43,11 +40,7 @@ namespace Squidex.Areas.Api.Controllers.Users await Task.WhenAll(taskForItems, taskForCount); - var response = new UsersDto - { - Total = taskForCount.Result, - Items = taskForItems.Result.Select(UserDto.FromUser).ToArray() - }; + var response = UsersDto.FromResults(taskForItems.Result, taskForCount.Result, this); return Ok(response); } @@ -64,7 +57,7 @@ namespace Squidex.Areas.Api.Controllers.Users return NotFound(); } - var response = UserDto.FromUser(entity); + var response = UserDto.FromUser(entity, this); return Ok(response); } @@ -96,7 +89,7 @@ namespace Squidex.Areas.Api.Controllers.Users [ApiPermission(Permissions.AdminUsersLock)] public async Task LockUser(string id) { - if (IsSelf(id)) + if (this.IsUser(id)) { throw new ValidationException("Locking user failed.", new ValidationError("You cannot lock yourself.")); } @@ -111,7 +104,7 @@ namespace Squidex.Areas.Api.Controllers.Users [ApiPermission(Permissions.AdminUsersUnlock)] public async Task UnlockUser(string id) { - if (IsSelf(id)) + if (this.IsUser(id)) { throw new ValidationException("Unlocking user failed.", new ValidationError("You cannot unlock yourself.")); } @@ -120,12 +113,5 @@ namespace Squidex.Areas.Api.Controllers.Users return NoContent(); } - - private bool IsSelf(string id) - { - var subject = User.OpenIdSubject(); - - return string.Equals(subject, id, StringComparison.OrdinalIgnoreCase); - } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs b/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs index 36c556adb..5311ff642 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs @@ -76,7 +76,7 @@ namespace Squidex.Areas.Api.Controllers.Users { var entities = await userResolver.QueryByEmailAsync(query); - var models = entities.Where(x => !x.IsHidden()).Select(UserDto.FromUser).ToArray(); + var models = entities.Where(x => !x.IsHidden()).Select(x => UserDto.FromUser(x, this)).ToArray(); return Ok(models); } @@ -110,7 +110,7 @@ namespace Squidex.Areas.Api.Controllers.Users if (entity != null) { - var response = UserDto.FromUser(entity); + var response = UserDto.FromUser(entity, this); return Ok(response); } 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 b9d1bdb51..d23721240 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 @@ -50,7 +50,7 @@ -
+
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 0c7e1efc2..9a7e9b71a 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 @@ -26,7 +26,7 @@ import { export class UserPageComponent extends ResourceOwner implements OnInit { public canUpdate = false; - public user?: { user: UserDto, isCurrentUser: boolean }; + public user?: UserDto; public userForm = new UserForm(this.formBuilder); constructor( @@ -45,7 +45,7 @@ export class UserPageComponent extends ResourceOwner implements OnInit { this.user = selectedUser!; if (selectedUser) { - this.userForm.load(selectedUser.user); + this.userForm.load(selectedUser); } })); } @@ -55,7 +55,7 @@ export class UserPageComponent extends ResourceOwner implements OnInit { if (value) { if (this.user) { - this.usersState.update(this.user.user, value) + this.usersState.update(this.user, value) .subscribe(() => { this.userForm.submitCompleted(); }, error => { 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 223f584bf..167ee3eb9 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 @@ -48,32 +48,24 @@
- - + + diff --git a/src/Squidex/app/features/administration/services/users.service.ts b/src/Squidex/app/features/administration/services/users.service.ts index a657eee38..b42fe46a2 100644 --- a/src/Squidex/app/features/administration/services/users.service.ts +++ b/src/Squidex/app/features/administration/services/users.service.ts @@ -14,12 +14,19 @@ import { ApiUrlConfig, Model, pretifyError, - ResultSet + Resource, + ResourceLinks, + ResultSet, + withLinks } from '@app/shared'; -export class UsersDto extends ResultSet {} +export class UsersDto extends ResultSet { + public _links: ResourceLinks; +} export class UserDto extends Model { + public _links: ResourceLinks; + constructor( public readonly id: string, public readonly email: string, @@ -60,17 +67,19 @@ export class UsersService { public getUsers(take: number, skip: number, query?: string): Observable { const url = this.apiUrl.buildUrl(`api/user-management?take=${take}&skip=${skip}&query=${query || ''}`); - return this.http.get<{ total: number, items: any[] }>(url).pipe( + return this.http.get<{ total: number, items: any[] } & Resource>(url).pipe( map(body => { const users = body.items.map(item => - new UserDto( - item.id, - item.email, - item.displayName, - item.permissions, - item.isLocked)); - - return new UsersDto(body.total, users); + withLinks( + new UserDto( + item.id, + item.email, + item.displayName, + item.permissions, + item.isLocked), + item)); + + return withLinks(new UsersDto(body.total, users), body); }), pretifyError('Failed to load users. Please reload.')); } 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 c2a912ecd..e735870a5 100644 --- a/src/Squidex/app/features/administration/state/users.state.spec.ts +++ b/src/Squidex/app/features/administration/state/users.state.spec.ts @@ -16,7 +16,7 @@ import { UsersService } from '@app/features/administration/internal'; -import { SnapshotUser, UsersState } from './users.state'; +import { UsersState } from './users.state'; describe('UsersState', () => { const oldUsers = [ @@ -26,21 +26,15 @@ describe('UsersState', () => { const newUser = new UserDto('id3', 'mail3@mail.de', 'name3', ['Permission3'], false); - let authService: IMock; let dialogs: IMock; let usersService: IMock; let usersState: UsersState; beforeEach(() => { - authService = Mock.ofType(); - - authService.setup(x => x.user) - .returns(() => { id: 'id2' }); - dialogs = Mock.ofType(); usersService = Mock.ofType(); - usersState = new UsersState(authService.object, dialogs.object, usersService.object); + usersState = new UsersState(dialogs.object, usersService.object); }); afterEach(() => { @@ -54,10 +48,7 @@ describe('UsersState', () => { usersState.load().subscribe(); - expect(usersState.snapshot.users.values).toEqual([ - { isCurrentUser: false, user: oldUsers[0] }, - { isCurrentUser: true, user: oldUsers[1] } - ]); + expect(usersState.snapshot.users.values).toEqual(oldUsers); expect(usersState.snapshot.usersPager.numberOfItems).toEqual(200); expect(usersState.snapshot.isLoaded).toBeTruthy(); @@ -91,7 +82,7 @@ describe('UsersState', () => { usersState.select('id1').subscribe(); usersState.load().subscribe(); - expect(usersState.snapshot.selectedUser).toEqual({ isCurrentUser: false, user: newUsers[0] }); + expect(usersState.snapshot.selectedUser).toEqual(newUsers[0]); }); it('should load next page and prev page when paging', () => { @@ -127,32 +118,32 @@ describe('UsersState', () => { }); it('should return user on select and not load when already loaded', () => { - let selectedUser: SnapshotUser; + let selectedUser: UserDto; usersState.select('id1').subscribe(x => { selectedUser = x!; }); - expect(selectedUser!.user).toEqual(oldUsers[0]); - expect(usersState.snapshot.selectedUser).toEqual({ isCurrentUser: false, user: oldUsers[0] }); + expect(selectedUser!).toEqual(oldUsers[0]); + expect(usersState.snapshot.selectedUser).toEqual(oldUsers[0]); }); it('should return user on select and load when not loaded', () => { usersService.setup(x => x.getUser('id3')) .returns(() => of(newUser)); - let selectedUser: SnapshotUser; + let selectedUser: UserDto; usersState.select('id3').subscribe(x => { selectedUser = x!; }); - expect(selectedUser!.user).toEqual(newUser); - expect(usersState.snapshot.selectedUser).toEqual({ isCurrentUser: false, user: newUser }); + expect(selectedUser!).toEqual(newUser); + expect(usersState.snapshot.selectedUser).toEqual(newUser); }); it('should return null on select when unselecting user', () => { - let selectedUser: SnapshotUser; + let selectedUser: UserDto; usersState.select(null).subscribe(x => { selectedUser = x!; @@ -166,7 +157,7 @@ describe('UsersState', () => { usersService.setup(x => x.getUser('unknown')) .returns(() => throwError({})).verifiable(); - let selectedUser: SnapshotUser; + let selectedUser: UserDto; usersState.select('unknown').subscribe(x => { selectedUser = x!; @@ -185,7 +176,7 @@ describe('UsersState', () => { const user_1 = usersState.snapshot.users.at(0); - expect(user_1.user.isLocked).toBeTruthy(); + expect(user_1.isLocked).toBeTruthy(); expect(user_1).toBe(usersState.snapshot.selectedUser!); }); @@ -198,7 +189,7 @@ describe('UsersState', () => { const user_1 = usersState.snapshot.users.at(1); - expect(user_1.user.isLocked).toBeFalsy(); + expect(user_1.isLocked).toBeFalsy(); expect(user_1).toBe(usersState.snapshot.selectedUser!); }); @@ -213,9 +204,9 @@ describe('UsersState', () => { const user_1 = usersState.snapshot.users.at(0); - expect(user_1.user.email).toEqual(request.email); - expect(user_1.user.displayName).toEqual(request.displayName); - expect(user_1.user.permissions).toEqual(request.permissions); + 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!); }); @@ -227,11 +218,7 @@ describe('UsersState', () => { usersState.create(request).subscribe(); - expect(usersState.snapshot.users.values).toEqual([ - { isCurrentUser: false, user: newUser }, - { isCurrentUser: false, user: oldUsers[0] }, - { isCurrentUser: true, user: oldUsers[1] } - ]); + expect(usersState.snapshot.users.values).toEqual([newUser, ...oldUsers]); 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 7de907b24..49c5163cc 100644 --- a/src/Squidex/app/features/administration/state/users.state.ts +++ b/src/Squidex/app/features/administration/state/users.state.ts @@ -7,12 +7,11 @@ import { Injectable } from '@angular/core'; import { Observable, of } from 'rxjs'; -import { catchError, distinctUntilChanged, map, tap } from 'rxjs/operators'; +import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'; import '@app/framework/utils/rxjs-extensions'; import { - AuthService, DialogService, ImmutableArray, Pager, @@ -27,14 +26,6 @@ import { UsersService } from './../services/users.service'; -export interface SnapshotUser { - // The user. - user: UserDto; - - // Indicates if the user is the current user. - isCurrentUser: boolean; -} - interface Snapshot { // The current users. users: UsersList; @@ -49,10 +40,10 @@ interface Snapshot { isLoaded?: boolean; // The selected user. - selectedUser?: SnapshotUser | null; + selectedUser?: UserDto | null; } -export type UsersList = ImmutableArray; +export type UsersList = ImmutableArray; export type UsersResult = { total: number, users: UsersList }; @Injectable() @@ -74,14 +65,13 @@ export class UsersState extends State { distinctUntilChanged()); constructor( - private readonly authState: AuthService, private readonly dialogs: DialogService, private readonly usersService: UsersService ) { super({ users: ImmutableArray.empty(), usersPager: new Pager(0) }); } - public select(id: string | null): Observable { + public select(id: string | null): Observable { return this.loadUser(id).pipe( tap(selectedUser => { this.next(s => ({ ...s, selectedUser })); @@ -94,13 +84,13 @@ export class UsersState extends State { return of(null); } - const found = this.snapshot.users.find(x => x.user.id === id); + const found = this.snapshot.users.find(x => x.id === id); if (found) { return of(found); } - return this.usersService.getUser(id).pipe(map(x => this.createUser(x)), catchError(() => of(null))); + return this.usersService.getUser(id).pipe(catchError(() => of(null))); } public load(isReload = false): Observable { @@ -125,12 +115,12 @@ export class UsersState extends State { this.next(s => { const usersPager = s.usersPager.setCount(total); - const users = ImmutableArray.of(items.map(x => this.createUser(x))); + const users = ImmutableArray.of(items); let selectedUser = s.selectedUser; if (selectedUser) { - selectedUser = users.find(x => x.user.id === selectedUser!.user.id) || selectedUser; + selectedUser = users.find(x => x.id === selectedUser!.id) || selectedUser; } return { ...s, users, usersPager, selectedUser, isLoaded: true }; @@ -143,7 +133,7 @@ export class UsersState extends State { return this.usersService.postUser(request).pipe( tap(created => { this.next(s => { - const users = s.users.pushFront(this.createUser(created)); + const users = s.users.pushFront(created); const usersPager = s.usersPager.incrementCount(); return { ...s, users, usersPager }; @@ -154,7 +144,7 @@ export class UsersState extends State { public update(user: UserDto, request: UpdateUserDto): Observable { return this.usersService.putUser(user.id, request).pipe( - map(() => update(user, request)), + switchMap(() => this.usersService.getUser(user.id)), tap(updated => { this.replaceUser(updated); }), @@ -163,7 +153,7 @@ export class UsersState extends State { public lock(user: UserDto): Observable { return this.usersService.lockUser(user.id).pipe( - map(() => setLocked(user, true)), + switchMap(() => this.usersService.getUser(user.id)), tap(updated => { this.replaceUser(updated); }), @@ -172,7 +162,7 @@ export class UsersState extends State { public unlock(user: UserDto): Observable { return this.usersService.unlockUser(user.id).pipe( - map(() => setLocked(user, false)), + switchMap(() => this.usersService.getUser(user.id)), tap(updated => { this.replaceUser(updated); }), @@ -199,30 +189,15 @@ export class UsersState extends State { private replaceUser(user: UserDto) { return this.next(s => { - const users = s.users.map(u => u.user.id === user.id ? this.createUser(user) : u); + const users = s.users.map(u => u.id === user.id ? user : u); const selectedUser = s.selectedUser && - s.selectedUser.user.id !== user.id ? + s.selectedUser.id !== user.id ? s.selectedUser : - users.find(x => x.user.id === user.id); + users.find(x => x.id === user.id); return { ...s, users, selectedUser }; }); } - - private get userId() { - return this.authState.user!.id; - } - - private createUser(user: UserDto): SnapshotUser { - return { user, isCurrentUser: user.id === this.userId }; - } -} - - -const update = (user: UserDto, request: UpdateUserDto) => - user.with(request); - -const setLocked = (user: UserDto, isLocked: boolean) => - user.with({ isLocked }); \ No newline at end of file +} \ No newline at end of file diff --git a/src/Squidex/app/framework/angular/http/hateos.pipes.ts b/src/Squidex/app/framework/angular/http/hateos.pipes.ts new file mode 100644 index 000000000..979c95205 --- /dev/null +++ b/src/Squidex/app/framework/angular/http/hateos.pipes.ts @@ -0,0 +1,23 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import { Resource } 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]; + } +} + +@Pipe({ + name: 'sqxHasNoLink', + pure: true +}) +export class HasNoLinkPipe implements PipeTransform { + public transform(value: Resource, rel: string) { + return !value._links || !value._links[rel]; + } +} \ No newline at end of file diff --git a/src/Squidex/app/framework/declarations.ts b/src/Squidex/app/framework/declarations.ts index 7111dde33..5cf90253c 100644 --- a/src/Squidex/app/framework/declarations.ts +++ b/src/Squidex/app/framework/declarations.ts @@ -31,6 +31,7 @@ export * from './angular/forms/validators'; export * from './angular/http/caching.interceptor'; export * from './angular/http/loading.interceptor'; +export * from './angular/http/hateos.pipes'; export * from './angular/http/http-extensions'; export * from './angular/modals/dialog-renderer.component'; diff --git a/src/Squidex/app/framework/internal.ts b/src/Squidex/app/framework/internal.ts index 776e7df49..73b658619 100644 --- a/src/Squidex/app/framework/internal.ts +++ b/src/Squidex/app/framework/internal.ts @@ -23,6 +23,7 @@ export * from './utils/date-helper'; export * from './utils/date-time'; export * from './utils/duration'; export * from './utils/error'; +export * from './utils/hateos'; export * from './utils/interpolator'; export * from './utils/immutable-array'; export * from './utils/math-helper'; diff --git a/src/Squidex/app/framework/module.ts b/src/Squidex/app/framework/module.ts index 5921bf736..b18aad34b 100644 --- a/src/Squidex/app/framework/module.ts +++ b/src/Squidex/app/framework/module.ts @@ -42,6 +42,8 @@ import { FormHintComponent, FromNowPipe, FullDateTimePipe, + HasLinkPipe, + HasNoLinkPipe, HoverBackgroundDirective, IFrameEditorComponent, IgnoreScrollbarDirective, @@ -122,6 +124,8 @@ import { FormHintComponent, FromNowPipe, FullDateTimePipe, + HasLinkPipe, + HasNoLinkPipe, HoverBackgroundDirective, IFrameEditorComponent, IgnoreScrollbarDirective, @@ -188,6 +192,8 @@ import { FormsModule, FromNowPipe, FullDateTimePipe, + HasLinkPipe, + HasNoLinkPipe, HoverBackgroundDirective, IFrameEditorComponent, IgnoreScrollbarDirective, diff --git a/src/Squidex/app/framework/utils/hateos.ts b/src/Squidex/app/framework/utils/hateos.ts new file mode 100644 index 000000000..14e5357d8 --- /dev/null +++ b/src/Squidex/app/framework/utils/hateos.ts @@ -0,0 +1,25 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + + export interface Resource { + _links?: { [rel: string]: ResourceLink }; + } + + export type ResourceLinks = { [rel: string]: ResourceLink }; + export type ResourceLink = { href: string; method: ResourceMethod; }; + + export function withLinks(value: T, source: Resource) { + value._links = source._links; + + return value; + } + + export type ResourceMethod = + 'get' | + 'post' | + 'put' | + 'delete'; \ No newline at end of file From ca77b20d6ad0029089bf250dad73b91cb89a156b Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 7 Jun 2019 15:56:49 +0200 Subject: [PATCH 02/64] 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 From 368cfc7b828fe4464c3992abb76b7241fc70b1b7 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 8 Jun 2019 14:20:34 +0200 Subject: [PATCH 03/64] HATEOS for event consumers. --- .../Grains/EventConsumerGrain.cs | 29 ++-- .../Grains/EventConsumerManagerGrain.cs | 12 +- .../Grains/IEventConsumerGrain.cs | 6 +- .../Grains/IEventConsumerManagerGrain.cs | 10 +- src/Squidex.Web/Resource.cs | 1 - src/Squidex.Web/UrlHelperExtensions.cs | 2 - .../Contents/ContentsController.cs | 1 - .../EventConsumersController.cs | 31 +++-- .../EventConsumers/Models/EventConsumerDto.cs | 37 +++++- .../Models/EventConsumersDto.cs | 39 ++++++ .../event-consumers-page.component.html | 6 +- .../services/event-consumers.service.spec.ts | 124 +++++++++++++----- .../services/event-consumers.service.ts | 82 ++++++++---- .../services/users.service.spec.ts | 3 +- .../state/event-consumers.state.spec.ts | 60 +++++---- .../state/event-consumers.state.ts | 21 +-- .../administration/state/users.state.spec.ts | 10 +- 17 files changed, 323 insertions(+), 151 deletions(-) create mode 100644 src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumersDto.cs diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs index f37a9200f..bc5ada68d 100644 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs +++ b/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs @@ -58,7 +58,12 @@ namespace Squidex.Infrastructure.EventSourcing.Grains public Task> GetStateAsync() { - return Task.FromResult(State.ToInfo(eventConsumer.Name).AsImmutable()); + return Task.FromResult(CreateInfo()); + } + + private Immutable CreateInfo() + { + return State.ToInfo(eventConsumer.Name).AsImmutable(); } public Task OnEventAsync(Immutable subscription, Immutable storedEvent) @@ -109,39 +114,43 @@ namespace Squidex.Infrastructure.EventSourcing.Grains return TaskHelper.Done; } - public Task StartAsync() + public async Task> StartAsync() { if (!State.IsStopped) { - return TaskHelper.Done; + return CreateInfo(); } - return DoAndUpdateStateAsync(() => + await DoAndUpdateStateAsync(() => { Subscribe(State.Position); State = State.Started(); }); + + return CreateInfo(); } - public Task StopAsync() + public async Task> StopAsync() { if (State.IsStopped) { - return TaskHelper.Done; + return CreateInfo(); } - return DoAndUpdateStateAsync(() => + await DoAndUpdateStateAsync(() => { Unsubscribe(); State = State.Stopped(); }); + + return CreateInfo(); } - public Task ResetAsync() + public async Task> ResetAsync() { - return DoAndUpdateStateAsync(async () => + await DoAndUpdateStateAsync(async () => { Unsubscribe(); @@ -151,6 +160,8 @@ namespace Squidex.Infrastructure.EventSourcing.Grains State = State.Reset(); }); + + return CreateInfo(); } private Task DoAndUpdateStateAsync(Action action, [CallerMemberName] string caller = null) diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerManagerGrain.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerManagerGrain.cs index ca9097142..4952088c0 100644 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerManagerGrain.cs +++ b/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerManagerGrain.cs @@ -74,33 +74,31 @@ namespace Squidex.Infrastructure.EventSourcing.Grains { return Task.WhenAll( eventConsumers - .Select(c => GrainFactory.GetGrain(c.Name)) - .Select(c => c.StartAsync())); + .Select(c => StartAsync(c.Name))); } public Task StopAllAsync() { return Task.WhenAll( eventConsumers - .Select(c => GrainFactory.GetGrain(c.Name)) - .Select(c => c.StopAsync())); + .Select(c => StopAsync(c.Name))); } - public Task ResetAsync(string consumerName) + public Task> ResetAsync(string consumerName) { var eventConsumer = GrainFactory.GetGrain(consumerName); return eventConsumer.ResetAsync(); } - public Task StartAsync(string consumerName) + public Task> StartAsync(string consumerName) { var eventConsumer = GrainFactory.GetGrain(consumerName); return eventConsumer.StartAsync(); } - public Task StopAsync(string consumerName) + public Task> StopAsync(string consumerName) { var eventConsumer = GrainFactory.GetGrain(consumerName); diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs index 58b7bf2fb..fb7d82811 100644 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs +++ b/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs @@ -16,11 +16,11 @@ namespace Squidex.Infrastructure.EventSourcing.Grains { Task> GetStateAsync(); - Task StopAsync(); + Task> StopAsync(); - Task StartAsync(); + Task> StartAsync(); - Task ResetAsync(); + Task> ResetAsync(); Task OnEventAsync(Immutable subscription, Immutable storedEvent); diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerManagerGrain.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerManagerGrain.cs index c0b53d403..397db21f4 100644 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerManagerGrain.cs +++ b/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerManagerGrain.cs @@ -16,15 +16,15 @@ namespace Squidex.Infrastructure.EventSourcing.Grains { Task ActivateAsync(string streamName); - Task StopAllAsync(); + Task StartAllAsync(); - Task StopAsync(string consumerName); + Task StopAllAsync(); - Task StartAllAsync(); + Task> StopAsync(string consumerName); - Task StartAsync(string consumerName); + Task> StartAsync(string consumerName); - Task ResetAsync(string consumerName); + Task> ResetAsync(string consumerName); Task>> GetConsumersAsync(); } diff --git a/src/Squidex.Web/Resource.cs b/src/Squidex.Web/Resource.cs index a0e68197c..59c4d10f9 100644 --- a/src/Squidex.Web/Resource.cs +++ b/src/Squidex.Web/Resource.cs @@ -8,7 +8,6 @@ using Newtonsoft.Json; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Net.Http; namespace Squidex.Web { diff --git a/src/Squidex.Web/UrlHelperExtensions.cs b/src/Squidex.Web/UrlHelperExtensions.cs index 486d48a76..27f00a1d9 100644 --- a/src/Squidex.Web/UrlHelperExtensions.cs +++ b/src/Squidex.Web/UrlHelperExtensions.cs @@ -7,8 +7,6 @@ using Microsoft.AspNetCore.Mvc; using System; -using System.Linq.Expressions; -using System.Reflection; namespace Squidex.Web { diff --git a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index bd374a986..61c74d203 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -21,7 +21,6 @@ using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.GraphQL; using Squidex.Infrastructure.Commands; using Squidex.Shared; -using Squidex.Shared.Identity; using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Contents diff --git a/src/Squidex/Areas/Api/Controllers/EventConsumers/EventConsumersController.cs b/src/Squidex/Areas/Api/Controllers/EventConsumers/EventConsumersController.cs index 1369eb9c7..9e17d5f09 100644 --- a/src/Squidex/Areas/Api/Controllers/EventConsumers/EventConsumersController.cs +++ b/src/Squidex/Areas/Api/Controllers/EventConsumers/EventConsumersController.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Orleans; @@ -30,44 +29,54 @@ namespace Squidex.Areas.Api.Controllers.EventConsumers [HttpGet] [Route("event-consumers/")] + [ProducesResponseType(typeof(EventConsumersDto), 200)] [ApiPermission(Permissions.AdminEventsRead)] public async Task GetEventConsumers() { var entities = await GetGrain().GetConsumersAsync(); - var response = entities.Value.OrderBy(x => x.Name).Select(EventConsumerDto.FromEventConsumerInfo).ToArray(); + var response = EventConsumersDto.FromResults(entities.Value, this); return Ok(response); } [HttpPut] [Route("event-consumers/{name}/start/")] + [ProducesResponseType(typeof(EventConsumerDto), 200)] [ApiPermission(Permissions.AdminEventsManage)] - public async Task Start(string name) + public async Task StartEventConsumer(string name) { - await GetGrain().StartAsync(name); + var entity = await GetGrain().StartAsync(name); - return NoContent(); + var response = EventConsumerDto.FromEventConsumerInfo(entity.Value, this); + + return Ok(response); } [HttpPut] [Route("event-consumers/{name}/stop/")] + [ProducesResponseType(typeof(EventConsumerDto), 200)] [ApiPermission(Permissions.AdminEventsManage)] - public async Task Stop(string name) + public async Task StopEventConsumer(string name) { - await GetGrain().StopAsync(name); + var entity = await GetGrain().StopAsync(name); + + var response = EventConsumerDto.FromEventConsumerInfo(entity.Value, this); - return NoContent(); + return Ok(response); } [HttpPut] [Route("event-consumers/{name}/reset/")] + [ProducesResponseType(typeof(EventConsumerDto), 200)] [ApiPermission(Permissions.AdminEventsManage)] - public async Task Reset(string name) + public async Task ResetEventConsumer(string name) { - await GetGrain().ResetAsync(name); + var entity = await GetGrain().ResetAsync(name); + + var response = EventConsumerDto.FromEventConsumerInfo(entity.Value, this); - return NoContent(); + return Ok(response); } private IEventConsumerManagerGrain GetGrain() diff --git a/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumerDto.cs b/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumerDto.cs index 3ef6535c3..df0846cbf 100644 --- a/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumerDto.cs +++ b/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumerDto.cs @@ -7,11 +7,16 @@ using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.Security; +using Squidex.Shared; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.EventConsumers.Models { - public sealed class EventConsumerDto + public sealed class EventConsumerDto : Resource { + private static readonly Permission EventsManagePermission = new Permission(Permissions.AdminEventsManage); + public bool IsStopped { get; set; } public bool IsResetting { get; set; } @@ -22,9 +27,35 @@ namespace Squidex.Areas.Api.Controllers.EventConsumers.Models public string Position { get; set; } - public static EventConsumerDto FromEventConsumerInfo(EventConsumerInfo eventConsumerInfo) + public static EventConsumerDto FromEventConsumerInfo(EventConsumerInfo eventConsumerInfo, ApiController controller) + { + var result = SimpleMapper.Map(eventConsumerInfo, new EventConsumerDto()); + + return CreateLinks(result, controller); + } + + private static EventConsumerDto CreateLinks(EventConsumerDto result, ApiController controller) { - return SimpleMapper.Map(eventConsumerInfo, new EventConsumerDto()); + if (controller.HasPermission(EventsManagePermission)) + { + var values = new { name = result.Name }; + + if (!result.IsResetting) + { + result.AddPutLink("reset", controller.Url(x => nameof(x.ResetEventConsumer), values)); + } + + if (result.IsStopped) + { + result.AddPutLink("start", controller.Url(x => nameof(x.StartEventConsumer), values)); + } + else + { + result.AddPutLink("stop", controller.Url(x => nameof(x.StopEventConsumer), values)); + } + } + + return result; } } } diff --git a/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumersDto.cs b/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumersDto.cs new file mode 100644 index 000000000..8f9a20766 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumersDto.cs @@ -0,0 +1,39 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.EventConsumers.Models +{ + public sealed class EventConsumersDto : Resource + { + /// + /// The event consumers. + /// + public EventConsumerDto[] Items { get; set; } + + public static EventConsumersDto FromResults(IEnumerable items, ApiController controller) + { + var result = new EventConsumersDto + { + Items = items.Select(x => EventConsumerDto.FromEventConsumerInfo(x, controller)).ToArray() + }; + + return CreateLinks(result, controller); + } + + private static EventConsumersDto CreateLinks(EventConsumersDto result, ApiController controller) + { + result.AddSelfLink(controller.Url(c => nameof(c.GetEventConsumers))); + + return result; + } + } +} diff --git a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html index 6664745ac..8c31c3da2 100644 --- a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html +++ b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html @@ -42,13 +42,13 @@ {{eventConsumer.position}}
diff --git a/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts b/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts index 5eacb6d62..33bb41306 100644 --- a/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts +++ b/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts @@ -8,9 +8,13 @@ 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 { EventConsumerDto, EventConsumersService } from './event-consumers.service'; +import { + EventConsumerDto, + EventConsumersDto, + EventConsumersService +} from './event-consumers.service'; describe('EventConsumersService', () => { beforeEach(() => { @@ -32,7 +36,7 @@ describe('EventConsumersService', () => { it('should make get request to get event consumers', inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { - let eventConsumers: EventConsumerDto[]; + let eventConsumers: EventConsumersDto; eventConsumersService.getEventConsumers().subscribe(result => { eventConsumers = result; @@ -43,66 +47,120 @@ describe('EventConsumersService', () => { expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush([ - { - name: 'event-consumer1', - position: '13', - isStopped: true, - isResetting: true, - error: 'an error 1' - }, - { - name: 'event-consumer2', - position: '29', - isStopped: true, - isResetting: true, - error: 'an error 2' - } - ]); + req.flush({ + items: [ + eventConsumerResponse(12), + eventConsumerResponse(13) + ] + }); expect(eventConsumers!).toEqual( - [ - new EventConsumerDto('event-consumer1', true, true, 'an error 1', '13'), - new EventConsumerDto('event-consumer2', true, true, 'an error 2', '29') - ]); + new EventConsumersDto([ + createEventConsumer(12), + createEventConsumer(13) + ])); })); it('should make put request to start event consumer', inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { - eventConsumersService.putStart('event-consumer1').subscribe(); + const resource: Resource = { + _links: { + start: { method: 'PUT', href: 'api/event-consumers/event-consumer123/start' } + } + }; + + let eventConsumer: EventConsumerDto; - const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer1/start'); + eventConsumersService.putStart(resource).subscribe(response => { + eventConsumer = response; + }); + + const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer123/start'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({}); + req.flush(eventConsumerResponse(123)); + + expect(eventConsumer!).toEqual(createEventConsumer(123)); })); it('should make put request to stop event consumer', inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { - eventConsumersService.putStop('event-consumer1').subscribe(); + const resource: Resource = { + _links: { + stop: { method: 'PUT', href: 'api/event-consumers/event-consumer123/stop' } + } + }; + + let eventConsumer: EventConsumerDto; - const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer1/stop'); + eventConsumersService.putStop(resource).subscribe(response => { + eventConsumer = response; + }); + + const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer123/stop'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({}); + req.flush(eventConsumerResponse(12)); + + expect(eventConsumer!).toEqual(createEventConsumer(12)); })); it('should make put request to reset event consumer', inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { - eventConsumersService.putReset('event-consumer1').subscribe(); + const resource: Resource = { + _links: { + reset: { method: 'PUT', href: 'api/event-consumers/event-consumer123/reset' } + } + }; + + let eventConsumer: EventConsumerDto; - const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer1/reset'); + eventConsumersService.putReset(resource).subscribe(response => { + eventConsumer = response; + }); + + const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer123/reset'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({}); + req.flush(eventConsumerResponse(12)); + + expect(eventConsumer!).toEqual(createEventConsumer(12)); })); -}); \ No newline at end of file + + function eventConsumerResponse(id: number) { + return { + name: `event-consumer${id}`, + position: `position-${id}`, + isStopped: true, + isResetting: true, + error: `failure-${id}`, + _links: { + reset: { method: 'PUT', href: `/event-consumers/${id}/reset` } + } + }; + } +}); + +export function createEventConsumer(id: number, suffix = '') { + const result = new EventConsumerDto( + `event-consumer${id}`, + true, + true, + `failure-${id}${suffix}`, + `position-${id}${suffix}`); + + result._links['reset'] = { + method: 'PUT', href: `/event-consumers/${id}/reset` + }; + + return result; +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/services/event-consumers.service.ts b/src/Squidex/app/features/administration/services/event-consumers.service.ts index 05b2cd0fd..5c49b1980 100644 --- a/src/Squidex/app/features/administration/services/event-consumers.service.ts +++ b/src/Squidex/app/features/administration/services/event-consumers.service.ts @@ -12,11 +12,24 @@ import { map } from 'rxjs/operators'; import { ApiUrlConfig, - Model, - pretifyError + pretifyError, + Resource, + ResourceLinks, + withLinks } from '@app/shared'; -export class EventConsumerDto extends Model { +export class EventConsumersDto { + public readonly _links: ResourceLinks = {}; + + constructor( + public readonly items: EventConsumerDto[] + ) { + } +} + +export class EventConsumerDto { + public readonly _links: ResourceLinks = {}; + constructor( public readonly name: string, public readonly isStopped?: boolean, @@ -24,7 +37,6 @@ export class EventConsumerDto extends Model { public readonly error?: string, public readonly position?: string ) { - super(); } } @@ -36,42 +48,62 @@ export class EventConsumersService { ) { } - public getEventConsumers(): Observable { + public getEventConsumers(): Observable { const url = this.apiUrl.buildUrl('/api/event-consumers'); - return this.http.get(url).pipe( + return this.http.get<{ items: any[] } & Resource>(url).pipe( map(body => { - const eventConsumers = body.map(item => - new EventConsumerDto( - item.name, - item.isStopped, - item.isResetting, - item.error, - item.position)); - - return eventConsumers; + const eventConsumers = body.items.map(item => parseEventConsumer(item)); + + return withLinks(new EventConsumersDto(eventConsumers), body); }), pretifyError('Failed to load event consumers. Please reload.')); } - public putStart(name: string): Observable { - const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/start`); + public putStart(eventConsumer: Resource): Observable { + const link = eventConsumer._links['start']; - return this.http.put(url, {}).pipe( + const url = this.apiUrl.buildUrl(link.href); + + return this.http.request(link.method, url).pipe( + map(body => { + return parseEventConsumer(body); + }), pretifyError('Failed to start event consumer. Please reload.')); } - public putStop(name: string): Observable { - const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/stop`); + public putStop(eventConsumer: Resource): Observable { + const link = eventConsumer._links['stop']; + + const url = this.apiUrl.buildUrl(link.href); - return this.http.put(url, {}).pipe( + return this.http.request(link.method, url).pipe( + map(body => { + return parseEventConsumer(body); + }), pretifyError('Failed to stop event consumer. Please reload.')); } - public putReset(name: string): Observable { - const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/reset`); + public putReset(eventConsumer: Resource): Observable { + const link = eventConsumer._links['reset']; + + const url = this.apiUrl.buildUrl(link.href); - return this.http.put(url, {}).pipe( + return this.http.request(link.method, url).pipe( + map(body => { + return parseEventConsumer(body); + }), pretifyError('Failed to reset event consumer. Please reload.')); } -} \ No newline at end of file +} + +function parseEventConsumer(response: any): EventConsumerDto { + return withLinks( + new EventConsumerDto( + response.name, + response.isStopped, + response.isResetting, + response.error, + response.position), + response); +} 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 03b9b68fb..825f962f6 100644 --- a/src/Squidex/app/features/administration/services/users.service.spec.ts +++ b/src/Squidex/app/features/administration/services/users.service.spec.ts @@ -227,7 +227,8 @@ describe('UsersService', () => { }); export function createUser(id: number, suffix = '') { - const result = new UserDto(`${id}`, + const result = new UserDto( + `${id}`, `user${id}${suffix}@domain.com`, `user${id}${suffix}`, [ diff --git a/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts b/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts index 4a69ffbeb..ba384f5d1 100644 --- a/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts +++ b/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts @@ -11,14 +11,14 @@ import { IMock, It, Mock, Times } from 'typemoq'; import { DialogService } from '@app/framework'; -import { EventConsumerDto, EventConsumersService } from '@app/features/administration/internal'; +import { EventConsumersDto, EventConsumersService } from '@app/features/administration/internal'; import { EventConsumersState } from './event-consumers.state'; +import { createEventConsumer } from './../services/event-consumers.service.spec'; + describe('EventConsumersState', () => { - const oldConsumers = [ - new EventConsumerDto('name1', false, false, 'error', '1'), - new EventConsumerDto('name2', true, true, 'error', '2') - ]; + const eventConsumer1 = createEventConsumer(1); + const eventConsumer2 = createEventConsumer(2); let dialogs: IMock; let eventConsumersService: IMock; @@ -38,11 +38,11 @@ describe('EventConsumersState', () => { describe('Loading', () => { it('should load event consumers', () => { eventConsumersService.setup(x => x.getEventConsumers()) - .returns(() => of(oldConsumers)).verifiable(); + .returns(() => of(new EventConsumersDto([eventConsumer1, eventConsumer2]))).verifiable(); eventConsumersState.load().subscribe(); - expect(eventConsumersState.snapshot.eventConsumers.values).toEqual(oldConsumers); + expect(eventConsumersState.snapshot.eventConsumers.values).toEqual([eventConsumer1, eventConsumer2]); expect(eventConsumersState.snapshot.isLoaded).toBeTruthy(); dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); @@ -50,7 +50,7 @@ describe('EventConsumersState', () => { it('should show notification on load when reload is true', () => { eventConsumersService.setup(x => x.getEventConsumers()) - .returns(() => of(oldConsumers)).verifiable(); + .returns(() => of(new EventConsumersDto([eventConsumer1, eventConsumer2]))).verifiable(); eventConsumersState.load(true).subscribe(); @@ -74,42 +74,48 @@ describe('EventConsumersState', () => { describe('Updates', () => { beforeEach(() => { eventConsumersService.setup(x => x.getEventConsumers()) - .returns(() => of(oldConsumers)).verifiable(); + .returns(() => of(new EventConsumersDto([eventConsumer1, eventConsumer2]))).verifiable(); eventConsumersState.load().subscribe(); }); - it('should unmark as stopped when started', () => { - eventConsumersService.setup(x => x.putStart(oldConsumers[1].name)) - .returns(() => of({})).verifiable(); + it('should update evnet consumer when started', () => { + const updated = createEventConsumer(2, '_new'); + + eventConsumersService.setup(x => x.putStart(eventConsumer2)) + .returns(() => of(updated)).verifiable(); - eventConsumersState.start(oldConsumers[1]).subscribe(); + eventConsumersState.start(eventConsumer2).subscribe(); - const es_1 = eventConsumersState.snapshot.eventConsumers.at(1); + const newConsumer2 = eventConsumersState.snapshot.eventConsumers.at(1); - expect(es_1.isStopped).toBeFalsy(); + expect(newConsumer2).toEqual(updated); }); - it('should mark as stopped when stopped', () => { - eventConsumersService.setup(x => x.putStop(oldConsumers[0].name)) - .returns(() => of({})).verifiable(); + it('should update event consumer when stopped', () => { + const updated = createEventConsumer(2, '_new'); - eventConsumersState.stop(oldConsumers[0]).subscribe(); + eventConsumersService.setup(x => x.putStop(eventConsumer2)) + .returns(() => of(updated)).verifiable(); - const es_1 = eventConsumersState.snapshot.eventConsumers.at(0); + eventConsumersState.stop(eventConsumer2).subscribe(); - expect(es_1.isStopped).toBeTruthy(); + const newConsumer2 = eventConsumersState.snapshot.eventConsumers.at(1); + + expect(newConsumer2).toEqual(updated); }); - it('should mark as resetting when reset', () => { - eventConsumersService.setup(x => x.putReset(oldConsumers[0].name)) - .returns(() => of({})).verifiable(); + it('should update event consumer when reset', () => { + const updated = createEventConsumer(2, '_new'); + + eventConsumersService.setup(x => x.putReset(eventConsumer2)) + .returns(() => of(updated)).verifiable(); - eventConsumersState.reset(oldConsumers[0]).subscribe(); + eventConsumersState.reset(eventConsumer2).subscribe(); - const es_1 = eventConsumersState.snapshot.eventConsumers.at(0); + const newConsumer2 = eventConsumersState.snapshot.eventConsumers.at(1); - expect(es_1.isResetting).toBeTruthy(); + expect(newConsumer2).toEqual(updated); }); }); }); \ No newline at end of file diff --git a/src/Squidex/app/features/administration/state/event-consumers.state.ts b/src/Squidex/app/features/administration/state/event-consumers.state.ts index f5c5a0d1d..278578beb 100644 --- a/src/Squidex/app/features/administration/state/event-consumers.state.ts +++ b/src/Squidex/app/features/administration/state/event-consumers.state.ts @@ -51,12 +51,12 @@ export class EventConsumersState extends State { } return this.eventConsumersService.getEventConsumers().pipe( - tap(payload => { + tap(({ items }) => { if (isReload && !silent) { this.dialogs.notifyInfo('Event Consumers reloaded.'); } - const eventConsumers = ImmutableArray.of(payload); + const eventConsumers = ImmutableArray.of(items); this.next(s => { return { ...s, eventConsumers, isLoaded: true }; @@ -66,8 +66,7 @@ export class EventConsumersState extends State { } public start(eventConsumer: EventConsumerDto): Observable { - return this.eventConsumersService.putStart(eventConsumer.name).pipe( - map(() => setStopped(eventConsumer, false)), + return this.eventConsumersService.putStart(eventConsumer).pipe( tap(updated => { this.replaceEventConsumer(updated); }), @@ -75,8 +74,7 @@ export class EventConsumersState extends State { } public stop(eventConsumer: EventConsumerDto): Observable { - return this.eventConsumersService.putStop(eventConsumer.name).pipe( - map(() => setStopped(eventConsumer, true)), + return this.eventConsumersService.putStop(eventConsumer).pipe( tap(updated => { this.replaceEventConsumer(updated); }), @@ -84,8 +82,7 @@ export class EventConsumersState extends State { } public reset(eventConsumer: EventConsumerDto): Observable { - return this.eventConsumersService.putReset(eventConsumer.name).pipe( - map(() => reset(eventConsumer)), + return this.eventConsumersService.putReset(eventConsumer).pipe( tap(updated => { this.replaceEventConsumer(updated); }), @@ -99,10 +96,4 @@ export class EventConsumersState extends State { return { ...s, eventConsumers }; }); } -} - -const setStopped = (eventConsumer: EventConsumerDto, isStopped: boolean) => - eventConsumer.with({ isStopped }); - -const reset = (eventConsumer: EventConsumerDto) => - eventConsumer.with({ isResetting: true }); \ No newline at end of file +} \ 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 b9e4971db..3000bf6f6 100644 --- a/src/Squidex/app/features/administration/state/users.state.spec.ts +++ b/src/Squidex/app/features/administration/state/users.state.spec.ts @@ -168,7 +168,7 @@ describe('UsersState', () => { expect(usersState.snapshot.selectedUser).toBeNull(); }); - it('should mark as locked when locked', () => { + it('should update user selected user when locked', () => { const updated = createUser(2, '_new'); usersService.setup(x => x.lockUser(user2)) @@ -177,12 +177,12 @@ describe('UsersState', () => { usersState.select(user2.id).subscribe(); usersState.lock(user2).subscribe(); - const userUser2 = usersState.snapshot.users.at(1); + const newUser2 = usersState.snapshot.users.at(1); - expect(userUser2).toBe(usersState.snapshot.selectedUser!); + expect(newUser2).toBe(usersState.snapshot.selectedUser!); }); - it('should unmark as locked when unlocked', () => { + it('should update user and selected user when unlocked', () => { const updated = createUser(2, '_new'); usersService.setup(x => x.unlockUser(user2)) @@ -197,7 +197,7 @@ describe('UsersState', () => { expect(newUser2).toBe(usersState.snapshot.selectedUser!); }); - it('should update user properties when updated', () => { + it('should update user and selected user when updated', () => { const request = { email: 'new@mail.com', displayName: 'New', permissions: ['Permission1'] }; const updated = createUser(2, '_new'); From 242df48c18e66466980b5a0ba6ba0de03ccb9e37 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 8 Jun 2019 21:10:30 +0200 Subject: [PATCH 04/64] Some progress. --- src/Squidex.Shared/Permissions.cs | 2 - src/Squidex.Web/PermissionExtensions.cs | 18 +-- .../Controllers/Apps/AppPatternsController.cs | 2 +- .../Api/Controllers/Apps/AppsController.cs | 4 +- .../Api/Controllers/Apps/Models/AppDto.cs | 91 ++++++++++- .../Controllers/Plans/AppPlansController.cs | 2 +- .../Schemas/Models/SchemaDetailsDto.cs | 70 +-------- .../Controllers/Schemas/Models/SchemaDto.cs | 20 ++- .../Controllers/Schemas/SchemasController.cs | 4 +- .../Controllers/Users/Models/ResourcesDto.cs | 39 +++++ .../Api/Controllers/Users/UsersController.cs | 17 ++ .../administration-area.component.html | 8 +- .../administration-area.component.ts | 6 + .../pages/dashboard-page.component.html | 2 +- .../pages/dashboard-page.component.ts | 5 +- .../settings/settings-area.component.html | 18 +-- .../framework/angular/http/hateos.pipes.ts | 20 +-- src/Squidex/app/framework/internal.ts | 1 - src/Squidex/app/framework/module.ts | 3 - .../app/framework/utils/permission.spec.ts | 145 ------------------ src/Squidex/app/framework/utils/permission.ts | 116 -------------- .../components/asset-uploader.component.html | 2 +- .../shared/components/permission.directive.ts | 133 ---------------- .../components/schema-category.component.html | 2 +- src/Squidex/app/shared/declarations.ts | 1 - src/Squidex/app/shared/module.ts | 3 - .../app/shared/services/apps.service.spec.ts | 7 +- .../app/shared/services/apps.service.ts | 37 +++-- .../app/shared/services/auth.service.ts | 21 +-- .../app/shared/services/users.service.spec.ts | 28 ++++ .../app/shared/services/users.service.ts | 21 ++- .../app/shared/state/apps.state.spec.ts | 9 +- src/Squidex/app/shared/state/apps.state.ts | 9 +- src/Squidex/app/shared/state/ui.state.spec.ts | 23 ++- src/Squidex/app/shared/state/ui.state.ts | 37 +++-- .../shell/pages/app/left-menu.component.html | 14 +- .../shell/pages/app/left-menu.component.ts | 3 +- .../pages/internal/apps-menu.component.html | 24 ++- .../internal/profile-menu.component.html | 8 +- .../pages/internal/profile-menu.component.ts | 4 +- 40 files changed, 369 insertions(+), 610 deletions(-) create mode 100644 src/Squidex/Areas/Api/Controllers/Users/Models/ResourcesDto.cs delete mode 100644 src/Squidex/app/framework/utils/permission.spec.ts delete mode 100644 src/Squidex/app/framework/utils/permission.ts delete mode 100644 src/Squidex/app/shared/components/permission.directive.ts diff --git a/src/Squidex.Shared/Permissions.cs b/src/Squidex.Shared/Permissions.cs index 964925efb..ee1e20cf4 100644 --- a/src/Squidex.Shared/Permissions.cs +++ b/src/Squidex.Shared/Permissions.cs @@ -79,7 +79,6 @@ namespace Squidex.Shared public const string AppRolesDelete = "squidex.apps.{app}.roles.delete"; public const string AppPatterns = "squidex.apps.{app}.patterns"; - public const string AppPatternsRead = "squidex.apps.{app}.patterns.read"; public const string AppPatternsCreate = "squidex.apps.{app}.patterns.create"; public const string AppPatternsUpdate = "squidex.apps.{app}.patterns.update"; public const string AppPatternsDelete = "squidex.apps.{app}.patterns.delete"; @@ -117,7 +116,6 @@ namespace Squidex.Shared public const string AppContents = "squidex.apps.{app}.contents.{name}"; public const string AppContentsRead = "squidex.apps.{app}.contents.{name}.read"; - public const string AppContentsGraphQL = "squidex.apps.{app}.contents.{name}.graphql"; public const string AppContentsCreate = "squidex.apps.{app}.contents.{name}.create"; public const string AppContentsUpdate = "squidex.apps.{app}.contents.{name}.update"; public const string AppContentsDiscard = "squidex.apps.{app}.contents.{name}.discard"; diff --git a/src/Squidex.Web/PermissionExtensions.cs b/src/Squidex.Web/PermissionExtensions.cs index 6dc7d0610..a6751e9e9 100644 --- a/src/Squidex.Web/PermissionExtensions.cs +++ b/src/Squidex.Web/PermissionExtensions.cs @@ -24,7 +24,7 @@ namespace Squidex.Web } } - public static PermissionSet GetPermissions(this HttpContext httpContext) + public static PermissionSet Permissions(this HttpContext httpContext) { var feature = httpContext.Features.Get(); @@ -38,22 +38,22 @@ namespace Squidex.Web return feature.Permissions; } - public static bool HasPermission(this HttpContext httpContext, Permission permission) + public static bool HasPermission(this HttpContext httpContext, Permission permission, PermissionSet permissions = null) { - return httpContext.GetPermissions().Includes(permission); + return httpContext.Permissions().Includes(permission) || permission?.Includes(permission) == true; } - public static bool HasPermission(this HttpContext httpContext, string id, string app = "*", string schema = "*") + public static bool HasPermission(this HttpContext httpContext, string id, string app = "*", string schema = "*", PermissionSet permissions = null) { - return httpContext.GetPermissions().Includes(Permissions.ForApp(id, app, schema)); + return httpContext.HasPermission(Shared.Permissions.ForApp(id, app, schema), permissions); } - public static bool HasPermission(this ApiController controller, Permission permission) + public static bool HasPermission(this ApiController controller, Permission permission, PermissionSet permissions = null) { - return controller.HttpContext.GetPermissions().Includes(permission); + return controller.HttpContext.HasPermission(permission, permissions); } - public static bool HasPermission(this ApiController controller, string id, string app = "*", string schema = "*") + public static bool HasPermission(this ApiController controller, string id, string app = "*", string schema = "*", PermissionSet permissions = null) { if (app == "*") { @@ -71,7 +71,7 @@ namespace Squidex.Web } } - return controller.HttpContext.GetPermissions().Includes(Permissions.ForApp(id, app, schema)); + return controller.HasPermission(Shared.Permissions.ForApp(id, app, schema), permissions); } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs index fd4fac1c5..fef02c374 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs @@ -43,7 +43,7 @@ namespace Squidex.Areas.Api.Controllers.Apps [HttpGet] [Route("apps/{app}/patterns/")] [ProducesResponseType(typeof(AppPatternDto[]), 200)] - [ApiPermission(Permissions.AppPatternsRead)] + [ApiPermission(Permissions.AppCommon)] [ApiCosts(0)] public IActionResult GetPatterns(string app) { diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs index 4219f42dd..36f336ced 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs @@ -58,11 +58,11 @@ namespace Squidex.Areas.Api.Controllers.Apps public async Task GetApps() { var userOrClientId = HttpContext.User.UserOrClientId(); - var userPermissions = HttpContext.User.Permissions(); + var userPermissions = HttpContext.Permissions(); var entities = await appProvider.GetUserApps(userOrClientId, userPermissions); - var response = entities.ToArray(a => AppDto.FromApp(a, userOrClientId, userPermissions, appPlansProvider)); + var response = entities.ToArray(a => AppDto.FromApp(a, userOrClientId, userPermissions, appPlansProvider, this)); Response.Headers[HeaderNames.ETag] = response.ToManyEtag(); diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs index 7fe96d7bc..dbb2f4ded 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs @@ -9,6 +9,11 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using NodaTime; +using Squidex.Areas.Api.Controllers.Assets; +using Squidex.Areas.Api.Controllers.Backups; +using Squidex.Areas.Api.Controllers.Plans; +using Squidex.Areas.Api.Controllers.Rules; +using Squidex.Areas.Api.Controllers.Schemas; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Infrastructure; @@ -16,10 +21,11 @@ using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Security; using Squidex.Shared; using Squidex.Web; +using AllPermissions = Squidex.Shared.Permissions; namespace Squidex.Areas.Api.Controllers.Apps.Models { - public sealed class AppDto : IGenerateETag + public sealed class AppDto : Resource, IGenerateETag { /// /// The name of the app. @@ -63,7 +69,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// public string PlanUpgrade { get; set; } - public static AppDto FromApp(IAppEntity app, string userId, PermissionSet userPermissions, IAppPlansProvider plans) + public static AppDto FromApp(IAppEntity app, string userId, PermissionSet userPermissions, IAppPlansProvider plans, ApiController controller) { var permissions = new List(); @@ -77,13 +83,84 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models permissions.AddRange(userPermissions.ToAppPermissions(app.Name)); } - var response = SimpleMapper.Map(app, new AppDto()); + var result = SimpleMapper.Map(app, new AppDto()); - response.Permissions = permissions.ToArray(x => x.Id); - response.PlanName = plans.GetPlanForApp(app)?.Name; - response.PlanUpgrade = plans.GetPlanUpgradeForApp(app)?.Name; + result.Permissions = permissions.ToArray(x => x.Id); + result.PlanName = plans.GetPlanForApp(app)?.Name; - return response; + if (controller.HasPermission(AllPermissions.AppPlansChange, app.Name)) + { + result.PlanUpgrade = plans.GetPlanUpgradeForApp(app)?.Name; + } + + return CreateLinks(result, controller, new PermissionSet(permissions)); + } + + private static AppDto CreateLinks(AppDto result, ApiController controller, PermissionSet permissions) + { + var values = new { app = result.Name }; + + if (controller.HasPermission(AllPermissions.AppDelete, result.Name, permissions: permissions)) + { + result.AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteApp), values)); + } + + if (controller.HasPermission(AllPermissions.AppAssetsRead, result.Name, permissions: permissions)) + { + result.AddGetLink("assets", controller.Url(x => nameof(x.GetAssets), values)); + } + + if (controller.HasPermission(AllPermissions.AppBackupsRead, result.Name, permissions: permissions)) + { + result.AddGetLink("backups", controller.Url(x => nameof(x.GetJobs), values)); + } + + if (controller.HasPermission(AllPermissions.AppClientsRead, result.Name, permissions: permissions)) + { + result.AddGetLink("clients", controller.Url(x => nameof(x.GetClients), values)); + } + + if (controller.HasPermission(AllPermissions.AppContributorsRead, result.Name, permissions: permissions)) + { + result.AddGetLink("contributors", controller.Url(x => nameof(x.GetContributors), values)); + } + + if (controller.HasPermission(AllPermissions.AppCommon, result.Name, permissions: permissions)) + { + result.AddGetLink("languages", controller.Url(x => nameof(x.GetLanguages), values)); + } + + if (controller.HasPermission(AllPermissions.AppCommon, result.Name, permissions: permissions)) + { + result.AddGetLink("patterns", controller.Url(x => nameof(x.GetPatterns), values)); + } + + if (controller.HasPermission(AllPermissions.AppPlansRead, result.Name, permissions: permissions)) + { + result.AddGetLink("plans", controller.Url(x => nameof(x.GetPlans), values)); + } + + if (controller.HasPermission(AllPermissions.AppRolesRead, result.Name, permissions: permissions)) + { + result.AddGetLink("roles", controller.Url(x => nameof(x.GetRoles), values)); + } + + if (controller.HasPermission(AllPermissions.AppRulesRead, result.Name, permissions: permissions)) + { + result.AddGetLink("rules", controller.Url(x => nameof(x.GetRules), values)); + } + + if (controller.HasPermission(AllPermissions.AppCommon, result.Name, permissions: permissions)) + { + result.AddGetLink("schemas", controller.Url(x => nameof(x.GetSchemas), values)); + } + + if (controller.HasPermission(AllPermissions.AppSchemasCreate, result.Name, permissions: permissions)) + { + result.AddPostLink("schemas/create", controller.Url(x => nameof(x.PostSchema), values)); + } + + return result; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs b/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs index 9490a4f48..5611ac7ac 100644 --- a/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs +++ b/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs @@ -74,7 +74,7 @@ namespace Squidex.Areas.Api.Controllers.Plans [ProducesResponseType(typeof(ErrorDto), 400)] [ApiPermission(Permissions.AppPlansChange)] [ApiCosts(0)] - public async Task ChangePlanAsync(string app, [FromBody] ChangePlanDto request) + public async Task PutPlan(string app, [FromBody] ChangePlanDto request) { var context = await CommandBus.PublishAsync(request.ToCommand()); diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs index a6ed2b46b..41494e0c1 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs @@ -5,49 +5,20 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using NodaTime; using Squidex.Areas.Api.Controllers.Schemas.Models.Converters; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Entities.Schemas; -using Squidex.Infrastructure; using Squidex.Infrastructure.Reflection; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Schemas.Models { - public sealed class SchemaDetailsDto + public sealed class SchemaDetailsDto : SchemaDto { private static readonly Dictionary EmptyPreviewUrls = new Dictionary(); - /// - /// The id of the schema. - /// - public Guid Id { get; set; } - - /// - /// The name of the schema. Unique within the app. - /// - [Required] - [RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")] - public string Name { get; set; } - - /// - /// The name of the category. - /// - public string Category { get; set; } - - /// - /// Indicates if the schema is a singleton. - /// - public bool IsSingleton { get; set; } - - /// - /// Indicates if the schema is published. - /// - public bool IsPublished { get; set; } - /// /// The scripts. /// @@ -64,40 +35,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models [Required] public List Fields { get; set; } - /// - /// The schema properties. - /// - [Required] - public SchemaPropertiesDto Properties { get; set; } = new SchemaPropertiesDto(); - - /// - /// The user that has created the schema. - /// - [Required] - public RefToken CreatedBy { get; set; } - - /// - /// The user that has updated the schema. - /// - [Required] - public RefToken LastModifiedBy { get; set; } - - /// - /// The date and time when the schema has been created. - /// - public Instant Created { get; set; } - - /// - /// The date and time when the schema has been modified last. - /// - public Instant LastModified { get; set; } - - /// - /// The version of the schema. - /// - public long Version { get; set; } - - public static SchemaDetailsDto FromSchema(ISchemaEntity schema) + public static SchemaDetailsDto FromSchemaWithDetails(ISchemaEntity schema, ApiController controller, string app) { var response = new SchemaDetailsDto(); @@ -147,7 +85,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models response.Fields.Add(fieldDto); } - return response; + return CreateLinks(response, controller, app); } } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs index f66d64d23..4bb86680f 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs @@ -8,14 +8,16 @@ using System; using System.ComponentModel.DataAnnotations; using NodaTime; +using Squidex.Areas.Api.Controllers.Contents; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.Reflection; +using Squidex.Shared; using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Schemas.Models { - public sealed class SchemaDto : IGenerateETag + public class SchemaDto : Resource, IGenerateETag { /// /// The id of the schema. @@ -77,7 +79,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// public long Version { get; set; } - public static SchemaDto FromSchema(ISchemaEntity schema) + public static SchemaDto FromSchema(ISchemaEntity schema, ApiController controller, string app) { var response = new SchemaDto { Properties = new SchemaPropertiesDto() }; @@ -85,6 +87,20 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models SimpleMapper.Map(schema.SchemaDef, response); SimpleMapper.Map(schema.SchemaDef.Properties, response.Properties); + return CreateLinks(response, controller, app); + } + + protected static T CreateLinks(T response, ApiController controller, string app) where T : SchemaDto + { + var values = new { app, name = response.Name }; + + response.AddSelfLink(controller.Url(x => nameof(x.GetSchema), values)); + + if (controller.HasPermission(Permissions.AppContentsRead, app, response.Name)) + { + response.AddGetLink("contents", controller.Url(x => nameof(x.GetContents), values)); + } + return response; } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs b/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs index 1fb64ec83..7320e4484 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs @@ -51,7 +51,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas { var schemas = await appProvider.GetSchemasAsync(AppId); - var response = schemas.ToArray(SchemaDto.FromSchema); + var response = schemas.ToArray(x => SchemaDto.FromSchema(x, this, app)); Response.Headers[HeaderNames.ETag] = response.ToManyEtag(); @@ -90,7 +90,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas return NotFound(); } - var response = SchemaDetailsDto.FromSchema(entity); + var response = SchemaDetailsDto.FromSchemaWithDetails(entity, this, app); Response.Headers[HeaderNames.ETag] = entity.Version.ToString(); diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/ResourcesDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/ResourcesDto.cs new file mode 100644 index 000000000..13570a1bf --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Users/Models/ResourcesDto.cs @@ -0,0 +1,39 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Areas.Api.Controllers.Backups; +using Squidex.Areas.Api.Controllers.EventConsumers; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Users.Models +{ + public sealed class ResourcesDto : Resource + { + public static ResourcesDto FromController(ApiController controller) + { + var result = new ResourcesDto(); + + if (controller.HasPermission(Permissions.AdminEventsRead)) + { + result.AddGetLink("admin/eventConsumers", controller.Url(x => nameof(x.GetEventConsumers))); + } + + if (controller.HasPermission(Permissions.AdminRestoreRead)) + { + result.AddGetLink("admin/restore", controller.Url(x => nameof(x.GetJob))); + } + + if (controller.HasPermission(Permissions.AdminUsersRead)) + { + result.AddGetLink("admin/users", controller.Url(x => nameof(x.GetUsers))); + } + + return result; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs b/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs index a5cf31c74..0ab2944c5 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs @@ -56,6 +56,23 @@ namespace Squidex.Areas.Api.Controllers.Users this.log = log; } + /// + /// Get the user resources. + /// + /// + /// 200 => User resources returned. + /// + [HttpGet] + [Route("user/resources/")] + [ProducesResponseType(typeof(ResourcesDto), 200)] + [ApiPermission] + public IActionResult GetUserResources() + { + var response = ResourcesDto.FromController(this); + + return Ok(response); + } + /// /// Get users by query. /// diff --git a/src/Squidex/app/features/administration/administration-area.component.html b/src/Squidex/app/features/administration/administration-area.component.html index c3b623cdb..3f2e2c3d0 100644 --- a/src/Squidex/app/features/administration/administration-area.component.html +++ b/src/Squidex/app/features/administration/administration-area.component.html @@ -1,18 +1,18 @@ - @@ -37,12 +38,12 @@
-
- +
diff --git a/src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.ts b/src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.ts index a3e1f140b..0e18f523f 100644 --- a/src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.ts +++ b/src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.ts @@ -29,6 +29,9 @@ export class ContentChangedTriggerComponent implements OnInit { @Input() public schemas: ImmutableArray; + @Input() + public canUpdate: boolean; + @Input() public trigger: any; diff --git a/src/Squidex/app/framework/angular/forms/toggle.component.ts b/src/Squidex/app/framework/angular/forms/toggle.component.ts index 7105ada32..33e53d9bf 100644 --- a/src/Squidex/app/framework/angular/forms/toggle.component.ts +++ b/src/Squidex/app/framework/angular/forms/toggle.component.ts @@ -28,6 +28,10 @@ export class ToggleComponent extends StatefulControlComponent { const version = new Version('1'); @@ -107,7 +107,7 @@ describe('RulesService', () => { it('should make get request to get app rules', inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => { - let rules: RuleDto[]; + let rules: RulesDto; rulesService.getRules('my-app').subscribe(result => { rules = result; @@ -118,49 +118,18 @@ describe('RulesService', () => { expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush([ - { - id: 'id1', - created: '2016-12-12T10:10', - createdBy: 'CreatedBy1', - lastModified: '2017-12-12T10:10', - lastModifiedBy: 'LastModifiedBy1', - url: 'http://squidex.io/hook', - version: '1', - trigger: { - param1: 1, - param2: 2, - triggerType: 'ContentChanged' - }, - action: { - param3: 3, - param4: 4, - actionType: 'Webhook' - }, - isEnabled: true - } - ]); + req.flush({ + items: [ + ruleResponse(12), + ruleResponse(13) + ] + }); expect(rules!).toEqual( - [ - new RuleDto('id1', 'CreatedBy1', 'LastModifiedBy1', - DateTime.parseISO_UTC('2016-12-12T10:10'), - DateTime.parseISO_UTC('2017-12-12T10:10'), - version, - true, - { - param1: 1, - param2: 2, - triggerType: 'ContentChanged' - }, - 'ContentChanged', - { - param3: 3, - param4: 4, - actionType: 'Webhook' - }, - 'Webhook') - ]); + new RulesDto(2, [ + createRule(12), + createRule(13) + ])); })); it('should make post request to create rule', @@ -179,7 +148,7 @@ describe('RulesService', () => { } }; - let rule: Versioned; + let rule: RuleDto; rulesService.postRule('my-app', dto).subscribe(result => { rule = result; @@ -190,18 +159,13 @@ describe('RulesService', () => { expect(req.request.method).toEqual('POST'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({ id: 'id1' }, { + req.flush(ruleResponse(12), { headers: { etag: '1' } }); - expect(rule!).toEqual({ - payload: { - id: 'id1' - }, - version - }); + expect(rule!).toEqual(createRule(12)); })); it('should make put request to update rule', @@ -216,46 +180,88 @@ describe('RulesService', () => { } }; - rulesService.putRule('my-app', '123', dto, version).subscribe(); + const resource: Resource = { + _links: { + update: { method: 'PUT', href: '/api/apps/my-app/rules/123' } + } + }; + + let rule: RuleDto; + + rulesService.putRule('my-app', resource, dto, version).subscribe(result => { + rule = result; + }); const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/123'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toEqual(version.value); - req.flush({}); + req.flush(ruleResponse(123)); + + expect(rule!).toEqual(createRule(123)); })); it('should make put request to enable rule', inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => { - rulesService.enableRule('my-app', '123', version).subscribe(); + const resource: Resource = { + _links: { + enable: { method: 'PUT', href: '/api/apps/my-app/rules/123/enable' } + } + }; + + let rule: RuleDto; + + rulesService.enableRule('my-app', resource, version).subscribe(result => { + rule = result; + }); const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/123/enable'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toEqual(version.value); - req.flush({}); + req.flush(ruleResponse(123)); + + expect(rule!).toEqual(createRule(123)); })); it('should make put request to disable rule', inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => { - rulesService.disableRule('my-app', '123', version).subscribe(); + const resource: Resource = { + _links: { + disable: { method: 'PUT', href: '/api/apps/my-app/rules/123/disable' } + } + }; + + let rule: RuleDto; + + rulesService.disableRule('my-app', resource, version).subscribe(result => { + rule = result; + }); const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/123/disable'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toEqual(version.value); - req.flush({}); + req.flush(ruleResponse(123)); + + expect(rule!).toEqual(createRule(123)); })); it('should make delete request to delete rule', inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => { - rulesService.deleteRule('my-app', '123', version).subscribe(); + const resource: Resource = { + _links: { + delete: { method: 'DELETE', href: '/api/apps/my-app/rules/123' } + } + }; + + rulesService.deleteRule('my-app', resource, version).subscribe(); const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/123'); @@ -322,7 +328,13 @@ describe('RulesService', () => { it('should make put request to enqueue rule event', inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => { - rulesService.enqueueEvent('my-app', '123').subscribe(); + const resource: Resource = { + _links: { + update: { method: 'PUT', href: '/api/apps/my-app/rules/events/123' } + } + }; + + rulesService.enqueueEvent('my-app', resource).subscribe(); const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/events/123'); @@ -335,7 +347,13 @@ describe('RulesService', () => { it('should make delete request to cancel rule event', inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => { - rulesService.cancelEvent('my-app', '123').subscribe(); + const resource: Resource = { + _links: { + delete: { method: 'DELETE', href: '/api/apps/my-app/rules/events/123' } + } + }; + + rulesService.cancelEvent('my-app', resource).subscribe(); const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/events/123'); @@ -344,4 +362,58 @@ describe('RulesService', () => { req.flush({}); })); -}); \ No newline at end of file + + function ruleResponse(id: number, suffix = '') { + return { + id: `id${id}`, + created: `${id % 1000 + 2000}-12-12T10:10`, + createdBy: `creator-${id}`, + lastModified: `${id % 1000 + 2000}-11-11T10:10`, + lastModifiedBy: `modifier-${id}`, + isEnabled: id % 2 === 0, + trigger: { + param1: 1, + param2: 2, + triggerType: `ContentChanged${id}${suffix}` + }, + action: { + param3: 3, + param4: 4, + actionType: `Webhook${id}${suffix}` + }, + version: id, + _links: { + update: { method: 'PUT', href: `/rules/${id}` } + } + }; + } +}); + +export function createRule(id: number, suffix = '') { + const result = new RuleDto( + `id${id}`, + `creator-${id}`, + `modifier-${id}`, + DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10`), + DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10`), + new Version(`${id}`), + id % 2 === 0, + { + param1: 1, + param2: 2, + triggerType: `ContentChanged${id}${suffix}` + }, + `ContentChanged${id}${suffix}`, + { + param3: 3, + param4: 4, + actionType: `Webhook${id}${suffix}` + }, + `Webhook${id}${suffix}`); + + result._links['update'] = { + method: 'PUT', href: `/rules/${id}` + }; + + return result; +} \ No newline at end of file diff --git a/src/Squidex/app/shared/services/rules.service.ts b/src/Squidex/app/shared/services/rules.service.ts index 7af1acfb7..c0a7030de 100644 --- a/src/Squidex/app/shared/services/rules.service.ts +++ b/src/Squidex/app/shared/services/rules.service.ts @@ -15,12 +15,13 @@ import { ApiUrlConfig, DateTime, HTTP, - mapVersioned, Model, pretifyError, + Resource, + ResourceLinks, ResultSet, Version, - Versioned + withLinks } from '@app/framework'; export const ALL_TRIGGERS = { @@ -74,7 +75,13 @@ export class RuleElementPropertyDto { } } +export class RulesDto extends ResultSet { + public readonly _links: ResourceLinks = {}; +} + export class RuleDto extends Model { + public readonly _links: ResourceLinks = {}; + constructor( public readonly id: string, public readonly createdBy: string, @@ -92,9 +99,13 @@ export class RuleDto extends Model { } } -export class RuleEventsDto extends ResultSet { } +export class RuleEventsDto extends ResultSet { + public readonly _links: ResourceLinks = {}; +} export class RuleEventDto extends Model { + public readonly _links: ResourceLinks = {}; + constructor( public readonly id: string, public readonly created: DateTime, @@ -115,10 +126,6 @@ export interface UpsertRuleDto { readonly action: RuleAction; } -export interface RuleCreatedDto { - readonly id: string; -} - export type RuleAction = { actionType: string } & any; export type RuleTrigger = { triggerType: string } & any; @@ -167,77 +174,87 @@ export class RulesService { pretifyError('Failed to load Rules. Please reload.')); } - public getRules(appName: string): Observable { + public getRules(appName: string): Observable { const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules`); return HTTP.getVersioned(this.http, url).pipe( map(({ payload }) => { - const items: any[] = payload.body; + const items: any[] = payload.body.items; - const rules = items.map(item => - new RuleDto( - item.id, - item.createdBy, - item.lastModifiedBy, - DateTime.parseISO_UTC(item.created), - DateTime.parseISO_UTC(item.lastModified), - new Version(item.version.toString()), - item.isEnabled, - item.trigger, - item.trigger.triggerType, - item.action, - item.action.actionType)); - - return rules; + const rules = items.map(item => parseRule(item)); + + return withLinks(new RulesDto(rules.length, rules), payload.body); }), pretifyError('Failed to load Rules. Please reload.')); } - public postRule(appName: string, dto: UpsertRuleDto): Observable> { + public postRule(appName: string, dto: UpsertRuleDto): Observable { const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules`); - return HTTP.postVersioned(this.http, url, dto).pipe( - mapVersioned(({ body }) => body!), + return HTTP.postVersioned(this.http, url, dto).pipe( + map(({ payload }) => { + return parseRule(payload.body); + }), tap(() => { this.analytics.trackEvent('Rule', 'Created', appName); }), pretifyError('Failed to create rule. Please reload.')); } - public putRule(appName: string, id: string, dto: Partial, version: Version): Observable> { - const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/${id}`); + public putRule(appName: string, resource: Resource, dto: Partial, version: Version): Observable { + const link = resource._links['update']; - return HTTP.putVersioned(this.http, url, dto, version).pipe( + const url = this.apiUrl.buildUrl(link.href); + + return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( + map(({ payload }) => { + return parseRule(payload.body); + }), tap(() => { this.analytics.trackEvent('Rule', 'Updated', appName); }), pretifyError('Failed to update rule. Please reload.')); } - public enableRule(appName: string, id: string, version: Version): Observable> { - const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/${id}/enable`); + public enableRule(appName: string, resource: Resource, version: Version): Observable { + const link = resource._links['enable']; - return HTTP.putVersioned(this.http, url, {}, version).pipe( + const url = this.apiUrl.buildUrl(link.href); + + return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( + map(({ payload }) => { + return parseRule(payload.body); + }), tap(() => { - this.analytics.trackEvent('Rule', 'Updated', appName); + this.analytics.trackEvent('Rule', 'Enabled', appName); }), pretifyError('Failed to enable rule. Please reload.')); } - public disableRule(appName: string, id: string, version: Version): Observable> { - const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/${id}/disable`); + public disableRule(appName: string, resource: Resource, version: Version): Observable { + const link = resource._links['disable']; - return HTTP.putVersioned(this.http, url, {}, version).pipe( + const url = this.apiUrl.buildUrl(link.href); + + return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( + map(({ payload }) => { + return parseRule(payload.body); + }), tap(() => { - this.analytics.trackEvent('Rule', 'Updated', appName); + this.analytics.trackEvent('Rule', 'Disabled', appName); }), pretifyError('Failed to disable rule. Please reload.')); } - public deleteRule(appName: string, id: string, version: Version): Observable { - const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/${id}`); + public deleteRule(appName: string, resource: Resource, version: Version): Observable { + const link = resource._links['delete']; - return HTTP.deleteVersioned(this.http, url, version).pipe( + const url = this.apiUrl.buildUrl(link.href); + + return HTTP.requestVersioned(this.http, link.method, url, version).pipe( + map(({ payload }) => { + return parseRule(payload.body); + }), tap(() => { this.analytics.trackEvent('Rule', 'Deleted', appName); }), @@ -270,23 +287,44 @@ export class RulesService { pretifyError('Failed to load events. Please reload.')); } - public enqueueEvent(appName: string, id: string): Observable { - const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/events/${id}`); + public enqueueEvent(appName: string, resource: Resource): Observable { + const link = resource._links['update']; + + const url = this.apiUrl.buildUrl(link.href); - return HTTP.putVersioned(this.http, url, {}).pipe( + return HTTP.requestVersioned(this.http, link.method, url).pipe( tap(() => { this.analytics.trackEvent('Rule', 'EventEnqueued', appName); }), pretifyError('Failed to enqueue rule event. Please reload.')); } - public cancelEvent(appName: string, id: string): Observable { - const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/events/${id}`); + public cancelEvent(appName: string, resource: Resource): Observable { + const link = resource._links['delete']; - return HTTP.deleteVersioned(this.http, url).pipe( + const url = this.apiUrl.buildUrl(link.href); + + return HTTP.requestVersioned(this.http, link.method, url).pipe( tap(() => { this.analytics.trackEvent('Rule', 'EventDequeued', appName); }), pretifyError('Failed to cancel rule event. Please reload.')); } +} + +function parseRule(resource: any) { + return withLinks( + new RuleDto( + resource.id, + resource.createdBy, + resource.lastModifiedBy, + DateTime.parseISO_UTC(resource.created), + DateTime.parseISO_UTC(resource.lastModified), + new Version(resource.version.toString()), + resource.isEnabled, + resource.trigger, + resource.trigger.triggerType, + resource.action, + resource.action.actionType), + resource); } \ No newline at end of file diff --git a/src/Squidex/app/shared/state/asset-uploader.state.spec.ts b/src/Squidex/app/shared/state/asset-uploader.state.spec.ts index 17413beff..c3a185314 100644 --- a/src/Squidex/app/shared/state/asset-uploader.state.spec.ts +++ b/src/Squidex/app/shared/state/asset-uploader.state.spec.ts @@ -22,7 +22,7 @@ import { createAsset } from './../services/assets.service.spec'; import { TestValues } from './_test-helpers'; -describe('AssetsState', () => { +describe('AssetUploaderState', () => { const { app, appsState @@ -155,7 +155,7 @@ describe('AssetsState', () => { it('should update status when uploading asset completes', () => { const file: File = { name: 'my-file' }; - let updated = createAsset(1, undefined, '-new'); + let updated = createAsset(1, undefined, '_new'); assetsService.setup(x => x.replaceFile(app, asset, file, asset.version)) .returns(() => of(10, 20, updated)).verifiable(); diff --git a/src/Squidex/app/shared/state/assets.state.spec.ts b/src/Squidex/app/shared/state/assets.state.spec.ts index c9f045a02..f8df2c6d7 100644 --- a/src/Squidex/app/shared/state/assets.state.spec.ts +++ b/src/Squidex/app/shared/state/assets.state.spec.ts @@ -41,6 +41,9 @@ describe('AssetsState', () => { dialogs = Mock.ofType(); assetsService = Mock.ofType(); + assetsService.setup(x => x.getTags(app)) + .returns(() => of({ tag1: 1, shared: 2, tag2: 1 })).verifiable(Times.atLeastOnce()); + assetsState = new AssetsState(appsState.object, assetsService.object, dialogs.object); }); @@ -50,12 +53,9 @@ describe('AssetsState', () => { describe('Loading', () => { it('should load assets', () => { - assetsService.setup(x => x.getAssets(app, 30, 0, undefined, [])) + assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue([]))) .returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable(); - assetsService.setup(x => x.getTags(app)) - .returns(() => of({ tag1: 1, shared: 2, tag2: 1 })).verifiable(); - assetsState.load().subscribe(); expect(assetsState.snapshot.assets.values).toEqual([asset1, asset2]); @@ -66,11 +66,8 @@ describe('AssetsState', () => { }); it('should show notification on load when reload is true', () => { - assetsService.setup(x => x.getAssets(app, 30, 0, undefined, [])) - .returns(() => of(new AssetsDto(200, [asset1, asset2]))); - - assetsService.setup(x => x.getTags(app)) - .returns(() => of({ tag1: 1, shared: 2, tag2: 1 })).verifiable(); + assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue([]))) + .returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable(); assetsState.load(true).subscribe(); @@ -80,20 +77,20 @@ describe('AssetsState', () => { }); it('should load with tags when tag toggled', () => { - assetsService.setup(x => x.getAssets(app, 30, 0, undefined, ['tag1'])) - .returns(() => of(new AssetsDto(0, []))); + assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue(['tag1']))) + .returns(() => of(new AssetsDto(0, []))).verifiable(); assetsState.toggleTag('tag1').subscribe(); expect(assetsState.isTagSelected('tag1')).toBeTruthy(); }); - it('should load without tags when tag toggled', () => { - assetsService.setup(x => x.getAssets(app, 30, 0, undefined, ['tag1'])) - .returns(() => of(new AssetsDto(0, []))); + it('should load without tags when tag untoggled', () => { + assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue(['tag1']))) + .returns(() => of(new AssetsDto(0, []))).verifiable(); - assetsService.setup(x => x.getAssets(app, 30, 0, undefined, [])) - .returns(() => of(new AssetsDto(0, []))); + assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue([]))) + .returns(() => of(new AssetsDto(0, []))).verifiable(); assetsState.toggleTag('tag1').subscribe(); assetsState.toggleTag('tag1').subscribe(); @@ -102,8 +99,8 @@ describe('AssetsState', () => { }); it('should load with tags when tags selected', () => { - assetsService.setup(x => x.getAssets(app, 30, 0, undefined, ['tag1', 'tag2'])) - .returns(() => of(new AssetsDto(0, []))); + assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue(['tag1', 'tag2']))) + .returns(() => of(new AssetsDto(0, []))).verifiable(); assetsState.selectTags(['tag1', 'tag2']).subscribe(); @@ -111,8 +108,8 @@ describe('AssetsState', () => { }); it('should load without tags when tags reset', () => { - assetsService.setup(x => x.getAssets(app, 30, 0, undefined, [])) - .returns(() => of(new AssetsDto(0, []))); + assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue([]))) + .returns(() => of(new AssetsDto(0, []))).verifiable(); assetsState.resetTags().subscribe(); @@ -120,9 +117,13 @@ describe('AssetsState', () => { }); it('should load next page and prev page when paging', () => { - assetsService.setup(x => x.getAssets(app, 30, 30, undefined, [])) - .returns(() => of(new AssetsDto(200, []))); + assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue([]))) + .returns(() => of(new AssetsDto(200, []))).verifiable(Times.exactly(2)); + assetsService.setup(x => x.getAssets(app, 30, 30, undefined, It.isValue([]))) + .returns(() => of(new AssetsDto(200, []))).verifiable(); + + assetsState.load().subscribe(); assetsState.goNext().subscribe(); assetsState.goPrev().subscribe(); @@ -130,8 +131,8 @@ describe('AssetsState', () => { }); it('should load with query when searching', () => { - assetsService.setup(x => x.getAssets(app, 30, 0, 'my-query', [])) - .returns(() => of(new AssetsDto(0, []))); + assetsService.setup(x => x.getAssets(app, 30, 0, 'my-query', It.isValue([]))) + .returns(() => of(new AssetsDto(0, []))).verifiable(); assetsState.search('my-query').subscribe(); @@ -141,12 +142,9 @@ describe('AssetsState', () => { describe('Updates', () => { beforeEach(() => { - assetsService.setup(x => x.getAssets(app, 30, 0, undefined, [])) + assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue([]))) .returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable(); - assetsService.setup(x => x.getTags(app)) - .returns(() => of({ tag1: 1, shared: 2, tag2: 1 })).verifiable(); - assetsState.load(true).subscribe(); }); @@ -165,7 +163,7 @@ describe('AssetsState', () => { }); it('should update asset when updated', () => { - const update = createAsset(1, ['new'], '-new'); + const update = createAsset(1, ['new'], '_new'); assetsState.update(update); diff --git a/src/Squidex/app/shared/state/rule-events.state.spec.ts b/src/Squidex/app/shared/state/rule-events.state.spec.ts index 764654588..5adcd6f4a 100644 --- a/src/Squidex/app/shared/state/rule-events.state.spec.ts +++ b/src/Squidex/app/shared/state/rule-events.state.spec.ts @@ -76,24 +76,24 @@ describe('RuleEventsState', () => { }); it('should call service when enqueuing event', () => { - rulesService.setup(x => x.enqueueEvent(app, oldRuleEvents[0].id)) + rulesService.setup(x => x.enqueueEvent(app, oldRuleEvents[0])) .returns(() => of({})); ruleEventsState.enqueue(oldRuleEvents[0]).subscribe(); expect().nothing(); - rulesService.verify(x => x.enqueueEvent(app, oldRuleEvents[0].id), Times.once()); + rulesService.verify(x => x.enqueueEvent(app, oldRuleEvents[0]), Times.once()); }); it('should call service when cancelling event', () => { - rulesService.setup(x => x.cancelEvent(app, oldRuleEvents[0].id)) + rulesService.setup(x => x.cancelEvent(app, oldRuleEvents[0])) .returns(() => of({})); ruleEventsState.cancel(oldRuleEvents[0]).subscribe(); expect().nothing(); - rulesService.verify(x => x.cancelEvent(app, oldRuleEvents[0].id), Times.once()); + rulesService.verify(x => x.cancelEvent(app, oldRuleEvents[0]), Times.once()); }); }); \ No newline at end of file diff --git a/src/Squidex/app/shared/state/rule-events.state.ts b/src/Squidex/app/shared/state/rule-events.state.ts index b9bf61996..8f777b4d5 100644 --- a/src/Squidex/app/shared/state/rule-events.state.ts +++ b/src/Squidex/app/shared/state/rule-events.state.ts @@ -82,7 +82,7 @@ export class RuleEventsState extends State { } public enqueue(event: RuleEventDto): Observable { - return this.rulesService.enqueueEvent(this.appsState.appName, event.id).pipe( + return this.rulesService.enqueueEvent(this.appsState.appName, event).pipe( tap(() => { this.dialogs.notifyInfo('Events enqueued. Will be resend in a few seconds.'); }), @@ -90,7 +90,7 @@ export class RuleEventsState extends State { } public cancel(event: RuleEventDto): Observable { - return this.rulesService.cancelEvent(this.appsState.appName, event.id).pipe( + return this.rulesService.cancelEvent(this.appsState.appName, event).pipe( tap(() => { return this.next(s => { const ruleEvents = s.ruleEvents.replaceBy('id', setCancelled(event)); diff --git a/src/Squidex/app/shared/state/rules.state.spec.ts b/src/Squidex/app/shared/state/rules.state.spec.ts index 6b8a8cdd5..90ed19ada 100644 --- a/src/Squidex/app/shared/state/rules.state.spec.ts +++ b/src/Squidex/app/shared/state/rules.state.spec.ts @@ -12,30 +12,27 @@ import { RulesState } from './rules.state'; import { DialogService, - RuleDto, + RulesDto, RulesService, versioned } from '@app/shared/internal'; +import { createRule } from '../services/rules.service.spec'; + import { TestValues } from './_test-helpers'; describe('RulesState', () => { const { app, appsState, - authService, - creation, - creator, - modified, - modifier, newVersion, version } = TestValues; - const oldRules = [ - new RuleDto('id1', creator, creator, creation, creation, version, false, {}, 'trigger1', {}, 'action1'), - new RuleDto('id2', creator, creator, creation, creation, version, true, {}, 'trigger2', {}, 'action2') - ]; + const rule1 = createRule(1); + const rule2 = createRule(2); + + const newRule = createRule(3); let dialogs: IMock; let rulesService: IMock; @@ -45,7 +42,7 @@ describe('RulesState', () => { dialogs = Mock.ofType(); rulesService = Mock.ofType(); - rulesState = new RulesState(appsState.object, authService.object, dialogs.object, rulesService.object); + rulesState = new RulesState(appsState.object, dialogs.object, rulesService.object); }); afterEach(() => { @@ -55,11 +52,11 @@ describe('RulesState', () => { describe('Loading', () => { it('should load rules', () => { rulesService.setup(x => x.getRules(app)) - .returns(() => of(oldRules)).verifiable(); + .returns(() => of(new RulesDto(2, [rule1, rule2]))).verifiable(); rulesState.load().subscribe(); - expect(rulesState.snapshot.rules.values).toEqual(oldRules); + expect(rulesState.snapshot.rules.values).toEqual([rule1, rule2]); expect(rulesState.snapshot.isLoaded).toBeTruthy(); dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); @@ -67,7 +64,7 @@ describe('RulesState', () => { it('should show notification on load when reload is true', () => { rulesService.setup(x => x.getRules(app)) - .returns(() => of(oldRules)).verifiable(); + .returns(() => of(new RulesDto(2, [rule1, rule2]))).verifiable(); rulesState.load(true).subscribe(); @@ -81,89 +78,85 @@ describe('RulesState', () => { describe('Updates', () => { beforeEach(() => { rulesService.setup(x => x.getRules(app)) - .returns(() => of(oldRules)).verifiable(); + .returns(() => of(new RulesDto(2, [rule1, rule2]))).verifiable(); rulesState.load().subscribe(); }); it('should add rule to snapshot when created', () => { - const newRule = new RuleDto('id3', modifier, modifier, modified, modified, version, true, { value: 3 }, 'trigger3', { value: 1 }, 'action3'); - const request = { trigger: { triggerType: 'trigger3', value: 3 }, action: { actionType: 'action3', value: 1 } }; rulesService.setup(x => x.postRule(app, request)) - .returns(() => of(versioned(version, { id: 'id3' }))); + .returns(() => of(newRule)); - rulesState.create(request, modified).subscribe(); + rulesState.create(request).subscribe(); - expect(rulesState.snapshot.rules.values).toEqual([...oldRules, newRule]); + expect(rulesState.snapshot.rules.values).toEqual([rule1, rule2, newRule]); }); - it('should update action and update and user info when updated action', () => { + it('should update rule when updated action', () => { const newAction = {}; - rulesService.setup(x => x.putRule(app, oldRules[0].id, It.isAny(), version)) - .returns(() => of(versioned(newVersion))).verifiable(); + const updated = createRule(1, 'new'); + + rulesService.setup(x => x.putRule(app, rule1, It.isAny(), version)) + .returns(() => of(updated)).verifiable(); - rulesState.updateAction(oldRules[0], newAction, modified).subscribe(); + rulesState.updateAction(rule1, newAction).subscribe(); - const rule_1 = rulesState.snapshot.rules.at(0); + const newRule1 = rulesState.snapshot.rules.at(0); - expect(rule_1.action).toBe(newAction); - expectToBeModified(rule_1); + expect(newRule1).toEqual(updated); }); - it('should update trigger and update and user info when updated trigger', () => { + it('should update rule when updated trigger', () => { const newTrigger = {}; - rulesService.setup(x => x.putRule(app, oldRules[0].id, It.isAny(), version)) - .returns(() => of(versioned(newVersion))).verifiable(); + const updated = createRule(1, 'new'); - rulesState.updateTrigger(oldRules[0], newTrigger, modified).subscribe(); + rulesService.setup(x => x.putRule(app, rule1, It.isAny(), version)) + .returns(() => of(updated)).verifiable(); - const rule_1 = rulesState.snapshot.rules.at(0); + rulesState.updateTrigger(rule1, newTrigger).subscribe(); - expect(rule_1.trigger).toBe(newTrigger); - expectToBeModified(rule_1); + const rule1New = rulesState.snapshot.rules.at(0); + + expect(rule1New).toEqual(updated); }); - it('should mark as enabled and update and user info when enabled', () => { - rulesService.setup(x => x.enableRule(app, oldRules[0].id, version)) - .returns(() => of(versioned(newVersion))).verifiable(); + it('should update rule when enabled', () => { + const updated = createRule(1, 'new'); + + rulesService.setup(x => x.enableRule(app, rule1, version)) + .returns(() => of(updated)).verifiable(); - rulesState.enable(oldRules[0], modified).subscribe(); + rulesState.enable(rule1).subscribe(); - const rule_1 = rulesState.snapshot.rules.at(0); + const rule1New = rulesState.snapshot.rules.at(0); - expect(rule_1.isEnabled).toBeTruthy(); - expectToBeModified(rule_1); + expect(rule1New).toEqual(updated); }); - it('should mark as disabled and update and user info when disabled', () => { - rulesService.setup(x => x.disableRule(app, oldRules[1].id, version)) - .returns(() => of(versioned(newVersion))).verifiable(); + it('should update rule when disabled', () => { + const updated = createRule(1, 'new'); + + rulesService.setup(x => x.disableRule(app, rule1, version)) + .returns(() => of(updated)).verifiable(); - rulesState.disable(oldRules[1], modified).subscribe(); + rulesState.disable(rule1).subscribe(); - const rule_1 = rulesState.snapshot.rules.at(1); + const rule1New = rulesState.snapshot.rules.at(0); - expect(rule_1.isEnabled).toBeFalsy(); - expectToBeModified(rule_1); + expect(rule1New).toEqual(updated); }); it('should remove rule from snapshot when deleted', () => { - rulesService.setup(x => x.deleteRule(app, oldRules[0].id, version)) + rulesService.setup(x => x.deleteRule(app, rule1, version)) .returns(() => of(versioned(newVersion))).verifiable(); - rulesState.delete(oldRules[0]).subscribe(); + rulesState.delete(rule1).subscribe(); - expect(rulesState.snapshot.rules.values).toEqual([oldRules[1]]); + expect(rulesState.snapshot.rules.values).toEqual([rule2]); }); - - function expectToBeModified(rule_1: RuleDto) { - expect(rule_1.lastModified).toEqual(modified); - expect(rule_1.lastModifiedBy).toEqual(modifier); - expect(rule_1.version).toEqual(newVersion); - } }); }); \ No newline at end of file diff --git a/src/Squidex/app/shared/state/rules.state.ts b/src/Squidex/app/shared/state/rules.state.ts index 1a0bcac0f..2412433df 100644 --- a/src/Squidex/app/shared/state/rules.state.ts +++ b/src/Squidex/app/shared/state/rules.state.ts @@ -10,20 +10,16 @@ import { Observable } from 'rxjs'; import { distinctUntilChanged, map, tap } from 'rxjs/operators'; import { - DateTime, DialogService, ImmutableArray, + ResourceLinks, shareSubscribed, - State, - Version, - Versioned + State } from '@app/framework'; -import { AuthService} from './../services/auth.service'; import { AppsState } from './apps.state'; import { - RuleCreatedDto, RuleDto, RulesService, UpsertRuleDto @@ -33,6 +29,9 @@ interface Snapshot { // The current rules. rules: RulesList; + // The resource links. + links: ResourceLinks; + // Indicates if the rules are loaded. isLoaded?: boolean; } @@ -49,13 +48,16 @@ export class RulesState extends State { this.changes.pipe(map(x => !!x.isLoaded), distinctUntilChanged()); + public links = + this.changes.pipe(map(x => x.links), + distinctUntilChanged()); + constructor( private readonly appsState: AppsState, - private readonly authState: AuthService, private readonly dialogs: DialogService, private readonly rulesService: RulesService ) { - super({ rules: ImmutableArray.empty() }); + super({ rules: ImmutableArray.empty(), links: {} }); } public load(isReload = false): Observable { @@ -64,23 +66,22 @@ export class RulesState extends State { } return this.rulesService.getRules(this.appName).pipe( - tap(payload => { + tap(({ items, _links: links }) => { if (isReload) { this.dialogs.notifyInfo('Rules reloaded.'); } this.next(s => { - const rules = ImmutableArray.of(payload); + const rules = ImmutableArray.of(items); - return { ...s, rules, isLoaded: true }; + return { ...s, rules, isLoaded: true, links }; }); }), shareSubscribed(this.dialogs)); } - public create(request: UpsertRuleDto, now?: DateTime): Observable { + public create(request: UpsertRuleDto): Observable { return this.rulesService.postRule(this.appName, request).pipe( - map(payload => createRule(request, payload, this.user, now)), tap(created => { this.next(s => { const rules = s.rules.push(created); @@ -92,7 +93,7 @@ export class RulesState extends State { } public delete(rule: RuleDto): Observable { - return this.rulesService.deleteRule(this.appName, rule.id, rule.version).pipe( + return this.rulesService.deleteRule(this.appName, rule, rule.version).pipe( tap(() => { this.next(s => { const rules = s.rules.removeAll(x => x.id === rule.id); @@ -103,36 +104,32 @@ export class RulesState extends State { shareSubscribed(this.dialogs)); } - public updateAction(rule: RuleDto, action: any, now?: DateTime): Observable { - return this.rulesService.putRule(this.appName, rule.id, { action }, rule.version).pipe( - map(({ version }) => updateAction(rule, action, this.user, version, now)), + public updateAction(rule: RuleDto, action: any): Observable { + return this.rulesService.putRule(this.appName, rule, { action }, rule.version).pipe( tap(updated => { this.replaceRule(updated); }), shareSubscribed(this.dialogs)); } - public updateTrigger(rule: RuleDto, trigger: any, now?: DateTime): Observable { - return this.rulesService.putRule(this.appName, rule.id, { trigger }, rule.version).pipe( - map(({ version }) => updateTrigger(rule, trigger, this.user, version, now)), + public updateTrigger(rule: RuleDto, trigger: any): Observable { + return this.rulesService.putRule(this.appName, rule, { trigger }, rule.version).pipe( tap(updated => { this.replaceRule(updated); }), shareSubscribed(this.dialogs)); } - public enable(rule: RuleDto, now?: DateTime): Observable { - return this.rulesService.enableRule(this.appName, rule.id, rule.version).pipe( - map(({ version }) => setEnabled(rule, true, this.user, version, now)), + public enable(rule: RuleDto): Observable { + return this.rulesService.enableRule(this.appName, rule, rule.version).pipe( tap(updated => { this.replaceRule(updated); }), shareSubscribed(this.dialogs)); } - public disable(rule: RuleDto, now?: DateTime): Observable { - return this.rulesService.disableRule(this.appName, rule.id, rule.version).pipe( - map(({ version }) => setEnabled(rule, false, this.user, version, now)), + public disable(rule: RuleDto): Observable { + return this.rulesService.disableRule(this.appName, rule, rule.version).pipe( tap(updated => { this.replaceRule(updated); }), @@ -150,57 +147,4 @@ export class RulesState extends State { private get appName() { return this.appsState.appName; } - - private get user() { - return this.authState.user!.token; - } -} - -const updateTrigger = (rule: RuleDto, trigger: any, user: string, version: Version, now?: DateTime) => - rule.with({ - trigger, - triggerType: trigger.triggerType, - lastModified: now || DateTime.now(), - lastModifiedBy: user, - version - }); - -const updateAction = (rule: RuleDto, action: any, user: string, version: Version, now?: DateTime) => - rule.with({ - action, - actionType: action.actionType, - lastModified: now || DateTime.now(), - lastModifiedBy: user, - version - }); - -const setEnabled = (rule: RuleDto, isEnabled: boolean, user: string, version: Version, now?: DateTime) => - rule.with({ - isEnabled, - lastModified: now || DateTime.now(), - lastModifiedBy: user, - version - }); - -function createRule(request: UpsertRuleDto, { payload, version }: Versioned, user: string, now?: DateTime) { - now = now || DateTime.now(); - - const { triggerType, ...trigger } = request.trigger; - - const { actionType, ...action } = request.action; - - const rule = new RuleDto( - payload.id, - user, - user, - now, - now, - version, - true, - trigger, - triggerType, - action, - actionType); - - return rule; } \ No newline at end of file diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs index d26a8335c..ee40fbd6e 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs @@ -76,9 +76,9 @@ namespace Squidex.Domain.Apps.Entities.Assets var result = context.Result(); - Assert.Equal(assetId, result.IdOrValue); - Assert.Contains("tag1", result.Tags); - Assert.Contains("tag2", result.Tags); + Assert.Equal(assetId, result.Asset.Id); + Assert.Contains("tag1", result.Asset.Tags); + Assert.Contains("tag2", result.Asset.Tags); AssertAssetHasBeenUploaded(0, context.ContextId); AssertAssetImageChecked(); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleGrainTests.cs index eb2c0ac26..4f3773f9b 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleGrainTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleGrainTests.cs @@ -60,7 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Rules var result = await sut.ExecuteAsync(CreateRuleCommand(command)); - result.ShouldBeEquivalent(EntityCreatedResult.Create(Id, 0)); + result.ShouldBeEquivalent(sut.Snapshot); Assert.Equal(AppId, sut.Snapshot.AppId.Id); @@ -110,7 +110,7 @@ namespace Squidex.Domain.Apps.Entities.Rules var result = await sut.ExecuteAsync(CreateRuleCommand(command)); - result.ShouldBeEquivalent(new EntitySavedResult(2)); + result.ShouldBeEquivalent(sut.Snapshot); Assert.True(sut.Snapshot.RuleDef.IsEnabled); @@ -129,7 +129,7 @@ namespace Squidex.Domain.Apps.Entities.Rules var result = await sut.ExecuteAsync(CreateRuleCommand(command)); - result.ShouldBeEquivalent(new EntitySavedResult(1)); + result.ShouldBeEquivalent(sut.Snapshot); Assert.False(sut.Snapshot.RuleDef.IsEnabled); From 7a641431ca050f34f1c50a8d410b715b2a3e65bf Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 13 Jun 2019 12:40:41 +0100 Subject: [PATCH 07/64] Hateos for Backups. --- src/Squidex.Web/PermissionExtensions.cs | 1 - .../Api/Controllers/Apps/Models/AppDto.cs | 2 +- .../Controllers/Backups/BackupsController.cs | 7 +- .../Backups/Models/BackupJobDto.cs | 22 ++++++- .../Backups/Models/BackupJobsDto.cs | 49 ++++++++++++++ .../Controllers/Rules/Models/RuleEventsDto.cs | 1 - .../pages/backups/backups-page.component.html | 5 +- .../shared/services/backups.service.spec.ts | 31 ++++++--- .../app/shared/services/backups.service.ts | 65 ++++++++++++------- .../app/shared/state/backups.state.spec.ts | 19 +++--- src/Squidex/app/shared/state/backups.state.ts | 16 +++-- 11 files changed, 160 insertions(+), 58 deletions(-) create mode 100644 src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobsDto.cs diff --git a/src/Squidex.Web/PermissionExtensions.cs b/src/Squidex.Web/PermissionExtensions.cs index a6751e9e9..4bda25b58 100644 --- a/src/Squidex.Web/PermissionExtensions.cs +++ b/src/Squidex.Web/PermissionExtensions.cs @@ -7,7 +7,6 @@ using Microsoft.AspNetCore.Http; using Squidex.Infrastructure.Security; -using Squidex.Shared; using Squidex.Shared.Identity; namespace Squidex.Web diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs index dbb2f4ded..a41ad2dd0 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs @@ -112,7 +112,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models if (controller.HasPermission(AllPermissions.AppBackupsRead, result.Name, permissions: permissions)) { - result.AddGetLink("backups", controller.Url(x => nameof(x.GetJobs), values)); + result.AddGetLink("backups", controller.Url(x => nameof(x.GetBackups), values)); } if (controller.HasPermission(AllPermissions.AppClientsRead, result.Name, permissions: permissions)) diff --git a/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs b/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs index 0198fa186..e692bcfba 100644 --- a/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs @@ -12,7 +12,6 @@ using Microsoft.AspNetCore.Mvc; using Orleans; using Squidex.Areas.Api.Controllers.Backups.Models; using Squidex.Domain.Apps.Entities.Backup; -using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Tasks; using Squidex.Shared; @@ -44,16 +43,16 @@ namespace Squidex.Areas.Api.Controllers.Backups /// [HttpGet] [Route("apps/{app}/backups/")] - [ProducesResponseType(typeof(List), 200)] + [ProducesResponseType(typeof(BackupJobsDto), 200)] [ApiPermission(Permissions.AppBackupsRead)] [ApiCosts(0)] - public async Task GetJobs(string app) + public async Task GetBackups(string app) { var backupGrain = grainFactory.GetGrain(AppId); var jobs = await backupGrain.GetStateAsync(); - var response = jobs.Value.ToArray(BackupJobDto.FromBackup); + var response = BackupJobsDto.FromBackups(jobs.Value, this, app); return Ok(response); } diff --git a/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs b/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs index 475f4b059..8cc2fd455 100644 --- a/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs @@ -9,10 +9,12 @@ using System; using NodaTime; using Squidex.Domain.Apps.Entities.Backup; using Squidex.Infrastructure.Reflection; +using Squidex.Shared; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Backups.Models { - public sealed class BackupJobDto + public sealed class BackupJobDto : Resource { /// /// The id of the backup job. @@ -44,9 +46,23 @@ namespace Squidex.Areas.Api.Controllers.Backups.Models /// public JobStatus Status { get; set; } - public static BackupJobDto FromBackup(IBackupJob backup) + public static BackupJobDto FromBackup(IBackupJob backup, ApiController controller, string app) { - return SimpleMapper.Map(backup, new BackupJobDto()); + var result = SimpleMapper.Map(backup, new BackupJobDto()); + + return CreateLinks(result, controller, app); + } + + private static BackupJobDto CreateLinks(BackupJobDto result, ApiController controller, string app) + { + var values = new { app, id = result.Id }; + + if (controller.HasPermission(Permissions.AppBackupsDelete, app)) + { + result.AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteBackup), values)); + } + + return result; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobsDto.cs b/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobsDto.cs new file mode 100644 index 000000000..33c88bc25 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobsDto.cs @@ -0,0 +1,49 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Backups.Models +{ + public sealed class BackupJobsDto : Resource + { + /// + /// The backups. + /// + [Required] + public BackupJobDto[] Items { get; set; } + + public static BackupJobsDto FromBackups(IEnumerable backups, ApiController controller, string app) + { + var result = new BackupJobsDto + { + Items = backups.Select(x => BackupJobDto.FromBackup(x, controller, app)).ToArray() + }; + + return CreateLinks(result, controller, app); + } + + private static BackupJobsDto CreateLinks(BackupJobsDto result, ApiController controller, string app) + { + var values = new { app }; + + result.AddSelfLink(controller.Url(x => nameof(x.GetBackups), values)); + + if (controller.HasPermission(Permissions.AppBackupsCreate, app)) + { + result.AddPostLink("create", controller.Url(x => nameof(x.PostBackup), values)); + } + + return result; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventsDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventsDto.cs index add2af47f..83b6d4c6b 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventsDto.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; diff --git a/src/Squidex/app/features/settings/pages/backups/backups-page.component.html b/src/Squidex/app/features/settings/pages/backups/backups-page.component.html index dddcf8cf6..ca99931ff 100644 --- a/src/Squidex/app/features/settings/pages/backups/backups-page.component.html +++ b/src/Squidex/app/features/settings/pages/backups/backups-page.component.html @@ -12,7 +12,7 @@ - @@ -78,7 +78,8 @@
-
- + - {{userInfo.user.displayName}} + {{user.displayName}} - {{userInfo.user.email}} + {{user.email}} - - - - - - - - + +
- - - - 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 0abdd7eed..ee7498713 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 @@ -29,8 +29,6 @@ export class UserPageComponent extends ResourceOwner implements OnInit { public user?: UserDto; public userForm = new UserForm(this.formBuilder); - public isReadOnly = false; - constructor( public readonly usersState: UsersState, private readonly formBuilder: FormBuilder, @@ -49,9 +47,9 @@ export class UserPageComponent extends ResourceOwner implements OnInit { if (selectedUser) { this.userForm.load(selectedUser); - this.isReadOnly = !hasLink(this.user, 'update'); + this.canUpdate = hasLink(this.user, 'update'); - if (this.isReadOnly) { + if (!this.canUpdate) { this.userForm.form.disable(); } } @@ -59,7 +57,7 @@ export class UserPageComponent extends ResourceOwner implements OnInit { } public save() { - if (this.isReadOnly) { + if (!this.canUpdate) { return; } diff --git a/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts b/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts index ba384f5d1..2a4c32c14 100644 --- a/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts +++ b/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts @@ -79,7 +79,7 @@ describe('EventConsumersState', () => { eventConsumersState.load().subscribe(); }); - it('should update evnet consumer when started', () => { + it('should update event consumer when started', () => { const updated = createEventConsumer(2, '_new'); eventConsumersService.setup(x => x.putStart(eventConsumer2)) 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 3000bf6f6..279ff67c2 100644 --- a/src/Squidex/app/features/administration/state/users.state.spec.ts +++ b/src/Squidex/app/features/administration/state/users.state.spec.ts @@ -168,7 +168,7 @@ describe('UsersState', () => { expect(usersState.snapshot.selectedUser).toBeNull(); }); - it('should update user selected user when locked', () => { + it('should update user and selected user when locked', () => { const updated = createUser(2, '_new'); usersService.setup(x => x.lockUser(user2)) @@ -177,9 +177,9 @@ describe('UsersState', () => { usersState.select(user2.id).subscribe(); usersState.lock(user2).subscribe(); - const newUser2 = usersState.snapshot.users.at(1); + const user2New = usersState.snapshot.users.at(1); - expect(newUser2).toBe(usersState.snapshot.selectedUser!); + expect(user2New).toBe(usersState.snapshot.selectedUser!); }); it('should update user and selected user when unlocked', () => { @@ -191,10 +191,10 @@ describe('UsersState', () => { usersState.select(user2.id).subscribe(); usersState.unlock(user2).subscribe(); - const newUser2 = usersState.snapshot.users.at(1); + const user2New = usersState.snapshot.users.at(1); - expect(newUser2).toEqual(updated); - expect(newUser2).toBe(usersState.snapshot.selectedUser!); + expect(user2New).toEqual(updated); + expect(user2New).toBe(usersState.snapshot.selectedUser!); }); it('should update user and selected user when updated', () => { @@ -208,10 +208,10 @@ describe('UsersState', () => { usersState.select(user2.id).subscribe(); usersState.update(user2, request).subscribe(); - const newUser2 = usersState.snapshot.users.at(1); + const user2New = usersState.snapshot.users.at(1); - expect(newUser2).toEqual(updated); - expect(newUser2).toBe(usersState.snapshot.selectedUser!); + expect(user2New).toEqual(updated); + expect(user2New).toBe(usersState.snapshot.selectedUser!); }); it('should add user to snapshot when created', () => { diff --git a/src/Squidex/app/features/administration/state/users.state.ts b/src/Squidex/app/features/administration/state/users.state.ts index 04a4bc80a..d638c5e0c 100644 --- a/src/Squidex/app/features/administration/state/users.state.ts +++ b/src/Squidex/app/features/administration/state/users.state.ts @@ -60,10 +60,6 @@ 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()); @@ -72,6 +68,10 @@ export class UsersState extends State { this.changes.pipe(map(x => !!x.isLoaded), distinctUntilChanged()); + public links = + this.changes.pipe(map(x => x.links), + distinctUntilChanged()); + constructor( private readonly dialogs: DialogService, private readonly usersService: UsersService @@ -131,7 +131,7 @@ export class UsersState extends State { selectedUser = users.find(x => x.id === selectedUser!.id) || selectedUser; } - return { ...s, users, usersPager, links, selectedUser, isLoaded: true }; + return { ...s, users, usersPager, selectedUser, isLoaded: true, links }; }); }), shareSubscribed(this.dialogs)); diff --git a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html index 422b0a98f..ecb11c85b 100644 --- a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html +++ b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html @@ -106,12 +106,12 @@ - + - + diff --git a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.ts b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.ts index c9b1a77a2..fd7dcd668 100644 --- a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.ts +++ b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.ts @@ -5,11 +5,12 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { AfterViewInit, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { Form, + hasLink, ImmutableArray, RuleDto, RuleElementDto, @@ -26,7 +27,7 @@ export const MODE_EDIT_ACTION = 'EditAction'; styleUrls: ['./rule-wizard.component.scss'], templateUrl: './rule-wizard.component.html' }) -export class RuleWizardComponent implements OnInit { +export class RuleWizardComponent implements AfterViewInit, OnInit { public actionForm = new Form(new FormGroup({})); public actionType: string; public action: any = {}; @@ -35,6 +36,8 @@ export class RuleWizardComponent implements OnInit { public triggerType: string; public trigger: any = {}; + public canUpdate: boolean; + public step = 1; @Output() @@ -61,6 +64,8 @@ export class RuleWizardComponent implements OnInit { } public ngOnInit() { + this.canUpdate = !this.rule || hasLink(this.rule, 'update'); + if (this.mode === MODE_EDIT_ACTION) { this.step = 4; @@ -74,6 +79,14 @@ export class RuleWizardComponent implements OnInit { } } + public ngAfterViewInit() { + if (!this.canUpdate) { + this.actionForm.form.disable(); + + this.triggerForm.form.disable(); + } + } + public emitComplete() { this.complete.emit(); } @@ -132,6 +145,10 @@ export class RuleWizardComponent implements OnInit { } private updateTrigger() { + if (!this.canUpdate) { + return; + } + this.rulesState.updateTrigger(this.rule, this.trigger) .subscribe(() => { this.emitComplete(); @@ -143,6 +160,10 @@ export class RuleWizardComponent implements OnInit { } private updateAction() { + if (!this.canUpdate) { + return; + } + this.rulesState.updateAction(this.rule, this.action) .subscribe(() => { this.emitComplete(); diff --git a/src/Squidex/app/features/rules/pages/rules/rules-page.component.html b/src/Squidex/app/features/rules/pages/rules/rules-page.component.html index d6102c1be..0e97c61f2 100644 --- a/src/Squidex/app/features/rules/pages/rules/rules-page.component.html +++ b/src/Squidex/app/features/rules/pages/rules/rules-page.component.html @@ -6,16 +6,19 @@ - - - + + + + + @@ -23,7 +26,7 @@
No rule created yet. -
@@ -48,10 +51,11 @@
- + -
- + @@ -47,7 +50,7 @@
- + - {{contributorInfo.contributor.contributorId | sqxUserName}} + {{contributor.contributorId | sqxUserName}} - -
-