Browse Source

Roles management in UI.

pull/332/head
Sebastian Stehle 7 years ago
parent
commit
67cd161f21
  1. 26
      src/Squidex.Domain.Apps.Entities/Apps/RoleExtensions.cs
  2. 17
      src/Squidex.Domain.Apps.Entities/Apps/RolePermissionsProvider.cs
  3. 27
      src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs
  4. 7
      src/Squidex/Areas/Api/Controllers/Apps/Models/RoleDto.cs
  5. 11
      src/Squidex/Areas/Api/Controllers/Apps/Models/RolesDto.cs
  6. 5
      src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs
  7. 3
      src/Squidex/Config/Domain/EntitiesServices.cs
  8. 8
      src/Squidex/app/features/settings/declarations.ts
  9. 24
      src/Squidex/app/features/settings/module.ts
  10. 2
      src/Squidex/app/features/settings/pages/clients/client.component.html
  11. 4
      src/Squidex/app/features/settings/pages/clients/client.component.ts
  12. 7
      src/Squidex/app/features/settings/pages/clients/clients-page.component.html
  13. 8
      src/Squidex/app/features/settings/pages/clients/clients-page.component.ts
  14. 4
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html
  15. 6
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts
  16. 8
      src/Squidex/app/features/settings/pages/languages/language.component.html
  17. 2
      src/Squidex/app/features/settings/pages/languages/language.component.scss
  18. 54
      src/Squidex/app/features/settings/pages/roles/role.component.html
  19. 22
      src/Squidex/app/features/settings/pages/roles/role.component.scss
  20. 95
      src/Squidex/app/features/settings/pages/roles/role.component.ts
  21. 50
      src/Squidex/app/features/settings/pages/roles/roles-page.component.html
  22. 2
      src/Squidex/app/features/settings/pages/roles/roles-page.component.scss
  23. 81
      src/Squidex/app/features/settings/pages/roles/roles-page.component.ts
  24. 6
      src/Squidex/app/features/settings/settings-area.component.html
  25. 8
      src/Squidex/app/framework/angular/forms/autocomplete.component.ts
  26. 3
      src/Squidex/app/shared/internal.ts
  27. 4
      src/Squidex/app/shared/module.ts
  28. 4
      src/Squidex/app/shared/services/app-patterns.service.spec.ts
  29. 15
      src/Squidex/app/shared/services/app-patterns.service.ts
  30. 145
      src/Squidex/app/shared/services/app-roles.service.spec.ts
  31. 129
      src/Squidex/app/shared/services/app-roles.service.ts
  32. 52
      src/Squidex/app/shared/state/roles.forms.ts
  33. 108
      src/Squidex/app/shared/state/roles.state.spec.ts
  34. 121
      src/Squidex/app/shared/state/roles.state.ts
  35. 29
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/RoleExtensionsRests.cs

26
src/Squidex.Domain.Apps.Entities/Apps/RoleExtensions.cs

@ -5,6 +5,9 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Linq;
using Squidex.Infrastructure.Security;
using Squidex.Shared;
namespace Squidex.Domain.Apps.Entities.Apps
@ -31,5 +34,28 @@ namespace Squidex.Domain.Apps.Entities.Apps
return permissions;
}
public static PermissionSet WithoutApp(this PermissionSet set, string name)
{
var prefix = Permissions.ForApp(Permissions.App, name).Id;
return new PermissionSet(set.Select(x =>
{
var id = x.Id;
if (string.Equals(id, prefix, StringComparison.OrdinalIgnoreCase))
{
return Permission.Any;
}
else if (id.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return id.Substring(prefix.Length + 1);
}
else
{
return id;
}
}));
}
}
}

17
src/Squidex.Domain.Apps.Entities/Apps/RolePermissionsProvider.cs

@ -27,10 +27,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
public async Task<List<string>> GetPermissionsAsync(IAppEntity app)
{
var schemas = await appProvider.GetSchemasAsync(app.Id);
var schemaNames = schemas.Select(x => x.Name).ToList();
schemaNames.Insert(0, Permission.Any);
var schemaNames = await GetSchemaNamesAsync(app);
var result = new List<string> { Permission.Any };
@ -61,5 +58,17 @@ namespace Squidex.Domain.Apps.Entities.Apps
return result;
}
private async Task<List<string>> GetSchemaNamesAsync(IAppEntity app)
{
var schemas = await appProvider.GetSchemasAsync(app.Id);
var schemaNames = new List<string>(); ;
schemaNames.Add(Permission.Any);
schemaNames.AddRange(schemas.Select(x => x.Name));
return schemaNames;
}
}
}

27
src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs

@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Mvc;
using Squidex.Areas.Api.Controllers.Apps.Models;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Pipeline;
using Squidex.Shared;
@ -43,15 +44,37 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ProducesResponseType(typeof(RolesDto), 200)]
[ApiPermission(Permissions.AppRolesRead)]
[ApiCosts(0)]
public async Task<IActionResult> GetRoles(string app)
public IActionResult GetRoles(string app)
{
var response = RolesDto.FromApp(App, await permissionsProvider.GetPermissionsAsync(App));
var response = RolesDto.FromApp(App);
Response.Headers["ETag"] = App.Version.ToString();
return Ok(response);
}
/// <summary>
/// Get app permissions.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <returns>
/// 200 => App permissions returned.
/// 404 => App not found.
/// </returns>
[HttpGet]
[Route("apps/{app}/roles/permissions")]
[ProducesResponseType(typeof(string[]), 200)]
[ApiPermission(Permissions.AppRolesRead)]
[ApiCosts(0)]
public async Task<IActionResult> GetPermissions(string app)
{
var response = await permissionsProvider.GetPermissionsAsync(App);
Response.Headers["ETag"] = string.Join(";", response).Sha256Base64();
return Ok(response);
}
/// <summary>
/// Add role to app.
/// </summary>

7
src/Squidex/Areas/Api/Controllers/Apps/Models/RoleDto.cs

@ -8,6 +8,7 @@
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Entities.Apps;
namespace Squidex.Areas.Api.Controllers.Apps.Models
{
@ -25,9 +26,11 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
[Required]
public string[] Permissions { get; set; }
public static RoleDto FromRole(Role role)
public static RoleDto FromRole(Role role, string appName)
{
return new RoleDto { Name = role.Name, Permissions = role.Permissions.Select(x => x.Id).ToArray() };
var permissions = role.Permissions.WithoutApp(appName);
return new RoleDto { Name = role.Name, Permissions = permissions.ToIds().ToArray() };
}
}
}

11
src/Squidex/Areas/Api/Controllers/Apps/Models/RolesDto.cs

@ -20,16 +20,11 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
[Required]
public RoleDto[] Roles { get; set; }
/// <summary>
/// Suggested permissions.
/// </summary>
public string[] AllPermissions { get; set; }
public static RolesDto FromApp(IAppEntity app, List<string> permissions)
public static RolesDto FromApp(IAppEntity app)
{
var roles = app.Roles.Values.Select(RoleDto.FromRole).ToArray();
var roles = app.Roles.Values.Select(x => RoleDto.FromRole(x, app.Name)).ToArray();
return new RolesDto { Roles = roles, AllPermissions = permissions.ToList() };
return new RolesDto { Roles = roles };
}
}
}

5
src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs

@ -17,6 +17,7 @@ using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Rules.Commands;
using Squidex.Domain.Apps.Entities.Rules.Repositories;
using Squidex.Extensions.Actions;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Reflection;
using Squidex.Pipeline;
@ -30,8 +31,8 @@ namespace Squidex.Areas.Api.Controllers.Rules
[ApiExplorerSettings(GroupName = nameof(Rules))]
public sealed class RulesController : ApiController
{
private static readonly string RuleActionsEtag = string.Join(";", RuleElementRegistry.Actions.Select(x => x.Key)).Sha256();
private static readonly string RuleTriggersEtag = string.Join(";", RuleElementRegistry.Triggers.Select(x => x.Key)).Sha256();
private static readonly string RuleActionsEtag = string.Join(";", RuleElementRegistry.Actions.Select(x => x.Key)).Sha256Base64();
private static readonly string RuleTriggersEtag = string.Join(";", RuleElementRegistry.Triggers.Select(x => x.Key)).Sha256Base64();
private readonly IAppProvider appProvider;
private readonly IRuleEventRepository ruleEventsRepository;

3
src/Squidex/Config/Domain/EntitiesServices.cs

@ -91,6 +91,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<SchemaHistoryEventsCreator>()
.As<IHistoryEventsCreator>();
services.AddSingletonAs<RolePermissionsProvider>()
.AsSelf();
services.AddSingletonAs<EdmModelBuilder>()
.AsSelf();

8
src/Squidex/app/features/settings/declarations.ts

@ -7,20 +7,16 @@
export * from './pages/backups/backups-page.component';
export * from './pages/backups/pipes';
export * from './pages/clients/client.component';
export * from './pages/clients/clients-page.component';
export * from './pages/contributors/contributors-page.component';
export * from './pages/languages/language.component';
export * from './pages/languages/languages-page.component';
export * from './pages/more/more-page.component';
export * from './pages/patterns/pattern.component';
export * from './pages/patterns/patterns-page.component';
export * from './pages/plans/plans-page.component';
export * from './pages/roles/role.component';
export * from './pages/roles/roles-page.component';
export * from './settings-area.component';

24
src/Squidex/app/features/settings/module.ts

@ -28,6 +28,8 @@ import {
PatternComponent,
PatternsPageComponent,
PlansPageComponent,
RoleComponent,
RolesPageComponent,
SettingsAreaComponent
} from './declarations';
@ -129,6 +131,26 @@ const routes: Routes = [
}
]
},
{
path: 'roles',
component: RolesPageComponent,
children: [
{
path: 'history',
component: HistoryComponent,
data: {
channel: 'settings.roles'
}
},
{
path: 'help',
component: HelpComponent,
data: {
helpPage: '05-integrated/roles'
}
}
]
},
{
path: 'languages',
component: LanguagesPageComponent,
@ -172,6 +194,8 @@ const routes: Routes = [
PatternComponent,
PatternsPageComponent,
PlansPageComponent,
RoleComponent,
RolesPageComponent,
SettingsAreaComponent
]
})

2
src/Squidex/app/features/settings/pages/clients/client.component.html

@ -71,7 +71,7 @@
</div>
<div class="col">
<select class="form-control" [ngModel]="client.role" (ngModelChange)="update($event)">
<option *ngFor="let role of clientRoles" [ngValue]="role">{{role}}</option>
<option *ngFor="let role of clientRoles" [ngValue]="role.name">{{role.name}}</option>
</select>
</div>
<div class="col col-auto cell-actions">

4
src/Squidex/app/features/settings/pages/clients/client.component.ts

@ -13,6 +13,7 @@ import {
AccessTokenDto,
AppClientDto,
AppClientsService,
AppRoleDto,
AppsState,
ClientsState,
DialogModel,
@ -32,7 +33,8 @@ export class ClientComponent implements OnChanges {
@Input()
public client: AppClientDto;
public clientRoles = [ 'Owner', 'Developer', 'Editor', 'Reader' ];
@Input()
public clientRoles: AppRoleDto[];
public isRenaming = false;

7
src/Squidex/app/features/settings/pages/clients/clients-page.component.html

@ -19,7 +19,12 @@
No client created yet.
</div>
<sqx-client *ngFor="let client of clients; trackBy: trackByClient" [client]="client"></sqx-client>
<ng-container *ngIf="rolesState.roles | async; let roles">
<sqx-client *ngFor="let client of clients; trackBy: trackByClient"
[client]="client"
[clientRoles]="roles">
</sqx-client>
</ng-container>
<div class="table-items-footer">
<form [formGroup]="addClientForm.form" (ngSubmit)="attachClient()">

8
src/Squidex/app/features/settings/pages/clients/clients-page.component.ts

@ -14,7 +14,8 @@ import {
AppsState,
AttachClientForm,
ClientsState,
CreateAppClientDto
CreateAppClientDto,
RolesState
} from '@app/shared';
@Component({
@ -28,11 +29,14 @@ export class ClientsPageComponent implements OnInit {
constructor(
public readonly appsState: AppsState,
public readonly clientsState: ClientsState,
public readonly rolesState: RolesState,
private readonly formBuilder: FormBuilder
) {
}
public ngOnInit() {
this.rolesState.load().pipe(onErrorResumeNext()).subscribe();
this.clientsState.load().pipe(onErrorResumeNext()).subscribe();
}
@ -46,7 +50,7 @@ export class ClientsPageComponent implements OnInit {
if (value) {
const requestDto = new CreateAppClientDto(value.name);
this.clientsState.attach(requestDto).pipe(onErrorResumeNext())
this.clientsState.attach(requestDto)
.subscribe(() => {
this.addClientForm.submitCompleted();
}, error => {

4
src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html

@ -22,7 +22,7 @@
</ng-container>
<ng-container *ngIf="contributorsState.contributors | async; let contributors">
<table class="table table-items table-fixed">
<table class="table table-items table-fixed" *ngIf="rolesState.roles | async; let roles">
<tbody *ngFor="let contributorInfo of contributors; trackBy: trackByContributor">
<tr>
<td class="cell-user">
@ -33,7 +33,7 @@
</td>
<td class="cell-time">
<select class="form-control" [ngModel]="contributorInfo.contributor.role" (ngModelChange)="changeRole(contributorInfo.contributor, $event)" [disabled]="contributorInfo.isCurrentUser">
<option *ngFor="let role of usersRoles" [ngValue]="role">{{role}}</option>
<option *ngFor="let role of roles" [ngValue]="role.name">{{role.name}}</option>
</select>
</td>
<td class="cell-actions">

6
src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts

@ -16,6 +16,7 @@ import {
AssignContributorForm,
AutocompleteSource,
ContributorsState,
RolesState,
Types,
UserDto,
UsersService
@ -53,19 +54,20 @@ export class UsersDataSource implements AutocompleteSource {
]
})
export class ContributorsPageComponent implements OnInit {
public usersRoles = [ 'Owner', 'Developer', 'Editor', 'Reader' ];
public assignContributorForm = new AssignContributorForm(this.formBuilder);
constructor(
public readonly appsState: AppsState,
public readonly contributorsState: ContributorsState,
public readonly rolesState: RolesState,
public readonly usersDataSource: UsersDataSource,
private readonly formBuilder: FormBuilder
) {
}
public ngOnInit() {
this.rolesState.load().pipe(onErrorResumeNext()).subscribe();
this.contributorsState.load().pipe(onErrorResumeNext()).subscribe();
}

8
src/Squidex/app/features/settings/pages/languages/language.component.html

@ -1,11 +1,11 @@
<div class="table-items-row table-items-row-expandable language">
<div class="table-items-row-summary">
<div class="row">
<div class="col col-2">
<span class="language-code" [class.language-optional]="language.isOptional" [class.language-master]="language.isMaster">{{language.iso2Code}}</span>
<div class="col col-2" [class.language-optional]="language.isOptional" [class.language-master]="language.isMaster">
{{language.iso2Code}}
</div>
<div class="col col-6">
<span class="language-name table-cell" [class.language-optional]="language.isOptional" [class.language-master]="language.isMaster">{{language.englishName}}</span>
<div class="col col-6" [class.language-optional]="language.isOptional" [class.language-master]="language.isMaster">
{{language.englishName}}
</div>
<div class="col col-options">
<div class="float-right">

2
src/Squidex/app/features/settings/pages/languages/language.component.scss

@ -47,6 +47,6 @@ $field-header: #e7ebef;
.table-items-row-details {
&::before {
right: 4.6rem;
right: 4.4rem;
}
}

54
src/Squidex/app/features/settings/pages/roles/role.component.html

@ -0,0 +1,54 @@
<div class="table-items-row table-items-row-expandable">
<div class="table-items-row-summary">
<div class="row">
<div class="col" [class.built]="isDefaultRole">
{{role.name}}
</div>
<div class="col col-options">
<div class="float-right">
<button type="button" class="btn btn-secondary table-items-edit-button" [class.active]="isEditing" (click)="toggleEditing()">
<i class="icon-settings"></i>
</button>
<button type="button" class="btn btn-link btn-danger" [class.invisible]="isDefaultRole"
(sqxConfirmClick)="remove()"
confirmTitle="Delete role"
confirmText="Do you really want to delete the language?">
<i class="icon-bin2"></i>
</button>
</div>
</div>
</div>
</div>
<div class="table-items-row-details" *ngIf="isEditing">
<form [formGroup]="editForm.form" (ngSubmit)="save()">
<div class="table-items-row-details-tabs clearfix">
<div class="float-right">
<button type="reset" class="btn btn-link" (click)="toggleEditing()">Cancel</button>
<button type="submit" class="btn btn-primary" *ngIf="!isDefaultRole">Save</button>
</div>
</div>
<div class="table-items-row-details-tab">
<div class="form-group row no-gutters" *ngFor="let control of editForm.form.controls; let i = index">
<div class="col">
<sqx-control-errors [for]="control" [fieldName]="'Permission'" [submitted]="editForm.submitted | async"></sqx-control-errors>
<sqx-autocomplete [formControl]="control" [source]="allPermissions"></sqx-autocomplete>
</div>
<div class="col col-auto" *ngIf="!isDefaultRole">
<button type="button" class="btn btn-link btn-danger" (click)="removePermission(i)">
<i class="icon-bin2"></i>
</button>
</div>
</div>
<div class="form-group" *ngIf="!isDefaultRole">
<button type="button" class="btn btn-success btn-sm" (click)="addPermission()">
Add Permission
</button>
</div>
</div>
</form>
</div>
</div>

22
src/Squidex/app/features/settings/pages/roles/role.component.scss

@ -0,0 +1,22 @@
@import '_vars';
@import '_mixins';
.form-group {
& {
margin-bottom: .375rem;
}
&:last-child {
margin: 0;
}
}
.built {
font-weight: bold;
}
.table-items-row-details {
&::before {
right: 4.4rem;
}
}

95
src/Squidex/app/features/settings/pages/roles/role.component.ts

@ -0,0 +1,95 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, Input, OnChanges } from '@angular/core';
import { onErrorResumeNext } from 'rxjs/operators';
import {
AppRoleDto,
AutocompleteSource,
EditPermissionsForm,
fadeAnimation,
RolesState,
UpdateAppRoleDto
} from '@app/shared';
const DEFAULT_ROLES = [
'Owner',
'Developer',
'Editor',
'Reader'
];
@Component({
selector: 'sqx-role',
styleUrls: ['./role.component.scss'],
templateUrl: './role.component.html',
animations: [
fadeAnimation
]
})
export class RoleComponent implements OnChanges {
@Input()
public role: AppRoleDto;
@Input()
public allPermissions: AutocompleteSource;
public isEditing = false;
public isDefaultRole = false;
public editForm = new EditPermissionsForm();
constructor(
private readonly rolesState: RolesState
) {
}
public ngOnChanges() {
this.isDefaultRole = DEFAULT_ROLES.indexOf(this.role.name) >= 0;
this.editForm.load(this.role.permissions);
if (this.isDefaultRole) {
this.editForm.form.disable();
}
}
public toggleEditing() {
this.isEditing = !this.isEditing;
}
public addPermission() {
this.editForm.add();
}
public removePermission(index: number) {
this.editForm.remove(index);
}
public remove() {
this.rolesState.delete(this.role).pipe(onErrorResumeNext()).subscribe();
}
public save() {
const value = this.editForm.submit();
if (value) {
const request = new UpdateAppRoleDto(value);
this.rolesState.update(this.role, request)
.subscribe(() => {
this.editForm.submitCompleted();
this.toggleEditing();
}, error => {
this.editForm.submitFailed(error);
});
}
}
}

50
src/Squidex/app/features/settings/pages/roles/roles-page.component.html

@ -0,0 +1,50 @@
<sqx-title message="{app} | Roles | Settings" parameter1="app" [value1]="appsState.appName"></sqx-title>
<sqx-panel desiredWidth="50rem" [showSidebar]="true">
<ng-container title>
Roles
</ng-container>
<ng-container menu>
<button class="btn btn-link btn-secondary" (click)="reload()" title="Refresh roles (CTRL + SHIFT + R)">
<i class="icon-reset"></i> Refresh
</button>
<sqx-shortcut keys="ctrl+shift+r" (trigger)="reload()"></sqx-shortcut>
</ng-container>
<ng-container content>
<ng-container *ngIf="rolesState.isLoaded | async">
<sqx-role *ngFor="let role of rolesState.roles | async; trackBy: trackByRole" [role]="role" [allPermissions]="allPermissions"></sqx-role>
<div class="table-items-footer">
<form [formGroup]="addRoleForm.form" (ngSubmit)="addRole()">
<div class="row no-gutters">
<div class="col">
<sqx-control-errors for="name" [submitted]="addRoleForm.submitted | async"></sqx-control-errors>
<input type="text" class="form-control" formControlName="name" maxlength="40" placeholder="Enter role name" autocomplete="off" />
</div>
<div class="col col-auto pl-1">
<button type="submit" class="btn btn-success" [disabled]="addRoleForm.hasNoName | async">Add role</button>
</div>
<div class="col col-auto pl-1">
<button type="reset" class="btn btn-secondary" (click)="cancelAddRole()">Cancel</button>
</div>
</div>
</form>
</div>
</ng-container>
</ng-container>
<ng-container sidebar>
<a class="panel-link" routerLink="history" routerLinkActive="active">
<i class="icon-time"></i>
</a>
<a class="panel-link" routerLink="help" routerLinkActive="active">
<i class="icon-help"></i>
</a>
</ng-container>
</sqx-panel>
<router-outlet></router-outlet>

2
src/Squidex/app/features/settings/pages/roles/roles-page.component.scss

@ -0,0 +1,2 @@
@import '_vars';
@import '_mixins';

81
src/Squidex/app/features/settings/pages/roles/roles-page.component.ts

@ -0,0 +1,81 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { Observable, of } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import {
AddRoleForm,
AppRoleDto,
AppRolesService,
AppsState,
AutocompleteSource,
RolesState
} from '@app/shared';
class PermissionsAutocomplete implements AutocompleteSource {
private permissions: string[] = [];
constructor(appsState: AppsState, rolesService: AppRolesService) {
rolesService.getPermissions(appsState.appName).subscribe(x => this.permissions = x);
}
public find(query: string): Observable<any[]> {
return of(this.permissions.filter(y => y.indexOf(query) === 0));
}
}
@Component({
selector: 'sqx-roles-page',
styleUrls: ['./roles-page.component.scss'],
templateUrl: './roles-page.component.html'
})
export class RolesPageComponent implements OnInit {
public addRoleForm = new AddRoleForm(this.formBuilder);
public allPermissions: AutocompleteSource = new PermissionsAutocomplete(this.appsState, this.rolesService);
constructor(
public readonly appsState: AppsState,
public readonly rolesService: AppRolesService,
public readonly rolesState: RolesState,
private readonly formBuilder: FormBuilder
) {
}
public ngOnInit() {
this.rolesState.load().pipe(onErrorResumeNext()).subscribe();
}
public reload() {
this.rolesState.load(true).pipe(onErrorResumeNext()).subscribe();
}
public cancelAddRole() {
this.addRoleForm.submitCompleted();
}
public addRole() {
const value = this.addRoleForm.submit();
if (value) {
this.rolesState.add(value)
.subscribe(() => {
this.addRoleForm.submitCompleted();
}, error => {
this.addRoleForm.submitFailed(error);
});
}
}
public trackByRole(index: number, role: AppRoleDto) {
return role.name;
}
}

6
src/Squidex/app/features/settings/settings-area.component.html

@ -25,6 +25,12 @@
<i class="icon-angle-right"></i>
</a>
</li>
<li class="nav-item" *sqxPermission="'squidex.apps.{app}.roles.read'">
<a class="nav-link" routerLink="roles" routerLinkActive="active">
Roles
<i class="icon-angle-right"></i>
</a>
</li>
<li class="nav-item" *sqxPermission="'squidex.apps.{app}.languages.read'">
<a class="nav-link" routerLink="languages" routerLinkActive="active">
Languages

8
src/Squidex/app/framework/angular/forms/autocomplete.component.ts

@ -109,10 +109,10 @@ export class AutocompleteComponent implements ControlValueAccessor, OnDestroy, O
if (!obj) {
this.resetForm();
} else {
const item = this.suggestedItems.find(i => i === obj);
if (item) {
this.queryInput.setValue(obj.title || '');
if (this.displayProperty && this.displayProperty.length > 0) {
this.queryInput.setValue(obj[this.displayProperty]);
} else {
this.queryInput.setValue(obj.toString());
}
}

3
src/Squidex/app/shared/internal.ts

@ -23,6 +23,7 @@ export * from './services/app-contributors.service';
export * from './services/app-clients.service';
export * from './services/app-languages.service';
export * from './services/app-patterns.service';
export * from './services/app-roles.service';
export * from './services/apps.service';
export * from './services/assets.service';
export * from './services/auth.service';
@ -62,6 +63,8 @@ export * from './state/patterns.forms';
export * from './state/patterns.state';
export * from './state/plans.state';
export * from './state/queries';
export * from './state/roles.forms';
export * from './state/roles.state';
export * from './state/rule-events.state';
export * from './state/rules.state';
export * from './state/schemas.forms';

4
src/Squidex/app/shared/module.ts

@ -19,6 +19,7 @@ import {
AppLanguagesService,
AppMustExistGuard,
AppPatternsService,
AppRolesService,
AppsService,
AppsState,
AssetComponent,
@ -63,6 +64,7 @@ import {
PlansService,
PlansState,
RichEditorComponent,
RolesState,
RuleEventsState,
RulesService,
RulesState,
@ -165,6 +167,7 @@ export class SqxSharedModule {
AppLanguagesService,
AppMustExistGuard,
AppPatternsService,
AppRolesService,
AppsService,
AppsState,
AssetsState,
@ -190,6 +193,7 @@ export class SqxSharedModule {
PatternsState,
PlansService,
PlansState,
RolesState,
RuleEventsState,
RulesService,
RulesState,

4
src/Squidex/app/shared/services/app-patterns.service.spec.ts

@ -9,6 +9,7 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/
import { inject, TestBed } from '@angular/core/testing';
import {
AnalyticsService,
ApiUrlConfig,
AppPatternDto,
AppPatternsDto,
@ -27,7 +28,8 @@ describe('AppPatternsService', () => {
],
providers: [
AppPatternsService,
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') }
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') },
{ provide: AnalyticsService, useValue: new AnalyticsService() }
]
});
});

15
src/Squidex/app/shared/services/app-patterns.service.ts

@ -8,9 +8,10 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { map, tap } from 'rxjs/operators';
import {
AnalyticsService,
ApiUrlConfig,
HTTP,
Model,
@ -53,7 +54,8 @@ export class EditAppPatternDto {
export class AppPatternsService {
constructor(
private readonly http: HttpClient,
private readonly apiUrl: ApiUrlConfig
private readonly apiUrl: ApiUrlConfig,
private readonly analytics: AnalyticsService
) {
}
@ -94,6 +96,9 @@ export class AppPatternsService {
return new Versioned(response.version, pattern);
}),
tap(() => {
this.analytics.trackEvent('Patterns', 'Created', appName);
}),
pretifyError('Failed to add pattern. Please reload.'));
}
@ -101,6 +106,9 @@ export class AppPatternsService {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/patterns/${id}`);
return HTTP.putVersioned(this.http, url, dto, version).pipe(
tap(() => {
this.analytics.trackEvent('Patterns', 'Updated', appName);
}),
pretifyError('Failed to update pattern. Please reload.'));
}
@ -108,6 +116,9 @@ export class AppPatternsService {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/patterns/${id}`);
return HTTP.deleteVersioned(this.http, url, version).pipe(
tap(() => {
this.analytics.trackEvent('Patterns', 'Configured', appName);
}),
pretifyError('Failed to remove pattern. Please reload.'));
}
}

145
src/Squidex/app/shared/services/app-roles.service.spec.ts

@ -0,0 +1,145 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing';
import {
AnalyticsService,
ApiUrlConfig,
AppRoleDto,
AppRolesDto,
AppRolesService,
UpdateAppRoleDto,
Version
} from './../';
import { CreateAppRoleDto } from './app-roles.service';
describe('AppRolesService', () => {
const version = new Version('1');
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
HttpClientTestingModule
],
providers: [
AppRolesService,
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') },
{ provide: AnalyticsService, useValue: new AnalyticsService() }
]
});
});
afterEach(inject([HttpTestingController], (httpMock: HttpTestingController) => {
httpMock.verify();
}));
it('should make get request to get all permissions',
inject([AppRolesService, HttpTestingController], (roleService: AppRolesService, httpMock: HttpTestingController) => {
let permissions: string[];
roleService.getPermissions('my-app').subscribe(result => {
permissions = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/roles/permissions');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush(['P1', 'P2']);
expect(permissions!).toEqual(['P1', 'P2']);
}));
it('should make get request to get roles',
inject([AppRolesService, HttpTestingController], (roleService: AppRolesService, httpMock: HttpTestingController) => {
let roles: AppRolesDto;
roleService.getRoles('my-app').subscribe(result => {
roles = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/roles');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({
roles: [{
name: 'Role1',
permissions: ['P1']
}, {
name: 'Role2',
permissions: ['P2']
}]
}, {
headers: {
etag: '2'
}
});
expect(roles!).toEqual(
new AppRolesDto([
new AppRoleDto('Role1', ['P1']),
new AppRoleDto('Role2', ['P2'])
],
new Version('2')));
}));
it('should make post request to add role',
inject([AppRolesService, HttpTestingController], (roleService: AppRolesService, httpMock: HttpTestingController) => {
const dto = new CreateAppRoleDto('Role3');
let role: AppRoleDto;
roleService.postRole('my-app', dto, version).subscribe(result => {
role = result.payload;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/roles');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toEqual(version.value);
req.flush({});
expect(role!).toEqual(new AppRoleDto('Role3', []));
}));
it('should make put request to update role',
inject([AppRolesService, HttpTestingController], (roleService: AppRolesService, httpMock: HttpTestingController) => {
const dto = new UpdateAppRoleDto(['P4', 'P5']);
roleService.putRole('my-app', 'role1', dto, version).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/roles/role1');
expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toEqual(version.value);
req.flush({});
}));
it('should make delete request to remove role',
inject([AppRolesService, HttpTestingController], (roleService: AppRolesService, httpMock: HttpTestingController) => {
roleService.deleteRole('my-app', 'role1', version).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/roles/role1');
expect(req.request.method).toEqual('DELETE');
expect(req.request.headers.get('If-Match')).toEqual(version.value);
req.flush({});
}));
});

129
src/Squidex/app/shared/services/app-roles.service.ts

@ -0,0 +1,129 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators';
import {
AnalyticsService,
ApiUrlConfig,
HTTP,
Model,
pretifyError,
Version,
Versioned
} from '@app/framework';
export class AppRolesDto extends Model {
constructor(
public readonly roles: AppRoleDto[],
public readonly version: Version
) {
super();
}
}
export class AppRoleDto extends Model {
constructor(
public readonly name: string,
public readonly permissions: string[]
) {
super();
}
public with(value: Partial<AppRoleDto>): AppRoleDto {
return this.clone(value);
}
}
export class CreateAppRoleDto {
constructor(
public readonly name: string
) {
}
}
export class UpdateAppRoleDto {
constructor(
public readonly permissions: string[]
) {
}
}
@Injectable()
export class AppRolesService {
constructor(
private readonly http: HttpClient,
private readonly apiUrl: ApiUrlConfig,
private readonly analytics: AnalyticsService
) {
}
public getRoles(appName: string): Observable<AppRolesDto> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/roles`);
return HTTP.getVersioned<any>(this.http, url).pipe(
map(response => {
const body = response.payload.body;
const items: any[] = body.roles;
const roles = items.map(item => {
return new AppRoleDto(
item.name,
item.permissions);
});
return new AppRolesDto(roles, response.version);
}),
pretifyError('Failed to load roles. Please reload.'));
}
public postRole(appName: string, dto: CreateAppRoleDto, version: Version): Observable<Versioned<AppRoleDto>> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/roles`);
return HTTP.postVersioned<any>(this.http, url, dto, version).pipe(
map(response => {
const role = new AppRoleDto(dto.name, []);
return new Versioned(response.version, role);
}),
tap(() => {
this.analytics.trackEvent('Role', 'Created', appName);
}),
pretifyError('Failed to add role. Please reload.'));
}
public putRole(appName: string, name: string, dto: UpdateAppRoleDto, version: Version): Observable<Versioned<any>> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/roles/${name}`);
return HTTP.putVersioned(this.http, url, dto, version).pipe(
tap(() => {
this.analytics.trackEvent('Role', 'Updated', appName);
}),
pretifyError('Failed to revoke role. Please reload.'));
}
public deleteRole(appName: string, name: string, version: Version): Observable<Versioned<any>> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/roles/${name}`);
return HTTP.deleteVersioned(this.http, url, version).pipe(
tap(() => {
this.analytics.trackEvent('Role', 'Deleted', appName);
}),
pretifyError('Failed to revoke role. Please reload.'));
}
public getPermissions(appName: string): Observable<string[]> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/roles/permissions`);
return this.http.get<string[]>(url).pipe(
pretifyError('Failed to load permissions. Please reload.'));
}
}

52
src/Squidex/app/shared/state/roles.forms.ts

@ -0,0 +1,52 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
import { map, startWith } from 'rxjs/operators';
import { Form } from '@app/framework';
export class EditPermissionsForm extends Form<FormArray> {
constructor() {
super(new FormArray([]));
}
public add() {
this.form.push(new FormControl(undefined, Validators.required));
}
public remove(index: number) {
this.form.removeAt(index);
}
public load(permissions: string[]) {
while (this.form.controls.length < permissions.length) {
this.add();
}
while (permissions.length > this.form.controls.length) {
this.form.removeAt(this.form.controls.length - 1);
}
super.load(permissions);
}
}
export class AddRoleForm extends Form<FormGroup> {
public hasNoName =
this.form.controls['name'].valueChanges.pipe(startWith(''), map(x => !x || x.length === 0));
constructor(formBuilder: FormBuilder) {
super(formBuilder.group({
name: [null,
[
Validators.required
]
]
}));
}
}

108
src/Squidex/app/shared/state/roles.state.spec.ts

@ -0,0 +1,108 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { of } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq';
import {
AppRoleDto,
AppRolesDto,
AppRolesService,
AppsState,
DialogService,
RolesState,
Version,
Versioned
} from '@app/shared';
import { CreateAppRoleDto, UpdateAppRoleDto } from '../services/app-roles.service';
describe('RolesState', () => {
const app = 'my-app';
const version = new Version('1');
const newVersion = new Version('2');
const oldRoles = [
new AppRoleDto('Role1', ['P1']),
new AppRoleDto('Role2', ['P2'])
];
let dialogs: IMock<DialogService>;
let appsState: IMock<AppsState>;
let rolesService: IMock<AppRolesService>;
let rolesState: RolesState;
beforeEach(() => {
dialogs = Mock.ofType<DialogService>();
appsState = Mock.ofType<AppsState>();
appsState.setup(x => x.appName)
.returns(() => app);
rolesService = Mock.ofType<AppRolesService>();
rolesService.setup(x => x.getRoles(app))
.returns(() => of(new AppRolesDto(oldRoles, version)));
rolesState = new RolesState(rolesService.object, appsState.object, dialogs.object);
rolesState.load().subscribe();
});
it('should load roles', () => {
expect(rolesState.snapshot.roles.values).toEqual(oldRoles);
expect(rolesState.snapshot.isLoaded).toBeTruthy();
expect(rolesState.snapshot.version).toEqual(version);
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never());
});
it('should show notification on load when reload is true', () => {
rolesState.load(true).subscribe();
expect().nothing();
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once());
});
it('should add role to snapshot when added', () => {
const newRole = new AppRoleDto('Role3', ['P3']);
const request = new CreateAppRoleDto('Role3');
rolesService.setup(x => x.postRole(app, request, version))
.returns(() => of(new Versioned<AppRoleDto>(newVersion, newRole)));
rolesState.add(request).subscribe();
expect(rolesState.snapshot.roles.values).toEqual([oldRoles[0], oldRoles[1], newRole]);
expect(rolesState.snapshot.version).toEqual(newVersion);
});
it('should update permissions when updated', () => {
const request = new UpdateAppRoleDto(['P4', 'P5']);
rolesService.setup(x => x.putRole(app, oldRoles[1].name, request, version))
.returns(() => of(new Versioned<any>(newVersion, {})));
rolesState.update(oldRoles[1], request).subscribe();
const role_1 = rolesState.snapshot.roles.at(1);
expect(role_1.permissions).toEqual(request.permissions);
expect(rolesState.snapshot.version).toEqual(newVersion);
});
it('should remove role from snapshot when deleted', () => {
rolesService.setup(x => x.deleteRole(app, oldRoles[0].name, version))
.returns(() => of(new Versioned<any>(newVersion, {})));
rolesState.delete(oldRoles[0]).subscribe();
expect(rolesState.snapshot.roles.values).toEqual([oldRoles[1]]);
expect(rolesState.snapshot.version).toEqual(newVersion);
});
});

121
src/Squidex/app/shared/state/roles.state.ts

@ -0,0 +1,121 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { distinctUntilChanged, map, tap } from 'rxjs/operators';
import {
DialogService,
ImmutableArray,
notify,
State,
Version
} from '@app/framework';
import { AppsState } from './apps.state';
import {
AppRoleDto,
AppRolesService,
CreateAppRoleDto,
UpdateAppRoleDto
} from './../services/app-roles.service';
interface Snapshot {
roles: ImmutableArray<AppRoleDto>;
version: Version;
isLoaded?: boolean;
}
@Injectable()
export class RolesState extends State<Snapshot> {
public roles =
this.changes.pipe(map(x => x.roles),
distinctUntilChanged());
public isLoaded =
this.changes.pipe(map(x => !!x.isLoaded),
distinctUntilChanged());
constructor(
private readonly appRolesService: AppRolesService,
private readonly appsState: AppsState,
private readonly dialogs: DialogService
) {
super({ roles: ImmutableArray.empty(), version: new Version('') });
}
public load(isReload = false): Observable<any> {
if (!isReload) {
this.resetState();
}
return this.appRolesService.getRoles(this.appName).pipe(
tap(dtos => {
if (isReload) {
this.dialogs.notifyInfo('Roles reloaded.');
}
this.next(s => {
const roles = ImmutableArray.of(dtos.roles).sortByStringAsc(x => x.name);
return { ...s, roles, isLoaded: true, version: dtos.version };
});
}),
notify(this.dialogs));
}
public add(request: CreateAppRoleDto): Observable<any> {
return this.appRolesService.postRole(this.appName, request, this.version).pipe(
tap(dto => {
this.next(s => {
const roles = s.roles.push(dto.payload);
return { ...s, roles, version: dto.version };
});
}),
notify(this.dialogs));
}
public delete(role: AppRoleDto): Observable<any> {
return this.appRolesService.deleteRole(this.appName, role.name, this.version).pipe(
tap(dto => {
this.next(s => {
const roles = s.roles.filter(c => c.name !== role.name);
return { ...s, roles, version: dto.version };
});
}),
notify(this.dialogs));
}
public update(role: AppRoleDto, request: UpdateAppRoleDto): Observable<any> {
return this.appRolesService.putRole(this.appName, role.name, request, this.version).pipe(
tap(dto => {
this.next(s => {
const roles = s.roles.replaceBy('name', update(role, request));
return { ...s, roles, version: dto.version };
});
}),
notify(this.dialogs));
}
private get appName() {
return this.appsState.appName;
}
private get version() {
return this.snapshot.version;
}
}
const update = (role: AppRoleDto, request: UpdateAppRoleDto) =>
role.with({ permissions: request.permissions });

29
tests/Squidex.Domain.Apps.Entities.Tests/Apps/RoleExtensionsRests.cs

@ -5,6 +5,8 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Linq;
using Squidex.Infrastructure.Security;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Apps
@ -28,5 +30,32 @@ namespace Squidex.Domain.Apps.Entities.Apps
Assert.Equal("squidex.apps.my-app.clients.read", result[1]);
}
[Fact]
public void Should_remove_app_prefix()
{
var source = new PermissionSet("squidex.apps.my-app.clients");
var result = source.WithoutApp("my-app");
Assert.Equal("clients", result.First().Id);
}
[Fact]
public void Should_not_remove_app_prefix_when_other_app()
{
var source = new PermissionSet("squidex.apps.other-app.clients");
var result = source.WithoutApp("my-app");
Assert.Equal("squidex.apps.other-app.clients", result.First().Id);
}
[Fact]
public void Should_set_to_wildcard_when_app_root_permission()
{
var source = new PermissionSet("squidex.apps.my-app");
var result = source.WithoutApp("my-app");
Assert.Equal(Permission.Any, result.First().Id);
}
}
}

Loading…
Cancel
Save