Browse Source

Merge pull request #354 from Squidex/feature/invitations

Feature/invitations
pull/355/head
Sebastian Stehle 7 years ago
committed by GitHub
parent
commit
f84b3787c9
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs
  2. 2
      src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs
  3. 21
      src/Squidex.Domain.Apps.Entities/Apps/Invitation/IInvitationEmailSender.cs
  4. 105
      src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs
  5. 107
      src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs
  6. 20
      src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailTextOptions.cs
  7. 6
      src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs
  8. 2
      src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitedResult.cs
  9. 31
      src/Squidex.Domain.Apps.Entities/Apps/Invitation/NoopInvitationEmailSender.cs
  10. 2
      src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs
  11. 3
      src/Squidex.Domain.Apps.Entities/Contents/Text/GrainTextIndexer.cs
  12. 14
      src/Squidex.Domain.Apps.Entities/IEmailUrlGenerator.cs
  13. 2
      src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs
  14. 2
      src/Squidex.Domain.Apps.Events/Apps/AppContributorAssigned.cs
  15. 6
      src/Squidex.Domain.Users/DefaultUserResolver.cs
  16. 7
      src/Squidex.Domain.Users/UserValues.cs
  17. 2
      src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Reader.cs
  18. 3
      src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbSubscription.cs
  19. 16
      src/Squidex.Infrastructure/Email/IEmailSender.cs
  20. 33
      src/Squidex.Infrastructure/Email/SmptOptions.cs
  21. 42
      src/Squidex.Infrastructure/Email/SmtpEmailSender.cs
  22. 2
      src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs
  23. 10
      src/Squidex.Infrastructure/RefToken.cs
  24. 2
      src/Squidex.Shared/Identity/SquidexClaimTypes.cs
  25. 2
      src/Squidex.Shared/Users/IUserResolver.cs
  26. 5
      src/Squidex.Shared/Users/UserExtensions.cs
  27. 8
      src/Squidex.Web/Services/UrlGenerator.cs
  28. 1
      src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs
  29. 6
      src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorAssignedDto.cs
  30. 28
      src/Squidex/Config/Domain/EntitiesServices.cs
  31. 4
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts
  32. 2
      src/Squidex/app/shared/services/app-contributors.service.spec.ts
  33. 4
      src/Squidex/app/shared/services/app-contributors.service.ts
  34. 2
      src/Squidex/app/shared/state/contributors.state.ts
  35. 62
      src/Squidex/appsettings.json
  36. 160
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEmailEventConsumerTests.cs
  37. 140
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEmailSenderTests.cs
  38. 12
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InviteUserCommandMiddlewareTests.cs
  39. 10
      tests/Squidex.Infrastructure.Tests/RefTokenTests.cs

2
src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs

@ -66,7 +66,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules
user = null;
}
if (user == null && actor.Type.Equals(RefTokenType.Client, StringComparison.OrdinalIgnoreCase))
if (user == null && actor.IsClient)
{
user = new ClientUser(actor);
}

2
src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs

@ -18,5 +18,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Commands
public bool IsRestore { get; set; }
public bool IsInviting { get; set; }
public bool IsCreated { get; set; }
}
}

21
src/Squidex.Domain.Apps.Entities/Apps/Invitation/IInvitationEmailSender.cs

@ -0,0 +1,21 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps.Invitation
{
public interface IInvitationEmailSender
{
bool IsActive { get; }
Task SendNewUserEmailAsync(IUser assigner, IUser assignee, string appName);
Task SendExistingUserEmailAsync(IUser assigner, IUser assignee, string appName);
}
}

105
src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs

@ -0,0 +1,105 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Squidex.Domain.Apps.Events.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Tasks;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps.Invitation
{
public sealed class InvitationEmailEventConsumer : IEventConsumer
{
private readonly IInvitationEmailSender emailSender;
private readonly IUserResolver userResolver;
private readonly ISemanticLog log;
public string Name
{
get { return "InvitationEmailSender"; }
}
public string EventsFilter
{
get { return "^app-"; }
}
public InvitationEmailEventConsumer(IInvitationEmailSender emailSender, IUserResolver userResolver, ISemanticLog log)
{
Guard.NotNull(emailSender, nameof(emailSender));
Guard.NotNull(userResolver, nameof(userResolver));
Guard.NotNull(log, nameof(log));
this.emailSender = emailSender;
this.userResolver = userResolver;
this.log = log;
}
public bool Handles(StoredEvent @event)
{
return true;
}
public Task ClearAsync()
{
return TaskHelper.Done;
}
public async Task On(Envelope<IEvent> @event)
{
if (!emailSender.IsActive)
{
return;
}
if (@event.Payload is AppContributorAssigned appContributorAssigned && appContributorAssigned.Actor.IsSubject)
{
var assignerId = appContributorAssigned.Actor.Identifier;
var assigneeId = appContributorAssigned.ContributorId;
var assigner = await userResolver.FindByIdOrEmailAsync(assignerId);
if (assigner == null)
{
LogWarning($"Assigner {assignerId} not found");
return;
}
var assignee = await userResolver.FindByIdOrEmailAsync(appContributorAssigned.ContributorId);
if (assignee == null)
{
LogWarning($"Assignee {assigneeId} not found");
return;
}
var appName = appContributorAssigned.AppId.Name;
if (appContributorAssigned.IsCreated)
{
await emailSender.SendNewUserEmailAsync(assigner, assignee, appName);
}
else
{
await emailSender.SendExistingUserEmailAsync(assigner, assignee, appName);
}
}
}
private void LogWarning(string reason)
{
log.LogWarning(w => w
.WriteProperty("action", "InviteUser")
.WriteProperty("status", "Failed")
.WriteProperty("reason", reason));
}
}
}

107
src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs

@ -0,0 +1,107 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Email;
using Squidex.Infrastructure.Log;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps.Invitation
{
public sealed class InvitationEmailSender : IInvitationEmailSender
{
private readonly IEmailSender emailSender;
private readonly IEmailUrlGenerator emailUrlGenerator;
private readonly ISemanticLog log;
private readonly InvitationEmailTextOptions texts;
public bool IsActive
{
get { return true; }
}
public InvitationEmailSender(
IOptions<InvitationEmailTextOptions> texts,
IEmailSender emailSender,
IEmailUrlGenerator emailUrlGenerator,
ISemanticLog log)
{
Guard.NotNull(texts, nameof(texts));
Guard.NotNull(emailSender, nameof(emailSender));
Guard.NotNull(emailUrlGenerator, nameof(emailUrlGenerator));
Guard.NotNull(log, nameof(log));
this.texts = texts.Value;
this.emailSender = emailSender;
this.emailUrlGenerator = emailUrlGenerator;
this.log = log;
}
public Task SendExistingUserEmailAsync(IUser assigner, IUser assignee, string appName)
{
return SendEmailAsync(texts.ExistingUserSubject, texts.ExistingUserBody, assigner, assignee, appName);
}
public Task SendNewUserEmailAsync(IUser assigner, IUser assignee, string appName)
{
return SendEmailAsync(texts.NewUserSubject, texts.NewUserBody, assigner, assignee, appName);
}
private async Task SendEmailAsync(string emailSubj, string emailBody, IUser assigner, IUser assignee, string appName)
{
if (string.IsNullOrWhiteSpace(emailBody))
{
LogWarning("No email subject configured for new users");
return;
}
if (string.IsNullOrWhiteSpace(emailSubj))
{
LogWarning("No email body configured for new users");
return;
}
var appUrl = emailUrlGenerator.GenerateUIUrl();
emailSubj = Format(emailSubj, assigner, assignee, appUrl, appName);
emailBody = Format(emailBody, assigner, assignee, appUrl, appName);
await emailSender.SendAsync(assignee.Email, emailSubj, emailBody);
}
private void LogWarning(string reason)
{
log.LogWarning(w => w
.WriteProperty("action", "InviteUser")
.WriteProperty("status", "Failed")
.WriteProperty("reason", reason));
}
private string Format(string text, IUser assigner, IUser assignee, string uiUrl, string appName)
{
text = text.Replace("$APP_NAME", appName);
if (assigner != null)
{
text = text.Replace("$ASSIGNER_EMAIL", assigner.Email);
text = text.Replace("$ASSIGNER_NAME", assigner.DisplayName());
}
if (assignee != null)
{
text = text.Replace("$ASSIGNEE_EMAIL", assignee.Email);
text = text.Replace("$ASSIGNEE_NAME", assignee.DisplayName());
}
text = text.Replace("$UI_URL", uiUrl);
return text;
}
}
}

20
src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailTextOptions.cs

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Apps.Invitation
{
public sealed class InvitationEmailTextOptions
{
public string NewUserSubject { get; set; }
public string NewUserBody { get; set; }
public string ExistingUserSubject { get; set; }
public string ExistingUserBody { get; set; }
}
}

6
src/Squidex.Domain.Apps.Entities/Apps/InviteUserCommandMiddleware.cs → src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs

@ -12,7 +12,7 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps
namespace Squidex.Domain.Apps.Entities.Apps.Invitation
{
public sealed class InviteUserCommandMiddleware : ICommandMiddleware
{
@ -31,11 +31,11 @@ namespace Squidex.Domain.Apps.Entities.Apps
{
if (assignContributor.IsInviting && assignContributor.ContributorId.IsEmail())
{
var isInvited = await userResolver.CreateUserIfNotExists(assignContributor.ContributorId);
assignContributor.IsCreated = await userResolver.CreateUserIfNotExists(assignContributor.ContributorId, true);
await next();
if (isInvited && context.PlainResult is EntityCreatedResult<string> id)
if (assignContributor.IsCreated && context.PlainResult is EntityCreatedResult<string> id)
{
context.Complete(new InvitedResult { Id = id });
}

2
src/Squidex.Domain.Apps.Entities/Apps/InvitedResult.cs → src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitedResult.cs

@ -7,7 +7,7 @@
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Apps
namespace Squidex.Domain.Apps.Entities.Apps.Invitation
{
public sealed class InvitedResult
{

31
src/Squidex.Domain.Apps.Entities/Apps/Invitation/NoopInvitationEmailSender.cs

@ -0,0 +1,31 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Squidex.Infrastructure.Tasks;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps.Invitation
{
public sealed class NoopInvitationEmailSender : IInvitationEmailSender
{
public bool IsActive
{
get { return false; }
}
public Task SendExistingUserEmailAsync(IUser assigner, IUser assignee, string appName)
{
return TaskHelper.Done;
}
public Task SendNewUserEmailAsync(IUser assigner, IUser assignee, string appName)
{
return TaskHelper.Done;
}
}
}

2
src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs

@ -239,7 +239,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
{
var actor = CurrentJob.Actor;
if (string.Equals(actor?.Type, RefTokenType.Subject))
if (actor?.IsSubject == true)
{
await commandBus.PublishAsync(new AssignContributor
{

3
src/Squidex.Domain.Apps.Entities/Contents/Text/GrainTextIndexer.cs

@ -17,6 +17,7 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Contents.Text
{
@ -48,7 +49,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
public Task ClearAsync()
{
return Task.CompletedTask;
return TaskHelper.Done;
}
public async Task On(Envelope<IEvent> @event)

14
src/Squidex.Domain.Apps.Entities/IEmailUrlGenerator.cs

@ -0,0 +1,14 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities
{
public interface IEmailUrlGenerator
{
string GenerateUIUrl();
}
}

2
src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs

@ -58,7 +58,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking
RegisterOrUpdateReminder("Default", TimeSpan.Zero, TimeSpan.FromMinutes(10));
RegisterTimer(x => CheckUsagesAsync(), null, TimeSpan.Zero, TimeSpan.FromMinutes(10));
return Task.CompletedTask;
return TaskHelper.Done;
}
public Task ActivateAsync()

2
src/Squidex.Domain.Apps.Events/Apps/AppContributorAssigned.cs

@ -15,5 +15,7 @@ namespace Squidex.Domain.Apps.Events.Apps
public string ContributorId { get; set; }
public string Role { get; set; }
public bool IsCreated { get; set; }
}
}

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

@ -28,7 +28,7 @@ namespace Squidex.Domain.Users
this.userFactory = userFactory;
}
public async Task<bool> CreateUserIfNotExists(string email)
public async Task<bool> CreateUserIfNotExists(string email, bool invited)
{
var user = userFactory.Create(email);
@ -38,7 +38,9 @@ namespace Squidex.Domain.Users
if (result.Succeeded)
{
await userManager.UpdateAsync(user, new UserValues { DisplayName = email });
var values = new UserValues { DisplayName = email, Invited = invited };
await userManager.UpdateAsync(user, values);
}
return result.Succeeded;

7
src/Squidex.Domain.Users/UserValues.cs

@ -22,6 +22,8 @@ namespace Squidex.Domain.Users
public string Email { get; set; }
public bool? Invited { get; set; }
public bool? Consent { get; set; }
public bool? ConsentForEmails { get; set; }
@ -47,6 +49,11 @@ namespace Squidex.Domain.Users
yield return new Claim(SquidexClaimTypes.Hidden, Hidden.ToString());
}
if (Invited.HasValue)
{
yield return new Claim(SquidexClaimTypes.Invited, Invited.ToString());
}
if (Consent.HasValue)
{
yield return new Claim(SquidexClaimTypes.Consent, Consent.ToString());

2
src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Reader.cs

@ -34,7 +34,7 @@ namespace Squidex.Infrastructure.EventSourcing
ThrowIfDisposed();
return Task.CompletedTask;
return TaskHelper.Done;
}
public async Task<IReadOnlyList<StoredEvent>> QueryAsync(string streamName, long streamPosition = 0)

3
src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbSubscription.cs

@ -13,6 +13,7 @@ using System.Threading.Tasks;
using Microsoft.Azure.Documents;
using Microsoft.Azure.Documents.ChangeFeedProcessor.FeedProcessing;
using Newtonsoft.Json;
using Squidex.Infrastructure.Tasks;
using Builder = Microsoft.Azure.Documents.ChangeFeedProcessor.ChangeFeedProcessorBuilder;
using Collection = Microsoft.Azure.Documents.ChangeFeedProcessor.DocumentCollectionInfo;
using Options = Microsoft.Azure.Documents.ChangeFeedProcessor.ChangeFeedProcessorOptions;
@ -103,7 +104,7 @@ namespace Squidex.Infrastructure.EventSourcing
public Task OpenAsync(IChangeFeedObserverContext context)
{
return Task.CompletedTask;
return TaskHelper.Done;
}
public async Task ProcessChangesAsync(IChangeFeedObserverContext context, IReadOnlyList<Document> docs, CancellationToken cancellationToken)

16
src/Squidex.Infrastructure/Email/IEmailSender.cs

@ -0,0 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
namespace Squidex.Infrastructure.Email
{
public interface IEmailSender
{
Task SendAsync(string recipient, string subject, string body);
}
}

33
src/Squidex.Infrastructure/Email/SmptOptions.cs

@ -0,0 +1,33 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Infrastructure.Email
{
public sealed class SmptOptions
{
public string Server { get; set; }
public string Sender { get; set; }
public string Username { get; set; }
public string Password { get; set; }
public bool EnableSsl { get; set; }
public int Port { get; set; } = 587;
public bool IsConfigured()
{
return
!string.IsNullOrWhiteSpace(Server) &&
!string.IsNullOrWhiteSpace(Sender) &&
!string.IsNullOrWhiteSpace(Username) &&
!string.IsNullOrWhiteSpace(Password);
}
}
}

42
src/Squidex.Infrastructure/Email/SmtpEmailSender.cs

@ -0,0 +1,42 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Net;
using System.Net.Mail;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
namespace Squidex.Infrastructure.Email
{
public sealed class SmtpEmailSender : IEmailSender
{
private readonly SmtpClient smtpClient;
private readonly string sender;
public SmtpEmailSender(IOptions<SmptOptions> options)
{
Guard.NotNull(options, nameof(options));
var config = options.Value;
smtpClient = new SmtpClient(config.Server, config.Port)
{
Credentials = new NetworkCredential(
config.Username,
config.Password),
EnableSsl = config.EnableSsl
};
sender = config.Sender;
}
public Task SendAsync(string recipient, string subject, string body)
{
return smtpClient.SendMailAsync(sender, recipient, subject, body);
}
}
}

2
src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs

@ -53,7 +53,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
eventConsumer = eventConsumerFactory(key);
return Task.CompletedTask;
return TaskHelper.Done;
}
public Task<Immutable<EventConsumerInfo>> GetStateAsync()

10
src/Squidex.Infrastructure/RefToken.cs

@ -15,6 +15,16 @@ namespace Squidex.Infrastructure
public string Identifier { get; }
public bool IsClient
{
get { return string.Equals(Type, RefTokenType.Client, StringComparison.OrdinalIgnoreCase); }
}
public bool IsSubject
{
get { return string.Equals(Type, RefTokenType.Subject, StringComparison.OrdinalIgnoreCase); }
}
public RefToken(string type, string identifier)
{
Guard.NotNullOrEmpty(type, nameof(type));

2
src/Squidex.Shared/Identity/SquidexClaimTypes.cs

@ -19,6 +19,8 @@ namespace Squidex.Shared.Identity
public static readonly string Hidden = "urn:squidex:hidden";
public static readonly string Invited = "urn:squidex:invited";
public static readonly string Permissions = "urn:squidex:permissions";
public static readonly string PermissionsClient = "client_urn:squidex:permissions";

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

@ -12,7 +12,7 @@ namespace Squidex.Shared.Users
{
public interface IUserResolver
{
Task<bool> CreateUserIfNotExists(string email);
Task<bool> CreateUserIfNotExists(string email, bool invited = false);
Task<IUser> FindByIdOrEmailAsync(string idOrEmail);

5
src/Squidex.Shared/Users/UserExtensions.cs

@ -19,6 +19,11 @@ namespace Squidex.Shared.Users
return new PermissionSet(user.GetClaimValues(SquidexClaimTypes.Permissions).Select(x => new Permission(x)));
}
public static bool IsInvited(this IUser user)
{
return user.HasClaimValue(SquidexClaimTypes.Invited, "true");
}
public static bool IsHidden(this IUser user)
{
return user.HasClaimValue(SquidexClaimTypes.Hidden, "true");

8
src/Squidex.Web/Services/UrlGenerator.cs

@ -9,6 +9,7 @@ using System;
using Microsoft.Extensions.Options;
using Squidex.Domain.Apps.Core.ConvertContent;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Contents;
@ -19,7 +20,7 @@ using Squidex.Infrastructure.Assets;
namespace Squidex.Web.Services
{
public sealed class UrlGenerator : IGraphQLUrlGenerator, IRuleUrlGenerator, IAssetUrlGenerator
public sealed class UrlGenerator : IGraphQLUrlGenerator, IRuleUrlGenerator, IAssetUrlGenerator, IEmailUrlGenerator
{
private readonly IAssetStore assetStore;
private readonly UrlsOptions urlsOptions;
@ -64,6 +65,11 @@ namespace Squidex.Web.Services
return urlsOptions.BuildUrl($"app/{appId.Name}/content/{schemaId.Name}/{contentId}/history");
}
public string GenerateUIUrl()
{
return urlsOptions.BuildUrl("app/");
}
public string GenerateAssetSourceUrl(IAppEntity app, IAssetEntity asset)
{
return assetStore.GeneratePublicUrl(asset.Id.ToString(), asset.FileVersion, null);

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

@ -11,6 +11,7 @@ using Microsoft.Net.Http.Headers;
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.Invitation;
using Squidex.Domain.Apps.Entities.Apps.Services;
using Squidex.Infrastructure.Commands;
using Squidex.Shared;

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

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

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

@ -22,6 +22,7 @@ using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Apps.Indexes;
using Squidex.Domain.Apps.Entities.Apps.Invitation;
using Squidex.Domain.Apps.Entities.Apps.Templates;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Commands;
@ -44,6 +45,7 @@ using Squidex.Domain.Apps.Entities.Schemas.Indexes;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Email;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Migrations;
using Squidex.Infrastructure.Orleans;
@ -63,7 +65,7 @@ namespace Squidex.Config.Domain
c.GetRequiredService<IOptions<UrlsOptions>>(),
c.GetRequiredService<IAssetStore>(),
exposeSourceUrl))
.As<IGraphQLUrlGenerator>().As<IRuleUrlGenerator>().As<IAssetUrlGenerator>();
.As<IGraphQLUrlGenerator>().As<IRuleUrlGenerator>().As<IAssetUrlGenerator>().As<IEmailUrlGenerator>();
services.AddSingletonAs<HistoryService>()
.As<IEventConsumer>().As<IHistoryService>();
@ -147,6 +149,30 @@ namespace Squidex.Config.Domain
return result;
});
var emailOptions = config.GetSection("email:smtp").Get<SmptOptions>();
if (emailOptions.IsConfigured())
{
services.AddSingleton(Options.Create(emailOptions));
services.Configure<InvitationEmailTextOptions>(
config.GetSection("email:invitations"));
services.AddSingletonAs<SmtpEmailSender>()
.As<IEmailSender>();
services.AddSingletonAs<InvitationEmailSender>()
.AsOptional<IInvitationEmailSender>();
}
else
{
services.AddSingletonAs<NoopInvitationEmailSender>()
.AsOptional<IInvitationEmailSender>();
}
services.AddSingletonAs<InvitationEmailEventConsumer>()
.As<IEventConsumer>();
}
private static void AddCommandPipeline(this IServiceCollection services)

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

@ -99,10 +99,10 @@ export class ContributorsPageComponent implements OnInit {
const requestDto = new AssignContributorDto(user, 'Editor', true);
this.contributorsState.assign(requestDto)
.subscribe(wasInvited => {
.subscribe(isCreated => {
this.assignContributorForm.submitCompleted({});
if (wasInvited) {
if (isCreated) {
this.dialogs.notifyInfo('A new user with the entered email address has been created and assigned as contributor.');
}
}, error => {

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

@ -94,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', wasInvited: true });
req.flush({ contributorId: '123', isCreated: true });
expect(contributorAssignedDto!.contributorId).toEqual('123');
}));

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

@ -52,7 +52,7 @@ export class AppContributorDto extends Model {
export class ContributorAssignedDto {
constructor(
public readonly contributorId: string,
public readonly wasInvited: boolean
public readonly isCreated: boolean
) {
}
}
@ -93,7 +93,7 @@ export class AppContributorsService {
map(response => {
const body: any = response.payload.body;
const result = new ContributorAssignedDto(body.contributorId, body.wasInvited);
const result = new ContributorAssignedDto(body.contributorId, body.isCreated);
return new Versioned(response.version, result);
}),

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

@ -115,7 +115,7 @@ export class ContributorsState extends State<Snapshot> {
this.replaceContributors(contributors, dto.version);
return dto.payload.wasInvited;
return dto.payload.isCreated;
}),
catchError(error => {
if (Types.is(error, ErrorDto) && error.statusCode === 404) {

62
src/Squidex/appsettings.json

@ -58,22 +58,70 @@
*
* Supported: GoogleMaps, OSM
*/
"type": "OSM",
"googleMaps": {
/*
"type": "OSM",
"googleMaps": {
/*
* The optional google maps API key. CREATE YOUR OWN PLEASE.
*/
"key": "AIzaSyB_Z8l3nwUxZhMJykiDUJy6bSHXXlwcYMg"
"key": "AIzaSyB_Z8l3nwUxZhMJykiDUJy6bSHXXlwcYMg"
}
}
},
"email": {
"smtp": {
/*
* The host name to your email server.
*/
"server": "",
/*
* The sender email address.
*/
"sender": "hello@squidex.io",
/*
* The username to authenticate to your email server.
*/
"username": "",
/*
* The password to authenticate to your email server.
*/
"password": "",
/*
* Always use SSL if possible.
*/
"enableSsl": true,
/*
* The port to your email server.
*/
"port": 465
},
"invitations": {
/*
* The email subject when a new user is added as contributor.
*/
"newUserSubject": "You have been invited to join Project $APP_NAME at Squidex CMS",
/*
* The email body when a new user is added as contributor.
*/
"newUserBody": "Welcome to Squidex\r\nDear User,\r\n\r\n{{var:assigner_name}} ($ASSIGNER_EMAIL) has invited you to join Project (also called an App) {{var:app_name}} at Squidex Headless CMS. Login with your Github, Google or Microsoft credentials to create a new user account and start editing content now.\r\n\r\nThank you very much,\r\nThe Squidex Team\r\n\r\n<<Start now!>> [https://cloud.squidex.io]",
/*
* The email subject when an existing user is added as contributor.
*/
"existingUserSubject": "[Squidex CMS] You have been invited to App $APP_NAME",
/*
* The email body when an existing user is added as contributor.
*/
"existingUserBody": "You have been invited to join an App at Squidex CMS\n\nWelcome to Squidex\n$ASSIGNER_NAME ($ASSIGNER_EMAIL) has invited you to join app $APP_NAME at Squidex Headless CMS.\nLogin or reload the Management UI to see the app.\n\nThank you very much,\nThe Squidex Team\n<<Start now!>> [$UI_URL]"
}
}
},
"robots": {
/*
* The text for the robots.txt file
*/
"text": "User-agent: *\nAllow: /api/assets/*"
},
"text": "User-agent: *\nAllow: /api/assets/*"
},
"healthz": {
"gc": {

160
tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEmailEventConsumerTests.cs

@ -0,0 +1,160 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Domain.Apps.Events.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Log;
using Squidex.Shared.Users;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Apps.Invitation
{
public class InvitationEmailEventConsumerTests
{
private readonly IInvitationEmailSender emailSender = A.Fake<IInvitationEmailSender>();
private readonly IUserResolver userResolver = A.Fake<IUserResolver>();
private readonly IUser assigner = A.Fake<IUser>();
private readonly IUser assignee = A.Fake<IUser>();
private readonly ISemanticLog log = A.Fake<ISemanticLog>();
private readonly string assignerId = Guid.NewGuid().ToString();
private readonly string assigneeId = Guid.NewGuid().ToString();
private readonly string appName = "my-app";
private readonly InvitationEmailEventConsumer sut;
public InvitationEmailEventConsumerTests()
{
A.CallTo(() => emailSender.IsActive)
.Returns(true);
sut = new InvitationEmailEventConsumer(emailSender, userResolver, log);
}
[Fact]
public async Task Should_ignore_contributors_assigned_by_clients()
{
var @event = Envelope.Create(CreateEvent(RefTokenType.Client, true));
await sut.On(@event);
A.CallTo(() => userResolver.FindByIdOrEmailAsync(A<string>.Ignored))
.MustNotHaveHappened();
A.CallTo(() => emailSender.SendNewUserEmailAsync(A<IUser>.Ignored, A<IUser>.Ignored, appName))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_send_email_if_sender_not_active()
{
var @event = Envelope.Create(CreateEvent(RefTokenType.Subject, true));
A.CallTo(() => emailSender.IsActive)
.Returns(false);
A.CallTo(() => userResolver.FindByIdOrEmailAsync(assignerId))
.Returns(Task.FromResult<IUser>(null));
await sut.On(@event);
A.CallTo(() => userResolver.FindByIdOrEmailAsync(A<string>.Ignored))
.MustNotHaveHappened();
A.CallTo(() => emailSender.SendNewUserEmailAsync(A<IUser>.Ignored, A<IUser>.Ignored, appName))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_send_email_if_assigner_not_found()
{
var @event = Envelope.Create(CreateEvent(RefTokenType.Subject, true));
A.CallTo(() => userResolver.FindByIdOrEmailAsync(assignerId))
.Returns(Task.FromResult<IUser>(null));
await sut.On(@event);
A.CallTo(() => emailSender.SendNewUserEmailAsync(A<IUser>.Ignored, A<IUser>.Ignored, appName))
.MustNotHaveHappened();
MustLogWarning();
}
[Fact]
public async Task Should_not_send_email_if_assignee_not_found()
{
var @event = Envelope.Create(CreateEvent(RefTokenType.Subject, true));
A.CallTo(() => userResolver.FindByIdOrEmailAsync(assignerId))
.Returns(assigner);
A.CallTo(() => userResolver.FindByIdOrEmailAsync(assigneeId))
.Returns(Task.FromResult<IUser>(null));
await sut.On(@event);
A.CallTo(() => emailSender.SendNewUserEmailAsync(A<IUser>.Ignored, A<IUser>.Ignored, appName))
.MustNotHaveHappened();
MustLogWarning();
}
[Fact]
public async Task Should_send_email_for_new_user()
{
var @event = Envelope.Create(CreateEvent(RefTokenType.Subject, true));
A.CallTo(() => userResolver.FindByIdOrEmailAsync(assignerId))
.Returns(assigner);
A.CallTo(() => userResolver.FindByIdOrEmailAsync(assigneeId))
.Returns(assignee);
await sut.On(@event);
A.CallTo(() => emailSender.SendNewUserEmailAsync(assigner, assignee, appName))
.MustHaveHappened();
}
[Fact]
public async Task Should_send_email_for_existing_user()
{
var @event = Envelope.Create(CreateEvent(RefTokenType.Subject, false));
A.CallTo(() => userResolver.FindByIdOrEmailAsync(assignerId))
.Returns(assigner);
A.CallTo(() => userResolver.FindByIdOrEmailAsync(assigneeId))
.Returns(assignee);
await sut.On(@event);
A.CallTo(() => emailSender.SendExistingUserEmailAsync(assigner, assignee, appName))
.MustHaveHappened();
}
private void MustLogWarning()
{
A.CallTo(() => log.Log(SemanticLogLevel.Warning, A<None>.Ignored, A<Action<None, IObjectWriter>>.Ignored))
.MustHaveHappened();
}
private IEvent CreateEvent(string assignerType, bool isNew)
{
return new AppContributorAssigned
{
Actor = new RefToken(assignerType, assignerId),
AppId = new NamedId<Guid>(Guid.NewGuid(), appName),
ContributorId = assigneeId,
IsCreated = isNew
};
}
}
}

140
tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEmailSenderTests.cs

@ -0,0 +1,140 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
using FakeItEasy;
using Microsoft.Extensions.Options;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Email;
using Squidex.Infrastructure.Log;
using Squidex.Shared.Identity;
using Squidex.Shared.Users;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Apps.Invitation
{
public class InvitationEmailSenderTests
{
private readonly IEmailSender emailSender = A.Fake<IEmailSender>();
private readonly IEmailUrlGenerator emailUrlGenerator = A.Fake<IEmailUrlGenerator>();
private readonly IUser assigner = A.Fake<IUser>();
private readonly IUser assignee = A.Fake<IUser>();
private readonly ISemanticLog log = A.Fake<ISemanticLog>();
private readonly string appName = "my-app";
private readonly string uiUrl = "my-ui";
private readonly InvitationEmailTextOptions texts = new InvitationEmailTextOptions();
private readonly InvitationEmailSender sut;
public InvitationEmailSenderTests()
{
A.CallTo(() => assigner.Email)
.Returns("sebastian@squidex.io");
A.CallTo(() => assigner.Claims)
.Returns(new List<Claim> { new Claim(SquidexClaimTypes.DisplayName, "Sebastian Stehle") });
A.CallTo(() => assignee.Email)
.Returns("qaisar@squidex.io");
A.CallTo(() => assignee.Claims)
.Returns(new List<Claim> { new Claim(SquidexClaimTypes.DisplayName, "Qaisar Ahmad") });
A.CallTo(() => emailUrlGenerator.GenerateUIUrl())
.Returns(uiUrl);
sut = new InvitationEmailSender(Options.Create(texts), emailSender, emailUrlGenerator, log);
}
[Fact]
public async Task Should_format_assigner_email_and_send_email()
{
await TestFormattingAsync("Email: $ASSIGNER_EMAIL", "Email: sebastian@squidex.io");
}
[Fact]
public async Task Should_format_assignee_email_and_send_email()
{
await TestFormattingAsync("Email: $ASSIGNEE_EMAIL", "Email: qaisar@squidex.io");
}
[Fact]
public async Task Should_format_assigner_name_and_send_email()
{
await TestFormattingAsync("Name: $ASSIGNER_NAME", "Name: Sebastian Stehle");
}
[Fact]
public async Task Should_format_assignee_name_and_send_email()
{
await TestFormattingAsync("Name: $ASSIGNEE_NAME", "Name: Qaisar Ahmad");
}
[Fact]
public async Task Should_format_app_name_and_send_email()
{
await TestFormattingAsync("App: $APP_NAME", "App: my-app");
}
[Fact]
public async Task Should_format_ui_url_and_send_email()
{
await TestFormattingAsync("UI: $UI_URL", "UI: my-ui");
}
[Fact]
public async Task Should_not_send_email_if_texts_for_new_user_are_empty()
{
await sut.SendNewUserEmailAsync(assigner, assignee, appName);
A.CallTo(() => emailSender.SendAsync(assignee.Email, A<string>.Ignored, A<string>.Ignored))
.MustNotHaveHappened();
MustLogWarning();
}
[Fact]
public async Task Should_not_send_email_if_texts_for_existing_user_are_empty()
{
await sut.SendExistingUserEmailAsync(assigner, assignee, appName);
A.CallTo(() => emailSender.SendAsync(assignee.Email, A<string>.Ignored, A<string>.Ignored))
.MustNotHaveHappened();
MustLogWarning();
}
[Fact]
public async Task Should_send_email_for_existing_user()
{
texts.ExistingUserSubject = "email-subject";
texts.ExistingUserBody = "email-body";
await sut.SendExistingUserEmailAsync(assigner, assignee, appName);
A.CallTo(() => emailSender.SendAsync(assignee.Email, "email-subject", "email-body"))
.MustHaveHappened();
}
private async Task TestFormattingAsync(string pattern, string result)
{
texts.NewUserSubject = pattern;
texts.NewUserBody = pattern;
await sut.SendNewUserEmailAsync(assigner, assignee, appName);
A.CallTo(() => emailSender.SendAsync(assignee.Email, result, result))
.MustHaveHappened();
}
private void MustLogWarning()
{
A.CallTo(() => log.Log(SemanticLogLevel.Warning, A<None>.Ignored, A<Action<None, IObjectWriter>>.Ignored))
.MustHaveHappened();
}
}
}

12
tests/Squidex.Domain.Apps.Entities.Tests/Apps/InviteUserCommandMiddlewareTests.cs → tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InviteUserCommandMiddlewareTests.cs

@ -12,7 +12,7 @@ using Squidex.Infrastructure.Commands;
using Squidex.Shared.Users;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Apps
namespace Squidex.Domain.Apps.Entities.Apps.Invitation
{
public class InviteUserCommandMiddlewareTests
{
@ -31,7 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
var command = new AssignContributor { ContributorId = "me@email.com", IsInviting = true };
var context = new CommandContext(command, commandBus);
A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com"))
A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com", true))
.Returns(true);
var result = EntityCreatedResult.Create("13", 13L);
@ -42,7 +42,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
Assert.Same(context.Result<InvitedResult>().Id, result);
A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com"))
A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com", true))
.MustHaveHappened();
}
@ -52,7 +52,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
var command = new AssignContributor { ContributorId = "me@email.com", IsInviting = true };
var context = new CommandContext(command, commandBus);
A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com"))
A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com", true))
.Returns(false);
var result = EntityCreatedResult.Create("13", 13L);
@ -63,7 +63,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
Assert.Same(context.Result<EntityCreatedResult<string>>(), result);
A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com"))
A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com", true))
.MustHaveHappened();
}
@ -75,7 +75,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
await sut.HandleAsync(context);
A.CallTo(() => userResolver.CreateUserIfNotExists(A<string>.Ignored))
A.CallTo(() => userResolver.CreateUserIfNotExists(A<string>.Ignored, A<bool>.Ignored))
.MustNotHaveHappened();
}
}

10
tests/Squidex.Infrastructure.Tests/RefTokenTests.cs

@ -30,6 +30,16 @@ namespace Squidex.Infrastructure
Assert.Equal("client", token.Type);
Assert.Equal("client1", token.Identifier);
Assert.True(token.IsClient);
}
[Fact]
public void Should_instantiate_subject_token()
{
var token = new RefToken("subject", "client1");
Assert.True(token.IsSubject);
}
[Fact]

Loading…
Cancel
Save