From 9d4f884efda7e9af8f68e5397fa5da9dfab683b8 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sun, 21 Apr 2019 22:55:24 +0200 Subject: [PATCH 1/6] Invitations. --- .../HandleRules/EventEnricher.cs | 2 +- .../Apps/Commands/AssignContributor.cs | 2 + .../Invitiation/IInvitationEmailSender.cs | 19 ++++ .../InvitationEmailEventConsumer.cs | 97 +++++++++++++++++ .../Apps/Invitiation/InvitationEmailSender.cs | 102 ++++++++++++++++++ .../Invitiation/InvitationEmailTextOptions.cs | 20 ++++ .../InviteUserCommandMiddleware.cs | 6 +- .../Apps/{ => Invitiation}/InvitedResult.cs | 2 +- .../Backup/RestoreGrain.cs | 2 +- .../Contents/Text/GrainTextIndexer.cs | 3 +- .../IEmailUrlGenerator.cs | 14 +++ .../Rules/UsageTracking/UsageTrackerGrain.cs | 2 +- .../Apps/AppContributorAssigned.cs | 2 + .../CosmosDbEventStore_Reader.cs | 2 +- .../EventSourcing/CosmosDbSubscription.cs | 2 +- .../Email/IEmailSender.cs | 16 +++ .../Email/SmptOptions.cs | 33 ++++++ .../Email/SmtpEmailSender.cs | 42 ++++++++ .../Grains/EventConsumerGrain.cs | 2 +- src/Squidex.Infrastructure/RefToken.cs | 10 ++ src/Squidex.Web/Services/UrlGenerator.cs | 8 +- .../Apps/AppContributorsController.cs | 1 + .../Apps/Models/ContributorAssignedDto.cs | 6 +- src/Squidex/Config/Domain/EntitiesServices.cs | 23 +++- src/Squidex/appsettings.json | 61 +++++++++-- .../RefTokenTests.cs | 10 ++ 26 files changed, 466 insertions(+), 23 deletions(-) create mode 100644 src/Squidex.Domain.Apps.Entities/Apps/Invitiation/IInvitationEmailSender.cs create mode 100644 src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitationEmailEventConsumer.cs create mode 100644 src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitationEmailSender.cs create mode 100644 src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitationEmailTextOptions.cs rename src/Squidex.Domain.Apps.Entities/Apps/{ => Invitiation}/InviteUserCommandMiddleware.cs (83%) rename src/Squidex.Domain.Apps.Entities/Apps/{ => Invitiation}/InvitedResult.cs (90%) create mode 100644 src/Squidex.Domain.Apps.Entities/IEmailUrlGenerator.cs create mode 100644 src/Squidex.Infrastructure/Email/IEmailSender.cs create mode 100644 src/Squidex.Infrastructure/Email/SmptOptions.cs create mode 100644 src/Squidex.Infrastructure/Email/SmtpEmailSender.cs diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs index f685f1d7e..b8515b1c5 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs +++ b/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); } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs index e7504b1c8..e31a6a55d 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs +++ b/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; } } } \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/IInvitationEmailSender.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/IInvitationEmailSender.cs new file mode 100644 index 000000000..7f0b46c55 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/IInvitationEmailSender.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// 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.Invitiation +{ + public interface IInvitationEmailSender + { + Task SendNewUserEmailAsync(IUser assigner, IUser assignee, string appName); + + Task SendExistingUserEmailAsync(IUser assigner, IUser assignee, string appName); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitationEmailEventConsumer.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitationEmailEventConsumer.cs new file mode 100644 index 000000000..b338f67c1 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitationEmailEventConsumer.cs @@ -0,0 +1,97 @@ +// ========================================================================== +// 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.Invitiation +{ + 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 @event) + { + if (@event.Payload is AppContributorAssigned appContributorAssigned && appContributorAssigned.Actor.IsClient) + { + var assigner = await userResolver.FindByIdOrEmailAsync(appContributorAssigned.Actor.Identifier); + + if (assigner == null) + { + LogWarning($"Assigner {appContributorAssigned.Actor.Identifier} not found"); + return; + } + + var assignee = await userResolver.FindByIdOrEmailAsync(appContributorAssigned.ContributorId); + + if (assignee == null) + { + LogWarning($"Assignee {appContributorAssigned.ContributorId} 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)); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitationEmailSender.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitationEmailSender.cs new file mode 100644 index 000000000..009a22629 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitationEmailSender.cs @@ -0,0 +1,102 @@ +// ========================================================================== +// 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.Invitiation +{ + public sealed class InvitationEmailSender : IInvitationEmailSender + { + private readonly IEmailSender emailSender; + private readonly IEmailUrlGenerator emailUrlGenerator; + private readonly ISemanticLog log; + private readonly InvitationEmailTextOptions texts; + + public InvitationEmailSender( + IOptions 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.NewUserSubject, texts.NewUserBody, assigner, assignee, appName); + } + + public Task SendNewUserEmailAsync(IUser assigner, IUser assignee, string appName) + { + return SendEmailAsync(texts.ExistingUserBody, texts.ExistingUserSubject, assigner, assignee, appName); + } + + private async Task SendEmailAsync(string emailBody, string emailSubj, IUser assigner, IUser assignee, string appName) + { + if (string.IsNullOrWhiteSpace(texts.NewUserSubject)) + { + LogWarning("No email subject configured for new users"); + return; + } + + if (string.IsNullOrWhiteSpace(texts.NewUserBody)) + { + 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); + + text = text.Replace("$UI_URL", uiUrl); + + 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()); + } + + return text; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitationEmailTextOptions.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitationEmailTextOptions.cs new file mode 100644 index 000000000..240b3e0a8 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/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.Invitiation +{ + public sealed class InvitationEmailTextOptions + { + public string NewUserSubject { get; set; } + + public string NewUserBody { get; set; } + + public string ExistingUserSubject { get; set; } + + public string ExistingUserBody { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/InviteUserCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InviteUserCommandMiddleware.cs similarity index 83% rename from src/Squidex.Domain.Apps.Entities/Apps/InviteUserCommandMiddleware.cs rename to src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InviteUserCommandMiddleware.cs index a82031519..5f77288b7 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/InviteUserCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/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.Invitiation { 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); await next(); - if (isInvited && context.PlainResult is EntityCreatedResult id) + if (assignContributor.IsCreated && context.PlainResult is EntityCreatedResult id) { context.Complete(new InvitedResult { Id = id }); } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/InvitedResult.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitedResult.cs similarity index 90% rename from src/Squidex.Domain.Apps.Entities/Apps/InvitedResult.cs rename to src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitedResult.cs index 9b599b39c..59dcec9f4 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/InvitedResult.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitedResult.cs @@ -7,7 +7,7 @@ using Squidex.Infrastructure.Commands; -namespace Squidex.Domain.Apps.Entities.Apps +namespace Squidex.Domain.Apps.Entities.Apps.Invitiation { public sealed class InvitedResult { diff --git a/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs b/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs index 6f3ac3cb3..9ceec5592 100644 --- a/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs +++ b/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 { diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Text/GrainTextIndexer.cs b/src/Squidex.Domain.Apps.Entities/Contents/Text/GrainTextIndexer.cs index 5f349f651..e4869b4e3 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Text/GrainTextIndexer.cs +++ b/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 @event) diff --git a/src/Squidex.Domain.Apps.Entities/IEmailUrlGenerator.cs b/src/Squidex.Domain.Apps.Entities/IEmailUrlGenerator.cs new file mode 100644 index 000000000..98ee82477 --- /dev/null +++ b/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(); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs b/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs index ba7773c68..326e6d3fb 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs +++ b/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() diff --git a/src/Squidex.Domain.Apps.Events/Apps/AppContributorAssigned.cs b/src/Squidex.Domain.Apps.Events/Apps/AppContributorAssigned.cs index 63f4bba59..64e36ed6b 100644 --- a/src/Squidex.Domain.Apps.Events/Apps/AppContributorAssigned.cs +++ b/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; } } } diff --git a/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Reader.cs b/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Reader.cs index e0cccf559..ada171f2a 100644 --- a/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Reader.cs +++ b/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> QueryAsync(string streamName, long streamPosition = 0) diff --git a/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbSubscription.cs b/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbSubscription.cs index fa5d8af86..2beb47a61 100644 --- a/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbSubscription.cs +++ b/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbSubscription.cs @@ -103,7 +103,7 @@ namespace Squidex.Infrastructure.EventSourcing public Task OpenAsync(IChangeFeedObserverContext context) { - return Task.CompletedTask; + return TaskHelper.Done; } public async Task ProcessChangesAsync(IChangeFeedObserverContext context, IReadOnlyList docs, CancellationToken cancellationToken) diff --git a/src/Squidex.Infrastructure/Email/IEmailSender.cs b/src/Squidex.Infrastructure/Email/IEmailSender.cs new file mode 100644 index 000000000..dbacfd19d --- /dev/null +++ b/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 text); + } +} diff --git a/src/Squidex.Infrastructure/Email/SmptOptions.cs b/src/Squidex.Infrastructure/Email/SmptOptions.cs new file mode 100644 index 000000000..847d9f358 --- /dev/null +++ b/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); + } + } +} diff --git a/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs b/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs new file mode 100644 index 000000000..1c73283f3 --- /dev/null +++ b/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 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); + } + } +} diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs index 80fcd9435..f37a9200f 100644 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs +++ b/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> GetStateAsync() diff --git a/src/Squidex.Infrastructure/RefToken.cs b/src/Squidex.Infrastructure/RefToken.cs index 5d0c1d215..1d01ba267 100644 --- a/src/Squidex.Infrastructure/RefToken.cs +++ b/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)); diff --git a/src/Squidex.Web/Services/UrlGenerator.cs b/src/Squidex.Web/Services/UrlGenerator.cs index ef2bab32d..5600beaaf 100644 --- a/src/Squidex.Web/Services/UrlGenerator.cs +++ b/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); diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs index d8af9f53b..8b512de21 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs +++ b/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.Invitiation; using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Infrastructure.Commands; using Squidex.Shared; diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorAssignedDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorAssignedDto.cs index 80d5255d4..871fcc6e9 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorAssignedDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorAssignedDto.cs @@ -20,11 +20,11 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// /// Indicates if the user was created. /// - 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 }; } } } diff --git a/src/Squidex/Config/Domain/EntitiesServices.cs b/src/Squidex/Config/Domain/EntitiesServices.cs index c99f44e15..84a6e1de3 100644 --- a/src/Squidex/Config/Domain/EntitiesServices.cs +++ b/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.Invitiation; 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>(), c.GetRequiredService(), exposeSourceUrl)) - .As().As().As(); + .As().As().As().As(); services.AddSingletonAs() .As().As(); @@ -147,6 +149,25 @@ namespace Squidex.Config.Domain return result; }); + + var emailOptions = config.GetValue("email:smtp"); + + if (emailOptions.IsConfigured()) + { + services.AddSingleton(Options.Create(emailOptions)); + + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .AsOptional(); + + services.Configure( + config.GetSection("email:invitations")); + } } private static void AddCommandPipeline(this IServiceCollection services) diff --git a/src/Squidex/appsettings.json b/src/Squidex/appsettings.json index 99f85ff90..28ad6cdc3 100644 --- a/src/Squidex/appsettings.json +++ b/src/Squidex/appsettings.json @@ -58,22 +58,69 @@ * * 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": 587 + }, + "invitiations": { + /* + * The email subject when a new user is added as contributor. + */ + "newUserSubject": "Welcome to Squidex, you have been invited to app $APP_NAME", + /* + * The email body when a new user is added as contributor. + */ + "newUserBody": "Welcome\n\nYou have been invited to Squidex CMS by $ASSIGNER_NAME ($ASSIGNER_EMAIL) and to app $", + /* + * 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": "" } - } }, "robots": { /* * The text for the robots.txt file */ - "text": "User-agent: *\nAllow: /api/assets/*" - }, + "text": "User-agent: *\nAllow: /api/assets/*" + }, "healthz": { "gc": { diff --git a/tests/Squidex.Infrastructure.Tests/RefTokenTests.cs b/tests/Squidex.Infrastructure.Tests/RefTokenTests.cs index b7ddf5d99..355aba360 100644 --- a/tests/Squidex.Infrastructure.Tests/RefTokenTests.cs +++ b/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] From d9c71c04286c69f91a239ed9eb5e866a1ab7ef64 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sun, 21 Apr 2019 22:57:44 +0200 Subject: [PATCH 2/6] Namespace fixed. --- .../Apps/{Invitiation => Invitation}/IInvitationEmailSender.cs | 2 +- .../{Invitiation => Invitation}/InvitationEmailEventConsumer.cs | 2 +- .../Apps/{Invitiation => Invitation}/InvitationEmailSender.cs | 2 +- .../{Invitiation => Invitation}/InvitationEmailTextOptions.cs | 2 +- .../{Invitiation => Invitation}/InviteUserCommandMiddleware.cs | 2 +- .../Apps/{Invitiation => Invitation}/InvitedResult.cs | 2 +- .../EventSourcing/CosmosDbSubscription.cs | 1 + .../Areas/Api/Controllers/Apps/AppContributorsController.cs | 2 +- src/Squidex/Config/Domain/EntitiesServices.cs | 2 +- 9 files changed, 9 insertions(+), 8 deletions(-) rename src/Squidex.Domain.Apps.Entities/Apps/{Invitiation => Invitation}/IInvitationEmailSender.cs (92%) rename src/Squidex.Domain.Apps.Entities/Apps/{Invitiation => Invitation}/InvitationEmailEventConsumer.cs (98%) rename src/Squidex.Domain.Apps.Entities/Apps/{Invitiation => Invitation}/InvitationEmailSender.cs (98%) rename src/Squidex.Domain.Apps.Entities/Apps/{Invitiation => Invitation}/InvitationEmailTextOptions.cs (92%) rename src/Squidex.Domain.Apps.Entities/Apps/{Invitiation => Invitation}/InviteUserCommandMiddleware.cs (96%) rename src/Squidex.Domain.Apps.Entities/Apps/{Invitiation => Invitation}/InvitedResult.cs (90%) diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/IInvitationEmailSender.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/IInvitationEmailSender.cs similarity index 92% rename from src/Squidex.Domain.Apps.Entities/Apps/Invitiation/IInvitationEmailSender.cs rename to src/Squidex.Domain.Apps.Entities/Apps/Invitation/IInvitationEmailSender.cs index 7f0b46c55..799242fb4 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/IInvitationEmailSender.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/IInvitationEmailSender.cs @@ -8,7 +8,7 @@ using System.Threading.Tasks; using Squidex.Shared.Users; -namespace Squidex.Domain.Apps.Entities.Apps.Invitiation +namespace Squidex.Domain.Apps.Entities.Apps.Invitation { public interface IInvitationEmailSender { diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitationEmailEventConsumer.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs similarity index 98% rename from src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitationEmailEventConsumer.cs rename to src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs index b338f67c1..28999ecb9 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitationEmailEventConsumer.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs @@ -13,7 +13,7 @@ using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Tasks; using Squidex.Shared.Users; -namespace Squidex.Domain.Apps.Entities.Apps.Invitiation +namespace Squidex.Domain.Apps.Entities.Apps.Invitation { public sealed class InvitationEmailEventConsumer : IEventConsumer { diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitationEmailSender.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs similarity index 98% rename from src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitationEmailSender.cs rename to src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs index 009a22629..62cf4a73c 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitationEmailSender.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs @@ -12,7 +12,7 @@ using Squidex.Infrastructure.Email; using Squidex.Infrastructure.Log; using Squidex.Shared.Users; -namespace Squidex.Domain.Apps.Entities.Apps.Invitiation +namespace Squidex.Domain.Apps.Entities.Apps.Invitation { public sealed class InvitationEmailSender : IInvitationEmailSender { diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitationEmailTextOptions.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailTextOptions.cs similarity index 92% rename from src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitationEmailTextOptions.cs rename to src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailTextOptions.cs index 240b3e0a8..8c564626d 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitationEmailTextOptions.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailTextOptions.cs @@ -5,7 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -namespace Squidex.Domain.Apps.Entities.Apps.Invitiation +namespace Squidex.Domain.Apps.Entities.Apps.Invitation { public sealed class InvitationEmailTextOptions { diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InviteUserCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs similarity index 96% rename from src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InviteUserCommandMiddleware.cs rename to src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs index 5f77288b7..a7d6007c9 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InviteUserCommandMiddleware.cs +++ b/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.Invitiation +namespace Squidex.Domain.Apps.Entities.Apps.Invitation { public sealed class InviteUserCommandMiddleware : ICommandMiddleware { diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitedResult.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitedResult.cs similarity index 90% rename from src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitedResult.cs rename to src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitedResult.cs index 59dcec9f4..695be0a4b 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Invitiation/InvitedResult.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitedResult.cs @@ -7,7 +7,7 @@ using Squidex.Infrastructure.Commands; -namespace Squidex.Domain.Apps.Entities.Apps.Invitiation +namespace Squidex.Domain.Apps.Entities.Apps.Invitation { public sealed class InvitedResult { diff --git a/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbSubscription.cs b/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbSubscription.cs index 2beb47a61..d2bb4b9b2 100644 --- a/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbSubscription.cs +++ b/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; diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs index 8b512de21..a4894e306 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs @@ -11,7 +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.Invitiation; +using Squidex.Domain.Apps.Entities.Apps.Invitation; using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Infrastructure.Commands; using Squidex.Shared; diff --git a/src/Squidex/Config/Domain/EntitiesServices.cs b/src/Squidex/Config/Domain/EntitiesServices.cs index 84a6e1de3..ea4130141 100644 --- a/src/Squidex/Config/Domain/EntitiesServices.cs +++ b/src/Squidex/Config/Domain/EntitiesServices.cs @@ -22,7 +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.Invitiation; +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; From ff3ee5e3ab2240126bb0200f5f68057744fccf2b Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Mon, 22 Apr 2019 11:42:35 +0200 Subject: [PATCH 3/6] Tests and fixes for invitation system. --- .../InvitationEmailEventConsumer.cs | 11 +- .../Apps/Invitation/InvitationEmailSender.cs | 10 +- src/Squidex/appsettings.json | 4 +- .../InvitationEmailEventConsumerTests.cs | 137 +++++++++++++++++ .../Invitation/InvitationEmailSenderTests.cs | 140 ++++++++++++++++++ .../InviteUserCommandMiddlewareTests.cs | 2 +- 6 files changed, 292 insertions(+), 12 deletions(-) create mode 100644 tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEmailEventConsumerTests.cs create mode 100644 tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEmailSenderTests.cs rename tests/Squidex.Domain.Apps.Entities.Tests/Apps/{ => Invitation}/InviteUserCommandMiddlewareTests.cs (98%) diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs index 28999ecb9..58111c21f 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs @@ -55,13 +55,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation public async Task On(Envelope @event) { - if (@event.Payload is AppContributorAssigned appContributorAssigned && appContributorAssigned.Actor.IsClient) + if (@event.Payload is AppContributorAssigned appContributorAssigned && appContributorAssigned.Actor.IsSubject) { - var assigner = await userResolver.FindByIdOrEmailAsync(appContributorAssigned.Actor.Identifier); + var assignerId = appContributorAssigned.Actor.Identifier; + var assigneeId = appContributorAssigned.ContributorId; + + var assigner = await userResolver.FindByIdOrEmailAsync(assignerId); if (assigner == null) { - LogWarning($"Assigner {appContributorAssigned.Actor.Identifier} not found"); + LogWarning($"Assigner {assignerId} not found"); return; } @@ -69,7 +72,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation if (assignee == null) { - LogWarning($"Assignee {appContributorAssigned.ContributorId} not found"); + LogWarning($"Assignee {assigneeId} not found"); return; } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs index 62cf4a73c..ac92cdc63 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs @@ -40,23 +40,23 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation public Task SendExistingUserEmailAsync(IUser assigner, IUser assignee, string appName) { - return SendEmailAsync(texts.NewUserSubject, texts.NewUserBody, assigner, assignee, appName); + return SendEmailAsync(texts.ExistingUserSubject, texts.ExistingUserBody, assigner, assignee, appName); } public Task SendNewUserEmailAsync(IUser assigner, IUser assignee, string appName) { - return SendEmailAsync(texts.ExistingUserBody, texts.ExistingUserSubject, assigner, assignee, appName); + return SendEmailAsync(texts.NewUserBody, texts.NewUserSubject, assigner, assignee, appName); } - private async Task SendEmailAsync(string emailBody, string emailSubj, IUser assigner, IUser assignee, string appName) + private async Task SendEmailAsync(string emailSubj, string emailBody, IUser assigner, IUser assignee, string appName) { - if (string.IsNullOrWhiteSpace(texts.NewUserSubject)) + if (string.IsNullOrWhiteSpace(emailBody)) { LogWarning("No email subject configured for new users"); return; } - if (string.IsNullOrWhiteSpace(texts.NewUserBody)) + if (string.IsNullOrWhiteSpace(emailSubj)) { LogWarning("No email body configured for new users"); return; diff --git a/src/Squidex/appsettings.json b/src/Squidex/appsettings.json index 28ad6cdc3..0c54dad3d 100644 --- a/src/Squidex/appsettings.json +++ b/src/Squidex/appsettings.json @@ -103,7 +103,7 @@ /* * The email body when a new user is added as contributor. */ - "newUserBody": "Welcome\n\nYou have been invited to Squidex CMS by $ASSIGNER_NAME ($ASSIGNER_EMAIL) and to app $", + "newUserBody": "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 with your Github, Google or Microsoft account to create a new user account and start editing content now.\n\nThank you very much,\nThe Squidex Team\n<> [$UI_URL]", /* * The email subject when an existing user is added as contributor. */ @@ -111,7 +111,7 @@ /* * The email body when an existing user is added as contributor. */ - "existingUserBody": "" + "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<> [$UI_URL]" } }, diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEmailEventConsumerTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEmailEventConsumerTests.cs new file mode 100644 index 000000000..c2134ea1a --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEmailEventConsumerTests.cs @@ -0,0 +1,137 @@ +// ========================================================================== +// 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(); + private readonly IUserResolver userResolver = A.Fake(); + private readonly IUser assigner = A.Fake(); + private readonly IUser assignee = A.Fake(); + private readonly ISemanticLog log = A.Fake(); + 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() + { + 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.Ignored)) + .MustNotHaveHappened(); + + A.CallTo(() => emailSender.SendNewUserEmailAsync(A.Ignored, A.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(null)); + + await sut.On(@event); + + A.CallTo(() => emailSender.SendNewUserEmailAsync(A.Ignored, A.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(null)); + + await sut.On(@event); + + A.CallTo(() => emailSender.SendNewUserEmailAsync(A.Ignored, A.Ignored, appName)) + .MustNotHaveHappened(); + + MustLogWarning(); + } + + [Fact] + public async Task Should_send_email_for_new_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.SendNewUserEmailAsync(assigner, assignee, appName)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_send_email_for_existing_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.SendExistingUserEmailAsync(assigner, assignee, appName)) + .MustNotHaveHappened(); + } + + private void MustLogWarning() + { + A.CallTo(() => log.Log(SemanticLogLevel.Warning, A.Ignored, A>.Ignored)) + .MustHaveHappened(); + } + + private IEvent CreateEvent(string assignerType, bool isNew) + { + return new AppContributorAssigned + { + Actor = new RefToken(assignerType, assignerId), + AppId = new NamedId(Guid.NewGuid(), appName), + ContributorId = assigneeId, + IsCreated = isNew + }; + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEmailSenderTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEmailSenderTests.cs new file mode 100644 index 000000000..9c50f13fc --- /dev/null +++ b/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(); + private readonly IEmailUrlGenerator emailUrlGenerator = A.Fake(); + private readonly IUser assigner = A.Fake(); + private readonly IUser assignee = A.Fake(); + private readonly ISemanticLog log = A.Fake(); + 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 { new Claim(SquidexClaimTypes.DisplayName, "Sebastian Stehle") }); + + A.CallTo(() => assignee.Email) + .Returns("qaisar@squidex.io"); + A.CallTo(() => assignee.Claims) + .Returns(new List { 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.Ignored, A.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.Ignored, A.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.Ignored, A>.Ignored)) + .MustHaveHappened(); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/InviteUserCommandMiddlewareTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InviteUserCommandMiddlewareTests.cs similarity index 98% rename from tests/Squidex.Domain.Apps.Entities.Tests/Apps/InviteUserCommandMiddlewareTests.cs rename to tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InviteUserCommandMiddlewareTests.cs index 9db5dca19..3e71cfee5 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/InviteUserCommandMiddlewareTests.cs +++ b/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 { From e0391bb5bb611fdc6bbdd8e5ffee35057bb87552 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Mon, 22 Apr 2019 12:37:53 +0200 Subject: [PATCH 4/6] Noop sender. --- .../Apps/Invitation/IInvitationEmailSender.cs | 2 ++ .../InvitationEmailEventConsumer.cs | 5 +++ .../Apps/Invitation/InvitationEmailSender.cs | 5 +++ .../Invitation/NoopInvitationEmailSender.cs | 31 +++++++++++++++++++ src/Squidex/Config/Domain/EntitiesServices.cs | 17 ++++++---- src/Squidex/appsettings.json | 3 +- .../InvitationEmailEventConsumerTests.cs | 23 ++++++++++++++ 7 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 src/Squidex.Domain.Apps.Entities/Apps/Invitation/NoopInvitationEmailSender.cs diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/IInvitationEmailSender.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/IInvitationEmailSender.cs index 799242fb4..536ac9c07 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/IInvitationEmailSender.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/IInvitationEmailSender.cs @@ -12,6 +12,8 @@ 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); diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs index 58111c21f..01211fcd2 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs @@ -55,6 +55,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation public async Task On(Envelope @event) { + if (!emailSender.IsActive) + { + return; + } + if (@event.Payload is AppContributorAssigned appContributorAssigned && appContributorAssigned.Actor.IsSubject) { var assignerId = appContributorAssigned.Actor.Identifier; diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs index ac92cdc63..867111fb8 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs @@ -21,6 +21,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation private readonly ISemanticLog log; private readonly InvitationEmailTextOptions texts; + public bool IsActive + { + get { return true; } + } + public InvitationEmailSender( IOptions texts, IEmailSender emailSender, diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/NoopInvitationEmailSender.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/NoopInvitationEmailSender.cs new file mode 100644 index 000000000..899d8bdb6 --- /dev/null +++ b/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; + } + } +} diff --git a/src/Squidex/Config/Domain/EntitiesServices.cs b/src/Squidex/Config/Domain/EntitiesServices.cs index ea4130141..df0d018fd 100644 --- a/src/Squidex/Config/Domain/EntitiesServices.cs +++ b/src/Squidex/Config/Domain/EntitiesServices.cs @@ -156,18 +156,23 @@ namespace Squidex.Config.Domain { services.AddSingleton(Options.Create(emailOptions)); + services.Configure( + config.GetSection("email:invitations")); + services.AddSingletonAs() .As(); - services.AddSingletonAs() - .As(); - services.AddSingletonAs() .AsOptional(); - - services.Configure( - config.GetSection("email:invitations")); } + else + { + services.AddSingletonAs() + .AsOptional(); + } + + services.AddSingletonAs() + .As(); } private static void AddCommandPipeline(this IServiceCollection services) diff --git a/src/Squidex/appsettings.json b/src/Squidex/appsettings.json index 0c54dad3d..afb699604 100644 --- a/src/Squidex/appsettings.json +++ b/src/Squidex/appsettings.json @@ -89,11 +89,12 @@ /* * Always use SSL if possible. */ + "enableSsl": true, /* * The port to your email server. */ - "port": 587 + "port": 465 }, "invitiations": { /* diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEmailEventConsumerTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEmailEventConsumerTests.cs index c2134ea1a..90d799c39 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEmailEventConsumerTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEmailEventConsumerTests.cs @@ -31,6 +31,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation public InvitationEmailEventConsumerTests() { + A.CallTo(() => emailSender.IsActive) + .Returns(true); + sut = new InvitationEmailEventConsumer(emailSender, userResolver, log); } @@ -48,6 +51,26 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation .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(null)); + + await sut.On(@event); + + A.CallTo(() => userResolver.FindByIdOrEmailAsync(A.Ignored)) + .MustNotHaveHappened(); + + A.CallTo(() => emailSender.SendNewUserEmailAsync(A.Ignored, A.Ignored, appName)) + .MustNotHaveHappened(); + } + [Fact] public async Task Should_not_send_email_if_assigner_not_found() { From afd1d65810d41dece4a41eb56315809212e5386e Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Tue, 23 Apr 2019 17:21:51 +0200 Subject: [PATCH 5/6] Finalized. --- .../Apps/Invitation/InvitationEmailEventConsumer.cs | 4 +++- .../Apps/Invitation/InvitationEmailSender.cs | 4 ++-- src/Squidex.Domain.Users/DefaultUserResolver.cs | 6 ++++-- src/Squidex.Domain.Users/UserValues.cs | 7 +++++++ src/Squidex.Shared/Identity/SquidexClaimTypes.cs | 2 ++ src/Squidex.Shared/Users/IUserResolver.cs | 2 +- src/Squidex.Shared/Users/UserExtensions.cs | 5 +++++ src/Squidex/Config/Domain/EntitiesServices.cs | 2 +- .../pages/contributors/contributors-page.component.ts | 4 ++-- .../shared/services/app-contributors.service.spec.ts | 2 +- .../app/shared/services/app-contributors.service.ts | 4 ++-- src/Squidex/app/shared/state/contributors.state.ts | 2 +- src/Squidex/appsettings.json | 10 +++++----- .../Invitation/InviteUserCommandMiddlewareTests.cs | 10 +++++----- 14 files changed, 41 insertions(+), 23 deletions(-) diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs index 01211fcd2..35fa0e12f 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs @@ -60,7 +60,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation return; } - if (@event.Payload is AppContributorAssigned appContributorAssigned && appContributorAssigned.Actor.IsSubject) + if (@event.Payload is AppContributorAssigned appContributorAssigned && + appContributorAssigned.IsCreated && + appContributorAssigned.Actor.IsSubject) { var assignerId = appContributorAssigned.Actor.Identifier; var assigneeId = appContributorAssigned.ContributorId; diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs index 867111fb8..c4ad43977 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs @@ -45,12 +45,12 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation public Task SendExistingUserEmailAsync(IUser assigner, IUser assignee, string appName) { - return SendEmailAsync(texts.ExistingUserSubject, texts.ExistingUserBody, assigner, assignee, appName); + return SendEmailAsync(texts.ExistingUserBody, texts.ExistingUserSubject, assigner, assignee, appName); } public Task SendNewUserEmailAsync(IUser assigner, IUser assignee, string appName) { - return SendEmailAsync(texts.NewUserBody, texts.NewUserSubject, assigner, assignee, appName); + return SendEmailAsync(texts.NewUserSubject, texts.NewUserBody, assigner, assignee, appName); } private async Task SendEmailAsync(string emailSubj, string emailBody, IUser assigner, IUser assignee, string appName) diff --git a/src/Squidex.Domain.Users/DefaultUserResolver.cs b/src/Squidex.Domain.Users/DefaultUserResolver.cs index ac67f7827..49723af01 100644 --- a/src/Squidex.Domain.Users/DefaultUserResolver.cs +++ b/src/Squidex.Domain.Users/DefaultUserResolver.cs @@ -28,7 +28,7 @@ namespace Squidex.Domain.Users this.userFactory = userFactory; } - public async Task CreateUserIfNotExists(string email) + public async Task 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; diff --git a/src/Squidex.Domain.Users/UserValues.cs b/src/Squidex.Domain.Users/UserValues.cs index e4ce18662..2dc1baac5 100644 --- a/src/Squidex.Domain.Users/UserValues.cs +++ b/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()); diff --git a/src/Squidex.Shared/Identity/SquidexClaimTypes.cs b/src/Squidex.Shared/Identity/SquidexClaimTypes.cs index 32ba4f90c..7112350b8 100644 --- a/src/Squidex.Shared/Identity/SquidexClaimTypes.cs +++ b/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"; diff --git a/src/Squidex.Shared/Users/IUserResolver.cs b/src/Squidex.Shared/Users/IUserResolver.cs index dd86bb5d1..29f3b541a 100644 --- a/src/Squidex.Shared/Users/IUserResolver.cs +++ b/src/Squidex.Shared/Users/IUserResolver.cs @@ -12,7 +12,7 @@ namespace Squidex.Shared.Users { public interface IUserResolver { - Task CreateUserIfNotExists(string email); + Task CreateUserIfNotExists(string email, bool invited = false); Task FindByIdOrEmailAsync(string idOrEmail); diff --git a/src/Squidex.Shared/Users/UserExtensions.cs b/src/Squidex.Shared/Users/UserExtensions.cs index 52584f671..52f5a5de5 100644 --- a/src/Squidex.Shared/Users/UserExtensions.cs +++ b/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"); diff --git a/src/Squidex/Config/Domain/EntitiesServices.cs b/src/Squidex/Config/Domain/EntitiesServices.cs index df0d018fd..28f6665f2 100644 --- a/src/Squidex/Config/Domain/EntitiesServices.cs +++ b/src/Squidex/Config/Domain/EntitiesServices.cs @@ -150,7 +150,7 @@ namespace Squidex.Config.Domain return result; }); - var emailOptions = config.GetValue("email:smtp"); + var emailOptions = config.GetSection("email:smtp").Get(); if (emailOptions.IsConfigured()) { diff --git a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts b/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts index b540f1b75..61d77c7b8 100644 --- a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts +++ b/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 => { diff --git a/src/Squidex/app/shared/services/app-contributors.service.spec.ts b/src/Squidex/app/shared/services/app-contributors.service.spec.ts index 76106a334..fda899774 100644 --- a/src/Squidex/app/shared/services/app-contributors.service.spec.ts +++ b/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'); })); diff --git a/src/Squidex/app/shared/services/app-contributors.service.ts b/src/Squidex/app/shared/services/app-contributors.service.ts index 766d8c08e..c67966b79 100644 --- a/src/Squidex/app/shared/services/app-contributors.service.ts +++ b/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); }), diff --git a/src/Squidex/app/shared/state/contributors.state.ts b/src/Squidex/app/shared/state/contributors.state.ts index 1531598e6..58034970b 100644 --- a/src/Squidex/app/shared/state/contributors.state.ts +++ b/src/Squidex/app/shared/state/contributors.state.ts @@ -115,7 +115,7 @@ export class ContributorsState extends State { this.replaceContributors(contributors, dto.version); - return dto.payload.wasInvited; + return dto.payload.isCreated; }), catchError(error => { if (Types.is(error, ErrorDto) && error.statusCode === 404) { diff --git a/src/Squidex/appsettings.json b/src/Squidex/appsettings.json index afb699604..cc836887c 100644 --- a/src/Squidex/appsettings.json +++ b/src/Squidex/appsettings.json @@ -96,23 +96,23 @@ */ "port": 465 }, - "invitiations": { + "invitations": { /* * The email subject when a new user is added as contributor. */ - "newUserSubject": "Welcome to Squidex, you have been invited to app $APP_NAME", + "newUserSubject": "Welcome to Squidex, you have been invited to join Project $APP_NAME", /* * The email body when a new user is added as contributor. */ - "newUserBody": "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 with your Github, Google or Microsoft account to create a new user account and start editing content now.\n\nThank you very much,\nThe Squidex Team\n<> [$UI_URL]", + "newUserBody": "You have been invited to join a Project 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 with your Github, Google or Microsoft credentials to create a new user account and start editing content now.\n\nThank you very much,\nThe Squidex Team\n<> [$UI_URL]", /* * The email subject when an existing user is added as contributor. */ - "existingUserSubject": "[Squidex CMS] You have been invited to app $APP_NAME", + "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<> [$UI_URL]" + "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<> [$UI_URL]" } }, diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InviteUserCommandMiddlewareTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InviteUserCommandMiddlewareTests.cs index 3e71cfee5..24e01634a 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InviteUserCommandMiddlewareTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InviteUserCommandMiddlewareTests.cs @@ -31,7 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation 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.Invitation Assert.Same(context.Result().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.Invitation 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.Invitation Assert.Same(context.Result>(), 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.Invitation await sut.HandleAsync(context); - A.CallTo(() => userResolver.CreateUserIfNotExists(A.Ignored)) + A.CallTo(() => userResolver.CreateUserIfNotExists(A.Ignored, A.Ignored)) .MustNotHaveHappened(); } } From 734c6a6876d6093684a8590233b33b8970c09f18 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Tue, 23 Apr 2019 18:58:41 +0200 Subject: [PATCH 6/6] Latest fixes for emails. --- .../Apps/Invitation/InvitationEmailEventConsumer.cs | 4 +--- .../Apps/Invitation/InvitationEmailSender.cs | 6 +++--- .../Apps/Invitation/InviteUserCommandMiddleware.cs | 2 +- src/Squidex.Infrastructure/Email/IEmailSender.cs | 2 +- src/Squidex/appsettings.json | 4 ++-- .../Apps/Invitation/InvitationEmailEventConsumerTests.cs | 8 ++++---- 6 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs index 35fa0e12f..01211fcd2 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs @@ -60,9 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation return; } - if (@event.Payload is AppContributorAssigned appContributorAssigned && - appContributorAssigned.IsCreated && - appContributorAssigned.Actor.IsSubject) + if (@event.Payload is AppContributorAssigned appContributorAssigned && appContributorAssigned.Actor.IsSubject) { var assignerId = appContributorAssigned.Actor.Identifier; var assigneeId = appContributorAssigned.ContributorId; diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs index c4ad43977..21ffd7c0b 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs @@ -45,7 +45,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation public Task SendExistingUserEmailAsync(IUser assigner, IUser assignee, string appName) { - return SendEmailAsync(texts.ExistingUserBody, texts.ExistingUserSubject, assigner, assignee, appName); + return SendEmailAsync(texts.ExistingUserSubject, texts.ExistingUserBody, assigner, assignee, appName); } public Task SendNewUserEmailAsync(IUser assigner, IUser assignee, string appName) @@ -87,8 +87,6 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation { text = text.Replace("$APP_NAME", appName); - text = text.Replace("$UI_URL", uiUrl); - if (assigner != null) { text = text.Replace("$ASSIGNER_EMAIL", assigner.Email); @@ -101,6 +99,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation text = text.Replace("$ASSIGNEE_NAME", assignee.DisplayName()); } + text = text.Replace("$UI_URL", uiUrl); + return text; } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs index a7d6007c9..0bf99f271 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs @@ -31,7 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation { if (assignContributor.IsInviting && assignContributor.ContributorId.IsEmail()) { - assignContributor.IsCreated = await userResolver.CreateUserIfNotExists(assignContributor.ContributorId); + assignContributor.IsCreated = await userResolver.CreateUserIfNotExists(assignContributor.ContributorId, true); await next(); diff --git a/src/Squidex.Infrastructure/Email/IEmailSender.cs b/src/Squidex.Infrastructure/Email/IEmailSender.cs index dbacfd19d..6fa4e5cd8 100644 --- a/src/Squidex.Infrastructure/Email/IEmailSender.cs +++ b/src/Squidex.Infrastructure/Email/IEmailSender.cs @@ -11,6 +11,6 @@ namespace Squidex.Infrastructure.Email { public interface IEmailSender { - Task SendAsync(string recipient, string subject, string text); + Task SendAsync(string recipient, string subject, string body); } } diff --git a/src/Squidex/appsettings.json b/src/Squidex/appsettings.json index cc836887c..c244a8b2b 100644 --- a/src/Squidex/appsettings.json +++ b/src/Squidex/appsettings.json @@ -100,11 +100,11 @@ /* * The email subject when a new user is added as contributor. */ - "newUserSubject": "Welcome to Squidex, you have been invited to join Project $APP_NAME", + "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": "You have been invited to join a Project 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 with your Github, Google or Microsoft credentials to create a new user account and start editing content now.\n\nThank you very much,\nThe Squidex Team\n<> [$UI_URL]", + "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<> [https://cloud.squidex.io]", /* * The email subject when an existing user is added as contributor. */ diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEmailEventConsumerTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEmailEventConsumerTests.cs index 90d799c39..a5e94c8f7 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEmailEventConsumerTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEmailEventConsumerTests.cs @@ -109,7 +109,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation [Fact] public async Task Should_send_email_for_new_user() { - var @event = Envelope.Create(CreateEvent(RefTokenType.Subject, false)); + var @event = Envelope.Create(CreateEvent(RefTokenType.Subject, true)); A.CallTo(() => userResolver.FindByIdOrEmailAsync(assignerId)) .Returns(assigner); @@ -120,13 +120,13 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation await sut.On(@event); A.CallTo(() => emailSender.SendNewUserEmailAsync(assigner, assignee, appName)) - .MustNotHaveHappened(); + .MustHaveHappened(); } [Fact] public async Task Should_send_email_for_existing_user() { - var @event = Envelope.Create(CreateEvent(RefTokenType.Subject, true)); + var @event = Envelope.Create(CreateEvent(RefTokenType.Subject, false)); A.CallTo(() => userResolver.FindByIdOrEmailAsync(assignerId)) .Returns(assigner); @@ -137,7 +137,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation await sut.On(@event); A.CallTo(() => emailSender.SendExistingUserEmailAsync(assigner, assignee, appName)) - .MustNotHaveHappened(); + .MustHaveHappened(); } private void MustLogWarning()