Browse Source

Allow to invite users.

pull/337/head
Sebastian Stehle 7 years ago
parent
commit
54ff6fd875
  1. 50
      src/Squidex.Domain.Apps.Entities/Apps/InviteCommandMiddleware.cs
  2. 16
      src/Squidex.Domain.Apps.Entities/Apps/InvitedResult.cs
  3. 16
      src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs
  4. 22
      src/Squidex.Domain.Apps.Entities/Apps/Templates/Scripts.cs
  5. 21
      src/Squidex.Domain.Users/DefaultUserResolver.cs
  6. 6
      src/Squidex.Infrastructure/StringExtensions.cs
  7. 2
      src/Squidex.Shared/Users/IUserResolver.cs
  8. 1
      src/Squidex/Areas/Api/Controllers/ApiController.cs
  9. 14
      src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs
  10. 7
      src/Squidex/Areas/Api/Controllers/Apps/Models/AssignContributorDto.cs
  11. 9
      src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorAssignedDto.cs
  12. 8
      src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  13. 3
      src/Squidex/Config/Domain/EntitiesServices.cs
  14. 6
      src/Squidex/Pipeline/ApiPermissionAttribute.cs
  15. 1
      src/Squidex/Squidex.csproj
  16. 15
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts
  17. 5
      src/Squidex/app/shared/services/app-contributors.service.spec.ts
  18. 17
      src/Squidex/app/shared/services/app-contributors.service.ts
  19. 10
      src/Squidex/app/shared/state/contributors.state.spec.ts
  20. 13
      src/Squidex/app/shared/state/contributors.state.ts
  21. 11
      tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs

50
src/Squidex.Domain.Apps.Entities/Apps/InviteCommandMiddleware.cs

@ -0,0 +1,50 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps
{
public sealed class InviteCommandMiddleware : ICommandMiddleware
{
private readonly IUserResolver userResolver;
public InviteCommandMiddleware(IUserResolver userResolver)
{
Guard.NotNull(userResolver, nameof(userResolver));
this.userResolver = userResolver;
}
public async Task HandleAsync(CommandContext context, Func<Task> next)
{
if (context.Command is AssignContributor assignContributor)
{
if (assignContributor.IsInviting && assignContributor.ContributorId.IsEmail())
{
var isInvited = await userResolver.CreateUserIfNotExists(assignContributor.ContributorId);
await next();
if (isInvited && context.Result<object>() is EntityCreatedResult<string> id)
{
context.Complete(new InvitedResult { Id = id });
}
return;
}
}
await next();
}
}
}

16
src/Squidex.Domain.Apps.Entities/Apps/InvitedResult.cs

@ -0,0 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Apps
{
public sealed class InvitedResult
{
public EntityCreatedResult<string> Id { get; set; }
}
}

16
src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs

@ -20,14 +20,6 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates
public sealed class CreateBlogCommandMiddleware : ICommandMiddleware
{
private const string TemplateName = "Blog";
private const string SlugScript = @"
var data = ctx.data;
if (data.title && data.title.iv) {
data.slug = { iv: slugify(data.title.iv) };
}
replace(data);";
public async Task HandleAsync(CommandContext context, Func<Task> next)
{
@ -129,8 +121,8 @@ replace(data);";
await publish(new ConfigureScripts
{
SchemaId = schemaId.Id,
ScriptCreate = SlugScript,
ScriptUpdate = SlugScript
ScriptCreate = Scripts.Slug,
ScriptUpdate = Scripts.Slug
});
return schemaId;
@ -163,8 +155,8 @@ replace(data);";
await publish(new ConfigureScripts
{
SchemaId = schemaId.Id,
ScriptCreate = SlugScript,
ScriptUpdate = SlugScript
ScriptCreate = Scripts.Slug,
ScriptUpdate = Scripts.Slug
});
return schemaId;

22
src/Squidex.Domain.Apps.Entities/Apps/Templates/Scripts.cs

@ -0,0 +1,22 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Apps.Templates
{
public static class Scripts
{
public const string Slug =
@"var data = ctx.data;
if (data.title && data.title.iv) {
data.slug = { iv: slugify(data.title.iv) };
replace(data);
}
";
}
}

21
src/Squidex.Domain.Users/DefaultUserResolver.cs

@ -28,6 +28,27 @@ namespace Squidex.Domain.Users
this.userFactory = userFactory;
}
public async Task<bool> CreateUserIfNotExists(string email)
{
var user = userFactory.Create(email);
try
{
var result = await userManager.CreateAsync(user);
if (result.Succeeded)
{
await userManager.UpdateAsync(user, new UserValues { DisplayName = email });
}
return result.Succeeded;
}
catch
{
return false;
}
}
public async Task<IUser> FindByIdOrEmailAsync(string idOrEmail)
{
if (userFactory.IsId(idOrEmail))

6
src/Squidex.Infrastructure/StringExtensions.cs

@ -17,6 +17,7 @@ namespace Squidex.Infrastructure
{
private const char NullChar = (char)0;
private static readonly Regex SlugRegex = new Regex("^[a-z0-9]+(\\-[a-z0-9]+)*$", RegexOptions.Compiled);
private static readonly Regex EmailRegex = new Regex("^[a-zA-Z0-9.!#$%&’*+\\/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:.[a-zA-Z0-9-]+)*$", RegexOptions.Compiled);
private static readonly Regex PropertyNameRegex = new Regex("^[a-zA-Z0-9]+(\\-[a-zA-Z0-9]+)*$", RegexOptions.Compiled);
private static readonly Dictionary<char, string> LowerCaseDiacritics;
private static readonly Dictionary<char, string> Diacritics = new Dictionary<char, string>
@ -314,6 +315,11 @@ namespace Squidex.Infrastructure
return value != null && SlugRegex.IsMatch(value);
}
public static bool IsEmail(this string value)
{
return value != null && EmailRegex.IsMatch(value);
}
public static bool IsPropertyName(this string value)
{
return value != null && PropertyNameRegex.IsMatch(value);

2
src/Squidex.Shared/Users/IUserResolver.cs

@ -12,6 +12,8 @@ namespace Squidex.Shared.Users
{
public interface IUserResolver
{
Task<bool> CreateUserIfNotExists(string email);
Task<IUser> FindByIdOrEmailAsync(string idOrEmail);
Task<List<IUser>> QueryByEmailAsync(string email);

1
src/Squidex/Areas/Api/Controllers/ApiController.cs

@ -16,6 +16,7 @@ using Squidex.Pipeline;
namespace Squidex.Areas.Api.Controllers
{
[Area("Api")]
[ApiController]
[ApiExceptionFilter]
[ApiModelValidation(false)]
public abstract class ApiController : Controller

14
src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs

@ -8,6 +8,7 @@
using System.Threading.Tasks;
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.Domain.Apps.Entities.Apps.Services;
using Squidex.Infrastructure.Commands;
@ -73,8 +74,17 @@ namespace Squidex.Areas.Api.Controllers.Apps
var command = request.ToCommand();
var context = await CommandBus.PublishAsync(command);
var result = context.Result<EntityCreatedResult<string>>();
var response = ContributorAssignedDto.FromId(result.IdOrValue);
var result = context.Result<object>();
var response = (ContributorAssignedDto)null;
if (result is EntityCreatedResult<string> idOrValue)
{
response = ContributorAssignedDto.FromId(idOrValue.IdOrValue, false);
}
else if (result is InvitedResult invited)
{
response = ContributorAssignedDto.FromId(invited.Id.IdOrValue, true);
}
return Ok(response);
}

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

@ -24,9 +24,14 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// </summary>
public string Role { get; set; }
/// <summary>
/// Set to true to invite the user if he does not exist.
/// </summary>
public bool Invite { get; set; }
public AssignContributor ToCommand()
{
return SimpleMapper.Map(this, new AssignContributor());
return SimpleMapper.Map(this, new AssignContributor { IsInviting = Invite });
}
}
}

9
src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorAssignedDto.cs

@ -17,9 +17,14 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
[Required]
public string ContributorId { get; set; }
public static ContributorAssignedDto FromId(string id)
/// <summary>
/// Indicates if the user was created.
/// </summary>
public bool WasInvited { get; set; }
public static ContributorAssignedDto FromId(string id, bool wasInvited)
{
return new ContributorAssignedDto { ContributorId = id };
return new ContributorAssignedDto { ContributorId = id, WasInvited = wasInvited };
}
}
}

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

@ -22,6 +22,7 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Pipeline;
using Squidex.Shared;
using Squidex.Shared.Identity;
namespace Squidex.Areas.Api.Controllers.Contents
{
@ -239,6 +240,13 @@ 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))
{
return new StatusCodeResult(123);
}
var command = new CreateContent { ContentId = Guid.NewGuid(), Data = request.ToCleaned(), Publish = publish };
var context = await CommandBus.PublishAsync(command);

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

@ -160,6 +160,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<EnrichWithSchemaIdCommandMiddleware>()
.As<ICommandMiddleware>();
services.AddSingletonAs<InviteCommandMiddleware>()
.As<ICommandMiddleware>();
services.AddSingletonAs<AssetCommandMiddleware>()
.As<ICommandMiddleware>();

6
src/Squidex/Pipeline/ApiPermissionAttribute.cs

@ -6,7 +6,6 @@
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using IdentityServer4.AccessTokenValidation;
using Microsoft.AspNetCore.Authorization;
@ -38,10 +37,7 @@ namespace Squidex.Pipeline
{
if (permissionIds.Length > 0)
{
var set = new PermissionSet(
context.HttpContext.User.FindAll(SquidexClaimTypes.Permissions)
.Select(x => x.Value)
.Select(x => new Permission(x)));
var set = context.HttpContext.User.Permissions();
var hasPermission = false;

1
src/Squidex/Squidex.csproj

@ -68,7 +68,6 @@
<PackageReference Include="Microsoft.AspNetCore.HttpOverrides" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Api.Analyzers" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.ViewCompilation" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="2.1.0" />

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

@ -13,9 +13,11 @@ import { filter, onErrorResumeNext, withLatestFrom } from 'rxjs/operators';
import {
AppContributorDto,
AppsState,
AssignContributorDto,
AssignContributorForm,
AutocompleteSource,
ContributorsState,
DialogService,
RolesState,
Types,
UserDto,
@ -61,6 +63,7 @@ export class ContributorsPageComponent implements OnInit {
public readonly contributorsState: ContributorsState,
public readonly rolesState: RolesState,
public readonly usersDataSource: UsersDataSource,
private readonly dialogs: DialogService,
private readonly formBuilder: FormBuilder
) {
}
@ -80,7 +83,7 @@ export class ContributorsPageComponent implements OnInit {
}
public changeRole(contributor: AppContributorDto, role: string) {
this.contributorsState.assign(new AppContributorDto(contributor.contributorId, role)).pipe(onErrorResumeNext()).subscribe();
this.contributorsState.assign(new AssignContributorDto(contributor.contributorId, role)).pipe(onErrorResumeNext()).subscribe();
}
public assignContributor() {
@ -93,11 +96,15 @@ export class ContributorsPageComponent implements OnInit {
user = user.id;
}
const requestDto = new AppContributorDto(user, 'Editor');
const requestDto = new AssignContributorDto(user, 'Editor', true);
this.contributorsState.assign(requestDto)
.subscribe(() => {
this.assignContributorForm.submitCompleted();
.subscribe(wasInvited => {
this.assignContributorForm.submitCompleted({});
if (wasInvited) {
this.dialogs.notifyInfo('A new user with the entered email address has been created and assigned as contributor.');
}
}, error => {
this.assignContributorForm.submitFailed(error);
});

5
src/Squidex/app/shared/services/app-contributors.service.spec.ts

@ -14,6 +14,7 @@ import {
AppContributorDto,
AppContributorsDto,
AppContributorsService,
AssignContributorDto,
ContributorAssignedDto,
Version
} from './../';
@ -80,7 +81,7 @@ describe('AppContributorsService', () => {
it('should make post request to assign contributor',
inject([AppContributorsService, HttpTestingController], (appContributorsService: AppContributorsService, httpMock: HttpTestingController) => {
const dto = new AppContributorDto('123', 'Owner');
const dto = new AssignContributorDto('123', 'Owner');
let contributorAssignedDto: ContributorAssignedDto;
@ -93,7 +94,7 @@ describe('AppContributorsService', () => {
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toEqual(version.value);
req.flush({ contributorId: '123' });
req.flush({ contributorId: '123', wasInvited: true });
expect(contributorAssignedDto!.contributorId).toEqual('123');
}));

17
src/Squidex/app/shared/services/app-contributors.service.ts

@ -30,6 +30,16 @@ export class AppContributorsDto extends Model {
}
}
export class AssignContributorDto extends Model {
constructor(
public readonly contributorId: string,
public readonly role: string,
public readonly invite = false
) {
super();
}
}
export class AppContributorDto extends Model {
constructor(
public readonly contributorId: string,
@ -41,7 +51,8 @@ export class AppContributorDto extends Model {
export class ContributorAssignedDto {
constructor(
public readonly contributorId: string
public readonly contributorId: string,
public readonly wasInvited: boolean
) {
}
}
@ -75,14 +86,14 @@ export class AppContributorsService {
pretifyError('Failed to load contributors. Please reload.'));
}
public postContributor(appName: string, dto: AppContributorDto, version: Version): Observable<Versioned<ContributorAssignedDto>> {
public postContributor(appName: string, dto: AssignContributorDto, version: Version): Observable<Versioned<ContributorAssignedDto>> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/contributors`);
return HTTP.postVersioned(this.http, url, dto, version).pipe(
map(response => {
const body: any = response.payload.body;
const result = new ContributorAssignedDto(body.contributorId);
const result = new ContributorAssignedDto(body.contributorId, body.wasInvited);
return new Versioned(response.version, result);
}),

10
src/Squidex/app/shared/state/contributors.state.spec.ts

@ -13,12 +13,14 @@ import {
AppContributorsDto,
AppContributorsService,
AppsState,
AssignContributorDto,
AuthService,
ContributorsState,
DialogService,
Version,
Versioned
} from '@app/shared';
import { ContributorAssignedDto } from '../services/app-contributors.service';
describe('ContributorsState', () => {
const app = 'my-app';
@ -82,10 +84,10 @@ describe('ContributorsState', () => {
it('should add contributor to snapshot when assigned', () => {
const newContributor = new AppContributorDto('id3', 'Developer');
const request = new AppContributorDto('mail2stehle@gmail.com', 'Developer');
const request = new AssignContributorDto('mail2stehle@gmail.com', 'Developer');
contributorsService.setup(x => x.postContributor(app, request, version))
.returns(() => of(new Versioned<AppContributorDto>(newVersion, newContributor)));
.returns(() => of(new Versioned<ContributorAssignedDto>(newVersion, new ContributorAssignedDto('id3', true))));
contributorsState.assign(request).subscribe();
@ -102,10 +104,10 @@ describe('ContributorsState', () => {
it('should update contributor in snapshot when assigned and already added', () => {
const newContributor = new AppContributorDto('id2', 'Owner');
const request = new AppContributorDto('mail2stehle@gmail.com', 'Owner');
const request = new AssignContributorDto('mail2stehle@gmail.com', 'Owner');
contributorsService.setup(x => x.postContributor(app, request, version))
.returns(() => of(new Versioned<AppContributorDto>(newVersion, newContributor)));
.returns(() => of(new Versioned<ContributorAssignedDto>(newVersion, new ContributorAssignedDto('id2', true))));
contributorsState.assign(request).subscribe();

13
src/Squidex/app/shared/state/contributors.state.ts

@ -19,7 +19,12 @@ import {
Version
} from '@app/framework';
import { AppContributorDto, AppContributorsService } from './../services/app-contributors.service';
import {
AppContributorDto,
AppContributorsService,
AssignContributorDto
} from './../services/app-contributors.service';
import { AuthService } from './../services/auth.service';
import { AppsState } from './apps.state';
@ -95,12 +100,14 @@ export class ContributorsState extends State<Snapshot> {
notify(this.dialogs));
}
public assign(request: AppContributorDto): Observable<any> {
public assign(request: AssignContributorDto): Observable<boolean> {
return this.appContributorsService.postContributor(this.appName, request, this.version).pipe(
tap(dto => {
map(dto => {
const contributors = this.updateContributors(dto.payload.contributorId, request.role, dto.version);
this.replaceContributors(contributors, dto.version);
return dto.payload.wasInvited;
}),
catchError(error => {
if (Types.is(error, ErrorDto) && error.statusCode === 404) {

11
tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs

@ -12,6 +12,17 @@ namespace Squidex.Infrastructure
{
public class StringExtensionsTests
{
[Theory]
[InlineData(null, false)]
[InlineData("", false)]
[InlineData("name", false)]
[InlineData("name@@web.de", false)]
[InlineData("name@web.de", true)]
public void Should_check_email(string email, bool isEmail)
{
Assert.Equal(isEmail, email.IsEmail());
}
[Theory]
[InlineData(null)]
[InlineData("")]

Loading…
Cancel
Save