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/Invitation/IInvitationEmailSender.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/IInvitationEmailSender.cs new file mode 100644 index 000000000..536ac9c07 --- /dev/null +++ b/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); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailEventConsumer.cs new file mode 100644 index 000000000..01211fcd2 --- /dev/null +++ b/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 @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)); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailSender.cs new file mode 100644 index 000000000..21ffd7c0b --- /dev/null +++ b/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 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; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailTextOptions.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEmailTextOptions.cs new file mode 100644 index 000000000..8c564626d --- /dev/null +++ b/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; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/InviteUserCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs similarity index 83% rename from src/Squidex.Domain.Apps.Entities/Apps/InviteUserCommandMiddleware.cs rename to src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs index a82031519..0bf99f271 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/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 +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 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/Invitation/InvitedResult.cs similarity index 90% rename from src/Squidex.Domain.Apps.Entities/Apps/InvitedResult.cs rename to src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitedResult.cs index 9b599b39c..695be0a4b 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/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 +namespace Squidex.Domain.Apps.Entities.Apps.Invitation { public sealed class InvitedResult { 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.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.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.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..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; @@ -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 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..6fa4e5cd8 --- /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 body); + } +} 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.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.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..a4894e306 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.Invitation; 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..28f6665f2 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.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>(), c.GetRequiredService(), exposeSourceUrl)) - .As().As().As(); + .As().As().As().As(); services.AddSingletonAs() .As().As(); @@ -147,6 +149,30 @@ namespace Squidex.Config.Domain return result; }); + + var emailOptions = config.GetSection("email:smtp").Get(); + + if (emailOptions.IsConfigured()) + { + services.AddSingleton(Options.Create(emailOptions)); + + services.Configure( + config.GetSection("email:invitations")); + + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .AsOptional(); + } + else + { + services.AddSingletonAs() + .AsOptional(); + } + + services.AddSingletonAs() + .As(); } private static void AddCommandPipeline(this IServiceCollection services) 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 99f85ff90..c244a8b2b 100644 --- a/src/Squidex/appsettings.json +++ b/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<> [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<> [$UI_URL]" } - } }, "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.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..a5e94c8f7 --- /dev/null +++ b/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(); + 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() + { + 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.Ignored)) + .MustNotHaveHappened(); + + A.CallTo(() => emailSender.SendNewUserEmailAsync(A.Ignored, A.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(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() + { + 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, 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.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 94% 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..24e01634a 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 { @@ -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().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>(), 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.Ignored)) + A.CallTo(() => userResolver.CreateUserIfNotExists(A.Ignored, A.Ignored)) .MustNotHaveHappened(); } } 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]