Browse Source

Merge branch 'master' into ui-logic-rework

# Conflicts:
#	src/Squidex/app/features/administration/pages/users/user-page.component.ts
#	src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html
#	src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts
#	src/Squidex/app/features/settings/pages/plans/plans-page.component.html
#	src/Squidex/app/shared/components/pipes.ts
#	src/Squidex/app/shared/services/users.service.spec.ts
#	src/Squidex/app/shared/services/users.service.ts
pull/271/head
Sebastian Stehle 8 years ago
parent
commit
1a5d163dba
  1. 5
      .drone.yml
  2. 2
      LICENSE.txt
  3. 4
      src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs
  4. 23
      src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs
  5. 4
      src/Squidex.Domain.Apps.Entities/Apps/Services/IAppLimitsPlan.cs
  6. 4
      src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppLimitsPlan.cs
  7. 16
      src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppPlansProvider.cs
  8. 12
      src/Squidex.Domain.Users.MongoDb/MongoUserStore.cs
  9. 10
      src/Squidex.Domain.Users/UserExtensions.cs
  10. 3
      src/Squidex.Domain.Users/UserManagerExtensions.cs
  11. 16
      src/Squidex.Infrastructure/EventSourcing/Grains/OrleansEventNotifier.cs
  12. 22
      src/Squidex.Infrastructure/Orleans/Bootstrap.cs
  13. 2
      src/Squidex.Shared/Identity/SquidexClaimTypes.cs
  14. 2
      src/Squidex.Shared/Users/IUserResolver.cs
  15. 11
      src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs
  16. 1
      src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs
  17. 2
      src/Squidex/Areas/Api/Controllers/Apps/Models/AssignAppContributorDto.cs
  18. 20
      src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorAssignedDto.cs
  19. 2
      src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs
  20. 3
      src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs
  21. 15
      src/Squidex/Areas/Api/Controllers/Plans/Models/PlanDto.cs
  22. 9
      src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs
  23. 1
      src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs
  24. 9
      src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs
  25. 26
      src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs
  26. 9
      src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs
  27. 6
      src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs
  28. 6
      src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs
  29. 4
      src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs
  30. 8
      src/Squidex/Areas/Api/Controllers/Users/UsersController.cs
  31. 2
      src/Squidex/Areas/IdentityServer/Controllers/Profile/ChangeProfileModel.cs
  32. 3
      src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs
  33. 2
      src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileVM.cs
  34. 12
      src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml
  35. 3
      src/Squidex/Config/Domain/EventStoreServices.cs
  36. 94
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html
  37. 11
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.scss
  38. 15
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts
  39. 120
      src/Squidex/app/features/settings/pages/plans/plans-page.component.html
  40. 18
      src/Squidex/app/features/settings/pages/plans/plans-page.component.scss
  41. 7
      src/Squidex/app/framework/angular/forms/autocomplete.component.ts
  42. 40
      src/Squidex/app/shared/components/pipes.ts
  43. 6
      src/Squidex/app/shared/module.ts
  44. 13
      src/Squidex/app/shared/services/app-contributors.service.spec.ts
  45. 16
      src/Squidex/app/shared/services/app-contributors.service.ts
  46. 8
      src/Squidex/app/shared/services/plans.service.spec.ts
  47. 4
      src/Squidex/app/shared/services/plans.service.ts
  48. 20
      src/Squidex/app/shared/services/users-provider.service.spec.ts
  49. 10
      src/Squidex/app/shared/services/users-provider.service.ts
  50. 245
      src/Squidex/app/shared/services/users.service.spec.ts
  51. 129
      src/Squidex/app/shared/services/users.service.ts
  52. 12
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs
  53. 14
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/Billing/ConfigAppLimitsProviderTests.cs
  54. 34
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppContributorsTests.cs
  55. 2
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppTests.cs
  56. 1
      tests/Squidex.Infrastructure.Tests/EventSourcing/Grains/OrleansEventNotifierTests.cs
  57. 39
      tests/Squidex.Infrastructure.Tests/Orleans/BootstrapTests.cs

5
.drone.yml

@ -1,3 +1,8 @@
clone:
git:
image: plugins/git:next
pull: true
pipeline: pipeline:
test_pull_request: test_pull_request:
image: docker image: docker

2
LICENSE.txt

@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.

4
src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs

@ -70,11 +70,13 @@ namespace Squidex.Domain.Apps.Entities.Apps
}); });
case AssignContributor assigneContributor: case AssignContributor assigneContributor:
return UpdateAsync(assigneContributor, async c => return UpdateReturnAsync(assigneContributor, async c =>
{ {
await GuardAppContributors.CanAssign(Snapshot.Contributors, c, userResolver, appPlansProvider.GetPlan(Snapshot.Plan?.PlanId)); await GuardAppContributors.CanAssign(Snapshot.Contributors, c, userResolver, appPlansProvider.GetPlan(Snapshot.Plan?.PlanId));
AssignContributor(c); AssignContributor(c);
return EntityCreatedResult.Create(c.ContributorId, NewVersion);
}); });
case RemoveContributor removeContributor: case RemoveContributor removeContributor:

23
src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs

@ -34,20 +34,27 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
} }
else else
{ {
if (await users.FindByIdAsync(command.ContributorId) == null) var user = await users.FindByIdOrEmailAsync(command.ContributorId);
if (user == null)
{ {
error(new ValidationError("Cannot find contributor id.", nameof(command.ContributorId))); error(new ValidationError("Cannot find contributor id.", nameof(command.ContributorId)));
} }
else if (contributors.TryGetValue(command.ContributorId, out var existing)) else
{ {
if (existing == command.Permission) command.ContributorId = user.Id;
if (contributors.TryGetValue(command.ContributorId, out var existing))
{ {
error(new ValidationError("Contributor has already this permission.", nameof(command.Permission))); if (existing == command.Permission)
{
error(new ValidationError("Contributor has already this permission.", nameof(command.Permission)));
}
}
else if (plan.MaxContributors == contributors.Count)
{
error(new ValidationError("You have reached the maximum number of contributors for your plan."));
} }
}
else if (plan.MaxContributors == contributors.Count)
{
error(new ValidationError("You have reached the maximum number of contributors for your plan."));
} }
} }
}); });

4
src/Squidex.Domain.Apps.Entities/Apps/Services/IAppLimitsPlan.cs

@ -15,6 +15,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Services
string Costs { get; } string Costs { get; }
string YearlyCosts { get; }
string YearlyId { get; }
long MaxApiCalls { get; } long MaxApiCalls { get; }
long MaxAssetSize { get; } long MaxAssetSize { get; }

4
src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppLimitsPlan.cs

@ -15,6 +15,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Services.Implementations
public string Costs { get; set; } public string Costs { get; set; }
public string YearlyCosts { get; set; }
public string YearlyId { get; set; }
public long MaxApiCalls { get; set; } public long MaxApiCalls { get; set; }
public long MaxAssetSize { get; set; } public long MaxAssetSize { get; set; }

16
src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppPlansProvider.cs

@ -23,15 +23,23 @@ namespace Squidex.Domain.Apps.Entities.Apps.Services.Implementations
MaxContributors = -1 MaxContributors = -1
}; };
private readonly Dictionary<string, ConfigAppLimitsPlan> plansById; private readonly Dictionary<string, ConfigAppLimitsPlan> plansById = new Dictionary<string, ConfigAppLimitsPlan>(StringComparer.OrdinalIgnoreCase);
private readonly List<ConfigAppLimitsPlan> plansList; private readonly List<ConfigAppLimitsPlan> plansList = new List<ConfigAppLimitsPlan>();
public ConfigAppPlansProvider(IEnumerable<ConfigAppLimitsPlan> config) public ConfigAppPlansProvider(IEnumerable<ConfigAppLimitsPlan> config)
{ {
Guard.NotNull(config, nameof(config)); Guard.NotNull(config, nameof(config));
plansList = config.Select(c => c.Clone()).OrderBy(x => x.MaxApiCalls).ToList(); foreach (var plan in config.OrderBy(x => x.MaxApiCalls).Select(x => x.Clone()))
plansById = plansList.ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase); {
plansList.Add(plan);
plansById[plan.Id] = plan;
if (!string.IsNullOrWhiteSpace(plan.YearlyId) && !string.IsNullOrWhiteSpace(plan.YearlyCosts))
{
plansById[plan.YearlyId] = plan;
}
}
} }
public IEnumerable<IAppLimitsPlan> GetAvailablePlans() public IEnumerable<IAppLimitsPlan> GetAvailablePlans()

12
src/Squidex.Domain.Users.MongoDb/MongoUserStore.cs

@ -12,6 +12,7 @@ using System.Security.Claims;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using MongoDB.Bson;
using MongoDB.Driver; using MongoDB.Driver;
using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.Tasks; using Squidex.Infrastructure.Tasks;
@ -72,9 +73,16 @@ namespace Squidex.Domain.Users.MongoDb
return new MongoUser { Email = email, UserName = email }; return new MongoUser { Email = email, UserName = email };
} }
public async Task<IUser> FindByIdAsync(string id) public async Task<IUser> FindByIdOrEmailAsync(string id)
{ {
return await Collection.Find(x => x.Id == id).FirstOrDefaultAsync(); if (ObjectId.TryParse(id, out var parsed))
{
return await Collection.Find(x => x.Id == id).FirstOrDefaultAsync();
}
else
{
return await Collection.Find(x => x.NormalizedEmail == id.ToUpperInvariant()).FirstOrDefaultAsync();
}
} }
public async Task<IUser> FindByIdAsync(string userId, CancellationToken cancellationToken) public async Task<IUser> FindByIdAsync(string userId, CancellationToken cancellationToken)

10
src/Squidex.Domain.Users/UserExtensions.cs

@ -35,6 +35,11 @@ namespace Squidex.Domain.Users
user.SetClaim(SquidexClaimTypes.SquidexPictureUrl, GravatarHelper.CreatePictureUrl(email)); user.SetClaim(SquidexClaimTypes.SquidexPictureUrl, GravatarHelper.CreatePictureUrl(email));
} }
public static void SetHidden(this IUser user, bool value)
{
user.SetClaim(SquidexClaimTypes.SquidexHidden, value.ToString());
}
public static void SetConsent(this IUser user) public static void SetConsent(this IUser user)
{ {
user.SetClaim(SquidexClaimTypes.SquidexConsent, "true"); user.SetClaim(SquidexClaimTypes.SquidexConsent, "true");
@ -45,6 +50,11 @@ namespace Squidex.Domain.Users
user.SetClaim(SquidexClaimTypes.SquidexConsentForEmails, value.ToString()); user.SetClaim(SquidexClaimTypes.SquidexConsentForEmails, value.ToString());
} }
public static bool IsHidden(this IUser user)
{
return user.HasClaimValue(SquidexClaimTypes.SquidexHidden, "true");
}
public static bool HasConsent(this IUser user) public static bool HasConsent(this IUser user)
{ {
return user.HasClaimValue(SquidexClaimTypes.SquidexConsent, "true"); return user.HasClaimValue(SquidexClaimTypes.SquidexConsent, "true");

3
src/Squidex.Domain.Users/UserManagerExtensions.cs

@ -71,8 +71,9 @@ namespace Squidex.Domain.Users
return user; return user;
} }
public static Task<IdentityResult> UpdateAsync(this UserManager<IUser> userManager, IUser user, string email, string displayName) public static Task<IdentityResult> UpdateAsync(this UserManager<IUser> userManager, IUser user, string email, string displayName, bool hidden)
{ {
user.SetHidden(hidden);
user.SetEmail(email); user.SetEmail(email);
user.SetDisplayName(displayName); user.SetDisplayName(displayName);

16
src/Squidex.Infrastructure/EventSourcing/Grains/OrleansEventNotifier.cs

@ -10,26 +10,24 @@ using Orleans;
namespace Squidex.Infrastructure.EventSourcing.Grains namespace Squidex.Infrastructure.EventSourcing.Grains
{ {
public sealed class OrleansEventNotifier : IEventNotifier, IInitializable public sealed class OrleansEventNotifier : IEventNotifier
{ {
private readonly IGrainFactory factory; private readonly IGrainFactory factory;
private IEventConsumerManagerGrain eventConsumerManagerGrain; private readonly Lazy<IEventConsumerManagerGrain> eventConsumerManagerGrain;
public OrleansEventNotifier(IGrainFactory factory) public OrleansEventNotifier(IGrainFactory factory)
{ {
Guard.NotNull(factory, nameof(factory)); Guard.NotNull(factory, nameof(factory));
this.factory = factory; eventConsumerManagerGrain = new Lazy<IEventConsumerManagerGrain>(() =>
} {
return factory.GetGrain<IEventConsumerManagerGrain>("Default");
public void Initialize() });
{
eventConsumerManagerGrain = factory.GetGrain<IEventConsumerManagerGrain>("Default");
} }
public void NotifyEventsStored(string streamName) public void NotifyEventsStored(string streamName)
{ {
eventConsumerManagerGrain?.ActivateAsync(streamName); eventConsumerManagerGrain.Value.ActivateAsync(streamName);
} }
public IDisposable Subscribe(Action<string> handler) public IDisposable Subscribe(Action<string> handler)

22
src/Squidex.Infrastructure/Orleans/Bootstrap.cs

@ -14,6 +14,7 @@ namespace Squidex.Infrastructure.Orleans
{ {
public sealed class Bootstrap<T> : IStartupTask where T : IBackgroundGrain public sealed class Bootstrap<T> : IStartupTask where T : IBackgroundGrain
{ {
private const int NumTries = 10;
private readonly IGrainFactory grainFactory; private readonly IGrainFactory grainFactory;
public Bootstrap(IGrainFactory grainFactory) public Bootstrap(IGrainFactory grainFactory)
@ -23,11 +24,26 @@ namespace Squidex.Infrastructure.Orleans
this.grainFactory = grainFactory; this.grainFactory = grainFactory;
} }
public Task Execute(CancellationToken cancellationToken) public async Task Execute(CancellationToken cancellationToken)
{ {
var grain = grainFactory.GetGrain<T>("Default"); for (var i = 1; i <= NumTries; i++)
{
try
{
var grain = grainFactory.GetGrain<T>("Default");
return grain.ActivateAsync(); await grain.ActivateAsync();
return;
}
catch (OrleansException)
{
if (i == NumTries)
{
throw;
}
}
}
} }
} }
} }

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

@ -17,6 +17,8 @@ namespace Squidex.Shared.Identity
public static readonly string SquidexConsentForEmails = "urn:squidex:consent:emails"; public static readonly string SquidexConsentForEmails = "urn:squidex:consent:emails";
public static readonly string SquidexHidden = "urn:squidex:hidden";
public static readonly string Prefix = "urn:squidex:"; public static readonly string Prefix = "urn:squidex:";
} }
} }

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

@ -11,6 +11,6 @@ namespace Squidex.Shared.Users
{ {
public interface IUserResolver public interface IUserResolver
{ {
Task<IUser> FindByIdAsync(string id); Task<IUser> FindByIdOrEmailAsync(string idOrEmail);
} }
} }

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

@ -65,19 +65,24 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// <param name="app">The name of the app.</param> /// <param name="app">The name of the app.</param>
/// <param name="request">Contributor object that needs to be added to the app.</param> /// <param name="request">Contributor object that needs to be added to the app.</param>
/// <returns> /// <returns>
/// 204 => User assigned to app. /// 200 => User assigned to app.
/// 400 => User is already assigned to the app or not found. /// 400 => User is already assigned to the app or not found.
/// 404 => App not found. /// 404 => App not found.
/// </returns> /// </returns>
[HttpPost] [HttpPost]
[Route("apps/{app}/contributors/")] [Route("apps/{app}/contributors/")]
[ProducesResponseType(typeof(ContributorAssignedDto), 201)]
[ProducesResponseType(typeof(ErrorDto), 400)] [ProducesResponseType(typeof(ErrorDto), 400)]
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> PostContributor(string app, [FromBody] AssignAppContributorDto request) public async Task<IActionResult> PostContributor(string app, [FromBody] AssignAppContributorDto request)
{ {
await CommandBus.PublishAsync(SimpleMapper.Map(request, new AssignContributor())); var command = SimpleMapper.Map(request, new AssignContributor());
var context = await CommandBus.PublishAsync(command);
return NoContent(); var result = context.Result<EntityCreatedResult<string>>();
var response = new ContributorAssignedDto { ContributorId = result.IdOrValue };
return Ok(response);
} }
/// <summary> /// <summary>

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

@ -99,7 +99,6 @@ namespace Squidex.Areas.Api.Controllers.Apps
public async Task<IActionResult> PostApp([FromBody] CreateAppDto request) public async Task<IActionResult> PostApp([FromBody] CreateAppDto request)
{ {
var command = SimpleMapper.Map(request, new CreateApp()); var command = SimpleMapper.Map(request, new CreateApp());
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<EntityCreatedResult<Guid>>(); var result = context.Result<EntityCreatedResult<Guid>>();

2
src/Squidex/Areas/Api/Controllers/Apps/Models/AssignAppContributorDto.cs

@ -15,7 +15,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
public sealed class AssignAppContributorDto public sealed class AssignAppContributorDto
{ {
/// <summary> /// <summary>
/// The id of the user to add to the app. /// The id or email of the user to add to the app.
/// </summary> /// </summary>
[Required] [Required]
public string ContributorId { get; set; } public string ContributorId { get; set; }

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

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
namespace Squidex.Areas.Api.Controllers.Apps.Models
{
public sealed class ContributorAssignedDto
{
/// <summary>
/// The id of the user that has been assigned as contributor.
/// </summary>
[Required]
public string ContributorId { get; set; }
}
}

2
src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs

@ -268,7 +268,6 @@ namespace Squidex.Areas.Api.Controllers.Contents
await contentQuery.FindSchemaAsync(App, name); await contentQuery.FindSchemaAsync(App, name);
var command = new UpdateContent { ContentId = id, Data = request.ToCleaned() }; var command = new UpdateContent { ContentId = id, Data = request.ToCleaned() };
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<ContentDataChangedResult>(); var result = context.Result<ContentDataChangedResult>();
@ -301,7 +300,6 @@ namespace Squidex.Areas.Api.Controllers.Contents
await contentQuery.FindSchemaAsync(App, name); await contentQuery.FindSchemaAsync(App, name);
var command = new PatchContent { ContentId = id, Data = request.ToCleaned() }; var command = new PatchContent { ContentId = id, Data = request.ToCleaned() };
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<ContentDataChangedResult>(); var result = context.Result<ContentDataChangedResult>();

3
src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs

@ -55,11 +55,12 @@ namespace Squidex.Areas.Api.Controllers.Plans
public IActionResult GetPlans(string app) public IActionResult GetPlans(string app)
{ {
var planId = appPlansProvider.GetPlanForApp(App).Id; var planId = appPlansProvider.GetPlanForApp(App).Id;
var plans = appPlansProvider.GetAvailablePlans().Select(x => SimpleMapper.Map(x, new PlanDto())).ToList();
var response = new AppPlansDto var response = new AppPlansDto
{ {
CurrentPlanId = planId, CurrentPlanId = planId,
Plans = appPlansProvider.GetAvailablePlans().Select(x => SimpleMapper.Map(x, new PlanDto())).ToList(), Plans = plans,
PlanOwner = App.Plan?.Owner.Identifier, PlanOwner = App.Plan?.Owner.Identifier,
HasPortal = appPlansBillingManager.HasPortal HasPortal = appPlansBillingManager.HasPortal
}; };

15
src/Squidex/Areas/Api/Controllers/Plans/Models/PlanDto.cs

@ -5,6 +5,8 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.ComponentModel.DataAnnotations;
namespace Squidex.Areas.Api.Controllers.Plans.Models namespace Squidex.Areas.Api.Controllers.Plans.Models
{ {
public sealed class PlanDto public sealed class PlanDto
@ -12,18 +14,31 @@ namespace Squidex.Areas.Api.Controllers.Plans.Models
/// <summary> /// <summary>
/// The id of the plan. /// The id of the plan.
/// </summary> /// </summary>
[Required]
public string Id { get; set; } public string Id { get; set; }
/// <summary> /// <summary>
/// The name of the plan. /// The name of the plan.
/// </summary> /// </summary>
[Required]
public string Name { get; set; } public string Name { get; set; }
/// <summary> /// <summary>
/// The monthly costs of the plan. /// The monthly costs of the plan.
/// </summary> /// </summary>
[Required]
public string Costs { get; set; } public string Costs { get; set; }
/// <summary>
/// The yearly costs of the plan.
/// </summary>
public string YearlyCosts { get; set; }
/// <summary>
/// The yearly id of the plan.
/// </summary>
public string YearlyId { get; set; }
/// <summary> /// <summary>
/// The maximum number of API calls. /// The maximum number of API calls.
/// </summary> /// </summary>

9
src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs

@ -11,6 +11,7 @@ using NSwag.Annotations;
using Squidex.Areas.Api.Controllers.Schemas.Models; using Squidex.Areas.Api.Controllers.Schemas.Models;
using Squidex.Domain.Apps.Entities.Schemas.Commands; using Squidex.Domain.Apps.Entities.Schemas.Commands;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Reflection;
using Squidex.Pipeline; using Squidex.Pipeline;
namespace Squidex.Areas.Api.Controllers.Schemas namespace Squidex.Areas.Api.Controllers.Schemas
@ -50,13 +51,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> PostField(string app, string name, [FromBody] AddFieldDto request) public async Task<IActionResult> PostField(string app, string name, [FromBody] AddFieldDto request)
{ {
var command = new AddField var command = SimpleMapper.Map(request, new AddField { Properties = request.Properties.ToProperties() });
{
Name = request.Name,
Partitioning = request.Partitioning,
Properties = request.Properties.ToProperties()
};
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<EntityCreatedResult<long>>(); var result = context.Result<EntityCreatedResult<long>>();

1
src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs

@ -120,7 +120,6 @@ namespace Squidex.Areas.Api.Controllers.Schemas
public async Task<IActionResult> PostSchema(string app, [FromBody] CreateSchemaDto request) public async Task<IActionResult> PostSchema(string app, [FromBody] CreateSchemaDto request)
{ {
var command = request.ToCommand(); var command = request.ToCommand();
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<EntityCreatedResult<Guid>>(); var result = context.Result<EntityCreatedResult<Guid>>();

9
src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs

@ -11,13 +11,22 @@ namespace Squidex.Areas.Api.Controllers.Users.Models
{ {
public sealed class CreateUserDto public sealed class CreateUserDto
{ {
/// <summary>
/// The email of the user. Unique value.
/// </summary>
[Required] [Required]
[EmailAddress] [EmailAddress]
public string Email { get; set; } public string Email { get; set; }
/// <summary>
/// The display name (usually first name and last name) of the user.
/// </summary>
[Required] [Required]
public string DisplayName { get; set; } public string DisplayName { get; set; }
/// <summary>
/// The password of the user.
/// </summary>
[Required] [Required]
public string Password { get; set; } public string Password { get; set; }
} }

26
src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs

@ -0,0 +1,26 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
namespace Squidex.Areas.Api.Controllers.Users.Models
{
public sealed class PublicUserDto
{
/// <summary>
/// The id of the user.
/// </summary>
[Required]
public string Id { get; set; }
/// <summary>
/// The display name (usually first name and last name) of the user.
/// </summary>
[Required]
public string DisplayName { get; set; }
}
}

9
src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs

@ -11,13 +11,22 @@ namespace Squidex.Areas.Api.Controllers.Users.Models
{ {
public sealed class UpdateUserDto public sealed class UpdateUserDto
{ {
/// <summary>
/// The email of the user. Unique value.
/// </summary>
[Required] [Required]
[EmailAddress] [EmailAddress]
public string Email { get; set; } public string Email { get; set; }
/// <summary>
/// The display name (usually first name and last name) of the user.
/// </summary>
[Required] [Required]
public string DisplayName { get; set; } public string DisplayName { get; set; }
/// <summary>
/// The password of the user.
/// </summary>
public string Password { get; set; } public string Password { get; set; }
} }
} }

6
src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs

@ -11,10 +11,10 @@ namespace Squidex.Areas.Api.Controllers.Users.Models
{ {
public sealed class UserCreatedDto public sealed class UserCreatedDto
{ {
/// <summary>
/// The id of the user.
/// </summary>
[Required] [Required]
public string Id { get; set; } public string Id { get; set; }
[Required]
public string PictureUrl { get; set; }
} }
} }

6
src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs

@ -23,12 +23,6 @@ namespace Squidex.Areas.Api.Controllers.Users.Models
[Required] [Required]
public string Email { get; set; } public string Email { get; set; }
/// <summary>
/// The url to the profile picture of the user.
/// </summary>
[Required]
public string PictureUrl { get; set; }
/// <summary> /// <summary>
/// The display name (usually first name and last name) of the user. /// The display name (usually first name and last name) of the user.
/// </summary> /// </summary>

4
src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs

@ -82,7 +82,7 @@ namespace Squidex.Areas.Api.Controllers.Users
{ {
var user = await userManager.CreateAsync(userFactory, request.Email, request.DisplayName, request.Password); var user = await userManager.CreateAsync(userFactory, request.Email, request.DisplayName, request.Password);
var response = new UserCreatedDto { Id = user.Id, PictureUrl = user.PictureUrl() }; var response = new UserCreatedDto { Id = user.Id };
return Ok(response); return Ok(response);
} }
@ -129,7 +129,7 @@ namespace Squidex.Areas.Api.Controllers.Users
private static UserDto Map(IUser user) private static UserDto Map(IUser user)
{ {
return SimpleMapper.Map(user, new UserDto { DisplayName = user.DisplayName(), PictureUrl = user.PictureUrl() }); return SimpleMapper.Map(user, new UserDto { DisplayName = user.DisplayName() });
} }
private bool IsSelf(string id) private bool IsSelf(string id)

8
src/Squidex/Areas/Api/Controllers/Users/UsersController.cs

@ -65,12 +65,12 @@ namespace Squidex.Areas.Api.Controllers.Users
[ApiAuthorize] [ApiAuthorize]
[HttpGet] [HttpGet]
[Route("users/")] [Route("users/")]
[ProducesResponseType(typeof(UserDto[]), 200)] [ProducesResponseType(typeof(PublicUserDto[]), 200)]
public async Task<IActionResult> GetUsers(string query) public async Task<IActionResult> GetUsers(string query)
{ {
var entities = await userManager.QueryByEmailAsync(query ?? string.Empty); var entities = await userManager.QueryByEmailAsync(query ?? string.Empty);
var models = entities.Select(x => SimpleMapper.Map(x, new UserDto { DisplayName = x.DisplayName(), PictureUrl = x.PictureUrl() })).ToArray(); var models = entities.Where(x => !x.IsHidden()).Select(x => SimpleMapper.Map(x, new UserDto { DisplayName = x.DisplayName() })).ToArray();
return Ok(models); return Ok(models);
} }
@ -86,7 +86,7 @@ namespace Squidex.Areas.Api.Controllers.Users
[ApiAuthorize] [ApiAuthorize]
[HttpGet] [HttpGet]
[Route("users/{id}/")] [Route("users/{id}/")]
[ProducesResponseType(typeof(UserDto), 200)] [ProducesResponseType(typeof(PublicUserDto), 200)]
public async Task<IActionResult> GetUser(string id) public async Task<IActionResult> GetUser(string id)
{ {
var entity = await userManager.FindByIdAsync(id); var entity = await userManager.FindByIdAsync(id);
@ -96,7 +96,7 @@ namespace Squidex.Areas.Api.Controllers.Users
return NotFound(); return NotFound();
} }
var response = SimpleMapper.Map(entity, new UserDto { DisplayName = entity.DisplayName(), PictureUrl = entity.PictureUrl() }); var response = SimpleMapper.Map(entity, new UserDto { DisplayName = entity.DisplayName() });
return Ok(response); return Ok(response);
} }

2
src/Squidex/Areas/IdentityServer/Controllers/Profile/ChangeProfileModel.cs

@ -17,5 +17,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
[Required(ErrorMessage = "DisplayName is required.")] [Required(ErrorMessage = "DisplayName is required.")]
public string DisplayName { get; set; } public string DisplayName { get; set; }
public bool IsHidden { get; set; }
} }
} }

3
src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs

@ -83,7 +83,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
[Route("/account/profile/update/")] [Route("/account/profile/update/")]
public Task<IActionResult> UpdateProfile(ChangeProfileModel model) public Task<IActionResult> UpdateProfile(ChangeProfileModel model)
{ {
return MakeChangeAsync(user => userManager.UpdateAsync(user, model.Email, model.DisplayName), return MakeChangeAsync(user => userManager.UpdateAsync(user, model.Email, model.DisplayName, model.IsHidden),
"Account updated successfully."); "Account updated successfully.");
} }
@ -195,6 +195,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
ExternalLogins = user.Logins, ExternalLogins = user.Logins,
ExternalProviders = externalProviders, ExternalProviders = externalProviders,
DisplayName = user.DisplayName(), DisplayName = user.DisplayName(),
IsHidden = user.IsHidden(),
HasPassword = await userManager.HasPasswordAsync(user), HasPassword = await userManager.HasPasswordAsync(user),
HasPasswordAuth = identityOptions.Value.AllowPasswordAuth, HasPasswordAuth = identityOptions.Value.AllowPasswordAuth,
SuccessMessage = successMessage SuccessMessage = successMessage

2
src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileVM.cs

@ -22,6 +22,8 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
public string SuccessMessage { get; set; } public string SuccessMessage { get; set; }
public bool IsHidden { get; set; }
public bool HasPassword { get; set; } public bool HasPassword { get; set; }
public bool HasPasswordAuth { get; set; } public bool HasPasswordAuth { get; set; }

12
src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml

@ -52,7 +52,7 @@
</div> </div>
} }
<input type="email" ap class="form-control" asp-for="Email" name="email" id="email" /> <input type="email" ap class="form-control" asp-for="Email" id="email" />
</div> </div>
<div class="form-group"> <div class="form-group">
@ -65,7 +65,15 @@
</div> </div>
} }
<input type="text" class="form-control" asp-for="DisplayName" name="displayName" id="displayName"/> <input type="text" class="form-control" asp-for="DisplayName" id="displayName"/>
</div>
<div class="form-group">
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="IsHidden" id="hidden" />
<label class="form-check-label" for="hidden">Do not show my profile to other users</label>
</div>
</div> </div>
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary">Save</button>

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

@ -53,8 +53,7 @@ namespace Squidex.Config.Domain
}); });
services.AddSingletonAs<OrleansEventNotifier>() services.AddSingletonAs<OrleansEventNotifier>()
.As<IEventNotifier>() .As<IEventNotifier>();
.As<IInitializable>();
services.AddSingletonAs<DefaultStreamNameResolver>() services.AddSingletonAs<DefaultStreamNameResolver>()
.As<IStreamNameResolver>(); .As<IStreamNameResolver>();

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

@ -6,55 +6,53 @@
</ng-container> </ng-container>
<ng-container content> <ng-container content>
<div class="panel-alert panel-alert-success" *ngIf="maxContributors > 0"> <div class="panel-alert panel-alert-success" *ngIf="maxContributors > 0">
Your plan allows up to {{maxContributors}} contributors. Your plan allows up to {{maxContributors}} contributors.
</div> </div>
<table class="table table-items table-fixed"> <table class="table table-items table-fixed">
<tbody> <tbody>
<ng-template ngFor let-contributor [ngForOf]="appContributors?.contributors"> <ng-template ngFor let-contributor [ngForOf]="appContributors?.contributors">
<tr> <tr>
<td class="cell-user"> <td class="cell-user">
<img class="user-picture" [attr.title]="contributor.contributorId | sqxUserName" [attr.src]="contributor.contributorId | sqxUserPicture" /> <img class="user-picture" [attr.title]="contributor.contributorId | sqxUserName" [attr.src]="contributor.contributorId | sqxUserPicture" />
</td> </td>
<td class="cell-auto"> <td class="cell-auto">
<span class="user-name table-cell">{{contributor.contributorId | sqxUserName}}</span> <span class="user-name table-cell">{{contributor.contributorId | sqxUserName}}</span>
</td> </td>
<td class="cell-auto"> <td class="cell-time">
<span class="user-email table-cell">{{contributor.contributorId | sqxUserEmail}}</span> <select class="form-control" [ngModel]="contributor.permission" (ngModelChange)="changePermission(contributor, $event)" [disabled]="userId === contributor.contributorId">
</td> <option *ngFor="let permission of usersPermissions" [ngValue]="permission">{{permission}}</option>
<td class="cell-time"> </select>
<select class="form-control" [ngModel]="contributor.permission" (ngModelChange)="changePermission(contributor, $event)" [disabled]="userId === contributor.contributorId"> </td>
<option *ngFor="let permission of usersPermissions" [ngValue]="permission">{{permission}}</option> <td class="cell-actions">
</select> <button *ngIf="ctx.userId !== contributor.contributorId" type="button" class="btn btn-link btn-danger" (click)="removeContributor(contributor)">
</td> <i class="icon-bin2"></i>
<td class="cell-actions"> </button>
<button type="button" class="btn btn-link btn-danger" [disabled]="userId === contributor.contributorId" (click)="removeContributor(contributor)"> </td>
<i class="icon-bin2"></i> </tr>
</button> <tr class="spacer"></tr>
</td> </ng-template>
</tr> </tbody>
<tr class="spacer"></tr> </table>
</ng-template>
</tbody> <div class="table-items-footer" *ngIf="appContributors">
</table> <form [formGroup]="addContributorForm" (ngSubmit)="assignContributor()">
<div class="row no-gutters">
<div class="table-items-footer" *ngIf="appContributors"> <div class="col">
<form [formGroup]="addContributorForm" (ngSubmit)="assignContributor()"> <sqx-autocomplete [source]="usersDataSource" formControlName="user" [inputName]="'contributor'" placeholder="Find existing user" displayProperty="displayName">
<div class="row no-gutters"> <ng-template let-user="$implicit">
<div class="col"> <span class="autocomplete-user">
<sqx-autocomplete [source]="usersDataSource" formControlName="user" [inputName]="'contributor'" placeholder="Find existing user" displayProperty="email"> <img class="user-picture autocomplete-user-picture" [attr.src]="user | sqxUserDtoPicture" />
<ng-template let-user="$implicit">
<img class="user-picture autocomplete-user-picture" [attr.src]="user | sqxUserDtoPicture" />
<span class="user-name autocomplete-user-name">{{user.displayName}}</span> <span class="user-name autocomplete-user-name">{{user.displayName}}</span>
<span class="user-email autocomplete-user-email">{{user.email}}</span> </span>
</ng-template> </ng-template>
</sqx-autocomplete> </sqx-autocomplete>
</div> </div>
<div class="col col-auto pl-1"> <div class="col col-auto pl-1">
<button type="submit" class="btn btn-success" [disabled]="!canAddContributor">Add Contributor</button> <button type="submit" class="btn btn-success" [disabled]="!canAddContributor">Add Contributor</button>
</div> </div>
</div> </div>
</form> </form>
</div> </div>

11
src/Squidex/app/features/settings/pages/contributors/contributors-page.component.scss

@ -2,14 +2,11 @@
@import '_mixins'; @import '_mixins';
.autocomplete-user { .autocomplete-user {
&-picture { & {
float: left; @include truncate;
margin-top: .4rem;
} }
&-name, &-name {
&-email { margin-left: .25rem;
@include truncate;
margin-left: 3rem;
} }
} }

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

@ -15,8 +15,9 @@ import {
AppContributorsService, AppContributorsService,
AppsState, AppsState,
AutocompleteSource, AutocompleteSource,
UsersService, DialogService,
DialogService PublicUserDto,
UsersService
} from '@app/shared'; } from '@app/shared';
export class UsersDataSource implements AutocompleteSource { export class UsersDataSource implements AutocompleteSource {
@ -111,11 +112,17 @@ export class ContributorsPageComponent implements OnInit {
} }
public assignContributor() { public assignContributor() {
const requestDto = new AppContributorDto(this.addContributorForm.controls['user'].value.id, 'Editor'); let value: any = this.addContributorForm.controls['user'].value;
if (value instanceof PublicUserDto) {
value = value.id;
}
const requestDto = new AppContributorDto(value, 'Editor');
this.appContributorsService.postContributor(this.appsState.appName, requestDto, this.appContributors.version) this.appContributorsService.postContributor(this.appsState.appName, requestDto, this.appContributors.version)
.subscribe(dto => { .subscribe(dto => {
this.updateContributors(this.appContributors.addContributor(requestDto, dto.version)); this.updateContributors(this.appContributors.addContributor(new AppContributorDto(dto.payload.contributorId, requestDto.permission), dto.version));
this.resetContributorForm(); this.resetContributorForm();
}, error => { }, error => {
this.dialogs.notifyError(error); this.dialogs.notifyError(error);

120
src/Squidex/app/features/settings/pages/plans/plans-page.component.html

@ -1,62 +1,86 @@
<sqx-title message="{app} | Plans | Settings" parameter1="app" [value1]="appsState.appName"></sqx-title> <sqx-title message="{app} | Plans | Settings" parameter1="app" [value1]="ctx.appName"></sqx-title>
<sqx-panel desiredWidth="61rem"> <sqx-panel desiredWidth="61rem">
<ng-container title> <div class="panel-header">
Contributors <div class="panel-title-row">
</ng-container> <div class="float-right">
<button class="btn btn-link btn-secondary" (click)="load(true)" title="Refresh Plans (CTRL + SHIFT + R)">
<ng-container menu> <i class="icon-reset"></i> Refresh
<button class="btn btn-link btn-secondary" (click)="load(true)" title="Refresh Plans (CTRL + SHIFT + R)"> </button>
<i class="icon-reset"></i> Refresh
</button>
<sqx-shortcut keys="ctrl+shift+r" (trigger)="load(true)"></sqx-shortcut>
</ng-container>
<ng-container content>
<ng-container *ngIf="plans">
<div class="panel-alert panel-alert-danger" *ngIf="!planOwned">
You have not created the subscription. Therefore you cannot change the plan.
</div>
<div class="text-muted text-center empty" *ngIf="plans.plans.length === 0"> <sqx-shortcut keys="ctrl+shift+r" (trigger)="load(true)"></sqx-shortcut>
No plan configured, this app has unlimited usage.
</div> </div>
<h3 class="panel-title">Update Plan</h3>
</div>
<div class="clearfix"> <a class="panel-close" sqxParentLink>
<div class="card plan float-left" *ngFor="let plan of plans.plans"> <i class="icon-close"></i>
<div class="card-body plan-header text-center"> </a>
<h4 class="card-title">{{plan.name}}</h4> </div>
<h5 class="plan-price">{{plan.costs}}</h5>
<small class="text-muted">Per Month</small> <div class="panel-main">
</div> <div class="panel-content">
<div class="card-body"> <div *ngIf="plans">
<div class="plan-fact"> <div class="panel-alert panel-alert-danger" *ngIf="!planOwned">
{{plan.maxApiCalls | sqxKNumber}} API Calls You have not created the subscription. Therefore you cannot change the plan.
</div>
<div class="text-muted text-center empty" *ngIf="plans.plans.length === 0">
No plan configured, this app has unlimited usage.
</div>
<div class="clearfix">
<div class="card plan float-left" *ngFor="let plan of plans.plans">
<div class="card-header text-center">
<h4 class="card-title">{{plan.name}}</h4>
<h5 class="plan-price">{{plan.costs}}</h5>
<small class="text-muted">Per Month</small>
</div> </div>
<div class="plan-fact"> <div class="card-body">
{{plan.maxAssetSize | sqxFileSize}} Storage <div class="plan-fact text-center">
<div>
<strong>{{plan.maxApiCalls | sqxKNumber}}</strong> API Calls
</div>
<div>
{{plan.maxAssetSize | sqxFileSize}} Storage
</div>
<div>
{{plan.maxContributors}} Contributors
</div>
</div>
<button *ngIf="plan.id === plans.currentPlanId" class="btn btn-block btn-link btn-success plan-selected">
&#10003; Selected
</button>
<button *ngIf="plan.id !== plans.currentPlanId" class="btn btn-block btn-success" [disabled]="isDisabled || !planOwned" (click)="changePlan(plan.id)">
Change
</button>
</div> </div>
<div class="plan-fact"> <div class="card-footer" *ngIf="plan.yearlyId">
{{plan.maxContributors}} Contributors <div class="text-center">
<h5 class="plan-price">{{plan.yearlyCosts}}</h5>
<small class="text-muted">Per Year</small>
</div>
<button *ngIf="plan.yearlyId === plans.currentPlanId" class="btn btn-block btn-link btn-success plan-selected">
&#10003; Selected
</button>
<button *ngIf="plan.yearlyId !== plans.currentPlanId" class="btn btn-block btn-success" [disabled]="isDisabled || !planOwned" (click)="changePlan(plan.yearlyId)">
Change
</button>
</div> </div>
</div> </div>
<div class="card-body">
<button *ngIf="plan.id === plans.currentPlanId" class="btn btn-block btn-link btn-success plan-selected">
&#10003; Selected
</button>
<button *ngIf="plan.id !== plans.currentPlanId" class="btn btn-block btn-success" [disabled]="isDisabled || !planOwned" (click)="changePlan(plan.id)">
Change
</button>
</div>
</div> </div>
</div>
<div *ngIf="plans.hasPortal" class="billing-portal-link"> <div *ngIf="plans.hasPortal" class="billing-portal-link">
Go to <a target="_blank" href="{{portalUrl}}">Billing Portal</a> for payment history and subscription overview. Go to <a target="_blank" href="{{portalUrl}}">Billing Portal</a> for payment history and subscription overview.
</div>
</div> </div>
</ng-container> </div>
</ng-container> </div>
</sqx-panel> </sqx-panel>

18
src/Squidex/app/features/settings/pages/plans/plans-page.component.scss

@ -8,12 +8,10 @@
margin: .5rem; margin: .5rem;
} }
&-header {
border-bottom: 1px solid $color-border;
}
&-price { &-price {
color: $color-theme-blue; color: $color-theme-blue;
margin-top: 0;
margin-bottom: 0;
} }
&-selected { &-selected {
@ -21,10 +19,20 @@
} }
&-fact { &-fact {
line-height: 2rem; line-height: 1.8rem;
}
.btn {
margin-top: 1rem;
} }
} }
.card-footer,
.card-header,
.card-body {
padding: 1rem;
}
.empty { .empty {
margin: 1.25rem; margin: 1.25rem;
margin-top: 6.25rem; margin-top: 6.25rem;

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

@ -61,15 +61,18 @@ export class AutocompleteComponent implements ControlValueAccessor, OnDestroy, O
public ngOnInit() { public ngOnInit() {
this.subscription = this.subscription =
this.queryInput.valueChanges this.queryInput.valueChanges
.do(query => {
this.callChange(query);
})
.map(query => <string>query) .map(query => <string>query)
.map(query => query ? query.trim() : query) .map(query => query ? query.trim() : query)
.distinctUntilChanged()
.debounceTime(200)
.do(query => { .do(query => {
if (!query) { if (!query) {
this.reset(); this.reset();
} }
}) })
.distinctUntilChanged()
.debounceTime(200)
.filter(query => !!query && !!this.source) .filter(query => !!query && !!this.source)
.switchMap(query => this.source.find(query)).catch(error => Observable.of([])) .switchMap(query => this.source.find(query)).catch(error => Observable.of([]))
.subscribe(items => { .subscribe(items => {

40
src/Squidex/app/shared/components/pipes.ts

@ -11,7 +11,7 @@ import { Observable, Subscription } from 'rxjs';
import { import {
ApiUrlConfig, ApiUrlConfig,
MathHelper, MathHelper,
UserDto, PublicUserDto,
UsersProviderService UsersProviderService
} from '@app/shared/internal'; } from '@app/shared/internal';
@ -91,42 +91,6 @@ export class UserNameRefPipe extends UserAsyncPipe implements PipeTransform {
} }
} }
@Pipe({
name: 'sqxUserEmail',
pure: false
})
export class UserEmailPipe extends UserAsyncPipe implements PipeTransform {
constructor(users: UsersProviderService, changeDetector: ChangeDetectorRef) {
super(users, changeDetector);
}
public transform(userId: string): string | null {
return super.transformInternal(userId, users => users.getUser(userId).map(u => u.email));
}
}
@Pipe({
name: 'sqxUserEmailRef',
pure: false
})
export class UserEmailRefPipe extends UserAsyncPipe implements PipeTransform {
constructor(users: UsersProviderService, changeDetector: ChangeDetectorRef) {
super(users, changeDetector);
}
public transform(userId: string): string | null {
return super.transformInternal(userId, users => {
const parts = userId.split(':');
if (parts[0] === 'subject') {
return users.getUser(parts[1]).map(u => u.email);
} else {
return Observable.of(null);
}
});
}
}
@Pipe({ @Pipe({
name: 'sqxUserDtoPicture', name: 'sqxUserDtoPicture',
pure: false pure: false
@ -137,7 +101,7 @@ export class UserDtoPicture implements PipeTransform {
) { ) {
} }
public transform(user: UserDto): string | null { public transform(user: PublicUserDto): string | null {
return this.apiUrl.buildUrl(`api/users/${user.id}/picture`); return this.apiUrl.buildUrl(`api/users/${user.id}/picture`);
} }
} }

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

@ -53,8 +53,6 @@ import {
UnsetAppGuard, UnsetAppGuard,
UsagesService, UsagesService,
UserDtoPicture, UserDtoPicture,
UserEmailPipe,
UserEmailRefPipe,
UserNamePipe, UserNamePipe,
UserNameRefPipe, UserNameRefPipe,
UserIdPicturePipe, UserIdPicturePipe,
@ -83,8 +81,6 @@ import {
LanguageSelectorComponent, LanguageSelectorComponent,
MarkdownEditorComponent, MarkdownEditorComponent,
UserDtoPicture, UserDtoPicture,
UserEmailPipe,
UserEmailRefPipe,
UserIdPicturePipe, UserIdPicturePipe,
UserNamePipe, UserNamePipe,
UserNameRefPipe, UserNameRefPipe,
@ -105,8 +101,6 @@ import {
MarkdownEditorComponent, MarkdownEditorComponent,
RouterModule, RouterModule,
UserDtoPicture, UserDtoPicture,
UserEmailPipe,
UserEmailRefPipe,
UserIdPicturePipe, UserIdPicturePipe,
UserNamePipe, UserNamePipe,
UserNameRefPipe, UserNameRefPipe,

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

@ -14,7 +14,8 @@ import {
AppContributorDto, AppContributorDto,
AppContributorsDto, AppContributorsDto,
AppContributorsService, AppContributorsService,
Version Version,
ContributorAssignedDto
} from './../'; } from './../';
describe('AppContributorsDto', () => { describe('AppContributorsDto', () => {
@ -122,14 +123,20 @@ describe('AppContributorsService', () => {
const dto = new AppContributorDto('123', 'Owner'); const dto = new AppContributorDto('123', 'Owner');
appContributorsService.postContributor('my-app', dto, version).subscribe(); let contributorAssignedDto: ContributorAssignedDto | null = null;
appContributorsService.postContributor('my-app', dto, version).subscribe(result => {
contributorAssignedDto = result.payload;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/contributors'); const req = httpMock.expectOne('http://service/p/api/apps/my-app/contributors');
expect(req.request.method).toEqual('POST'); expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toEqual(version.value); expect(req.request.headers.get('If-Match')).toEqual(version.value);
req.flush({}); req.flush({ contributorId: '123' });
expect(contributorAssignedDto!.contributorId).toEqual('123');
})); }));
it('should make delete request to remove contributor', it('should make delete request to remove contributor',

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

@ -58,6 +58,13 @@ export class AppContributorDto {
} }
} }
export class ContributorAssignedDto {
constructor(
public readonly contributorId: string
) {
}
}
@Injectable() @Injectable()
export class AppContributorsService { export class AppContributorsService {
constructor( constructor(
@ -87,10 +94,17 @@ export class AppContributorsService {
.pretifyError('Failed to load contributors. Please reload.'); .pretifyError('Failed to load contributors. Please reload.');
} }
public postContributor(appName: string, dto: AppContributorDto, version: Version): Observable<Versioned<any>> { public postContributor(appName: string, dto: AppContributorDto, version: Version): Observable<Versioned<ContributorAssignedDto>> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/contributors`); const url = this.apiUrl.buildUrl(`api/apps/${appName}/contributors`);
return HTTP.postVersioned(this.http, url, dto, version) return HTTP.postVersioned(this.http, url, dto, version)
.map(response => {
const body: any = response.payload.body;
const result = new ContributorAssignedDto(body.contributorId);
return new Versioned(response.version, result);
})
.do(() => { .do(() => {
this.analytics.trackEvent('Contributor', 'Configured', appName); this.analytics.trackEvent('Contributor', 'Configured', appName);
}) })

8
src/Squidex/app/shared/services/plans.service.spec.ts

@ -62,6 +62,8 @@ describe('PlansService', () => {
id: 'free', id: 'free',
name: 'Free', name: 'Free',
costs: '14 €', costs: '14 €',
yearlyId: 'free_yearly',
yearlyCosts: '12 €',
maxApiCalls: 1000, maxApiCalls: 1000,
maxAssetSize: 1500, maxAssetSize: 1500,
maxContributors: 2500 maxContributors: 2500
@ -70,6 +72,8 @@ describe('PlansService', () => {
id: 'prof', id: 'prof',
name: 'Prof', name: 'Prof',
costs: '18 €', costs: '18 €',
yearlyId: 'prof_yearly',
yearlyCosts: '16 €',
maxApiCalls: 4000, maxApiCalls: 4000,
maxAssetSize: 5500, maxAssetSize: 5500,
maxContributors: 6500 maxContributors: 6500
@ -87,8 +91,8 @@ describe('PlansService', () => {
'456', '456',
true, true,
[ [
new PlanDto('free', 'Free', '14 €', 1000, 1500, 2500), new PlanDto('free', 'Free', '14 €', 'free_yearly', '12 €', 1000, 1500, 2500),
new PlanDto('prof', 'Prof', '18 €', 4000, 5500, 6500) new PlanDto('prof', 'Prof', '18 €', 'prof_yearly', '16 €', 4000, 5500, 6500)
], ],
new Version('2') new Version('2')
)); ));

4
src/Squidex/app/shared/services/plans.service.ts

@ -44,6 +44,8 @@ export class PlanDto {
public readonly id: string, public readonly id: string,
public readonly name: string, public readonly name: string,
public readonly costs: string, public readonly costs: string,
public readonly yearlyId: string,
public readonly yearlyCosts: string,
public readonly maxApiCalls: number, public readonly maxApiCalls: number,
public readonly maxAssetSize: number, public readonly maxAssetSize: number,
public readonly maxContributors: number public readonly maxContributors: number
@ -92,6 +94,8 @@ export class PlansService {
item.id, item.id,
item.name, item.name,
item.costs, item.costs,
item.yearlyId,
item.yearlyCosts,
item.maxApiCalls, item.maxApiCalls,
item.maxAssetSize, item.maxAssetSize,
item.maxContributors); item.maxContributors);

20
src/Squidex/app/shared/services/users-provider.service.spec.ts

@ -11,7 +11,7 @@ import { IMock, Mock, Times } from 'typemoq';
import { import {
AuthService, AuthService,
Profile, Profile,
UserDto, PublicUserDto,
UsersProviderService, UsersProviderService,
UsersService UsersService
} from './../'; } from './../';
@ -28,12 +28,12 @@ describe('UsersProviderService', () => {
}); });
it('should return users service when user not cached', () => { it('should return users service when user not cached', () => {
const user = new UserDto('123', 'mail@domain.com', 'User1', 'path/to/image', true); const user = new PublicUserDto('123', 'User1');
usersService.setup(x => x.getUser('123')) usersService.setup(x => x.getUser('123'))
.returns(() => Observable.of(user)).verifiable(Times.once()); .returns(() => Observable.of(user)).verifiable(Times.once());
let resultingUser: UserDto | null = null; let resultingUser: PublicUserDto | null = null;
usersProviderService.getUser('123').subscribe(result => { usersProviderService.getUser('123').subscribe(result => {
resultingUser = result; resultingUser = result;
@ -45,14 +45,14 @@ describe('UsersProviderService', () => {
}); });
it('should return provide user from cache', () => { it('should return provide user from cache', () => {
const user = new UserDto('123', 'mail@domain.com', 'User1', 'path/to/image', true); const user = new PublicUserDto('123', 'User1');
usersService.setup(x => x.getUser('123')) usersService.setup(x => x.getUser('123'))
.returns(() => Observable.of(user)).verifiable(Times.once()); .returns(() => Observable.of(user)).verifiable(Times.once());
usersProviderService.getUser('123'); usersProviderService.getUser('123');
let resultingUser: UserDto | null = null; let resultingUser: PublicUserDto | null = null;
usersProviderService.getUser('123').subscribe(result => { usersProviderService.getUser('123').subscribe(result => {
resultingUser = result; resultingUser = result;
@ -64,7 +64,7 @@ describe('UsersProviderService', () => {
}); });
it('should return me when user is current user', () => { it('should return me when user is current user', () => {
const user = new UserDto('123', 'mail@domain.com', 'User1', 'path/to/image', true); const user = new PublicUserDto('123', 'User1');
authService.setup(x => x.user) authService.setup(x => x.user)
.returns(() => new Profile(<any>{ profile: { sub: '123'}})); .returns(() => new Profile(<any>{ profile: { sub: '123'}}));
@ -72,13 +72,13 @@ describe('UsersProviderService', () => {
usersService.setup(x => x.getUser('123')) usersService.setup(x => x.getUser('123'))
.returns(() => Observable.of(user)).verifiable(Times.once()); .returns(() => Observable.of(user)).verifiable(Times.once());
let resultingUser: UserDto | null = null; let resultingUser: PublicUserDto | null = null;
usersProviderService.getUser('123').subscribe(result => { usersProviderService.getUser('123').subscribe(result => {
resultingUser = result; resultingUser = result;
}).unsubscribe(); }).unsubscribe();
expect(resultingUser).toEqual(new UserDto('123', 'mail@domain.com', 'Me', 'path/to/image', true)); expect(resultingUser).toEqual(new PublicUserDto('123', 'Me'));
usersService.verifyAll(); usersService.verifyAll();
}); });
@ -90,13 +90,13 @@ describe('UsersProviderService', () => {
usersService.setup(x => x.getUser('123')) usersService.setup(x => x.getUser('123'))
.returns(() => Observable.throw('NOT FOUND')).verifiable(Times.once()); .returns(() => Observable.throw('NOT FOUND')).verifiable(Times.once());
let resultingUser: UserDto | null = null; let resultingUser: PublicUserDto | null = null;
usersProviderService.getUser('123').subscribe(result => { usersProviderService.getUser('123').subscribe(result => {
resultingUser = result; resultingUser = result;
}).unsubscribe(); }).unsubscribe();
expect(resultingUser).toEqual(new UserDto('NOT FOUND', 'NOT FOUND', 'NOT FOUND', null, false)); expect(resultingUser).toEqual(new PublicUserDto('Unknown', 'Unknown'));
usersService.verifyAll(); usersService.verifyAll();
}); });

10
src/Squidex/app/shared/services/users-provider.service.ts

@ -8,13 +8,13 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { UserDto, UsersService } from './users.service'; import { PublicUserDto, UsersService } from './users.service';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@Injectable() @Injectable()
export class UsersProviderService { export class UsersProviderService {
private readonly caches: { [id: string]: Observable<UserDto> } = {}; private readonly caches: { [id: string]: Observable<PublicUserDto> } = {};
constructor( constructor(
private readonly usersService: UsersService, private readonly usersService: UsersService,
@ -22,14 +22,14 @@ export class UsersProviderService {
) { ) {
} }
public getUser(id: string, me: string | null = 'Me'): Observable<UserDto> { public getUser(id: string, me: string | null = 'Me'): Observable<PublicUserDto> {
let result = this.caches[id]; let result = this.caches[id];
if (!result) { if (!result) {
const request = const request =
this.usersService.getUser(id) this.usersService.getUser(id)
.catch(error => { .catch(error => {
return Observable.of(new UserDto('NOT FOUND', 'NOT FOUND', 'NOT FOUND', null, false)); return Observable.of(new PublicUserDto('Unknown', 'Unknown'));
}) })
.publishLast(); .publishLast();
@ -41,7 +41,7 @@ export class UsersProviderService {
return result return result
.map(dto => { .map(dto => {
if (me && this.authService.user && dto.id === this.authService.user.id) { if (me && this.authService.user && dto.id === this.authService.user.id) {
dto = new UserDto(dto.id, dto.email, me, dto.pictureUrl, dto.isLocked); dto = new PublicUserDto(dto.id, me);
} }
return dto; return dto;
}).share(); }).share();

245
src/Squidex/app/shared/services/users.service.spec.ts

@ -10,10 +10,39 @@ import { inject, TestBed } from '@angular/core/testing';
import { import {
ApiUrlConfig, ApiUrlConfig,
CreateUserDto,
PublicUserDto,
UpdateUserDto,
UserDto, UserDto,
UserManagementService,
UsersDto,
UsersService UsersService
} from './../'; } from './../';
describe('UserDto', () => {
it('should update email and display name property when unlocking', () => {
const user_1 = new UserDto('1', 'sebastian@squidex.io', 'Sebastian', true);
const user_2 = user_1.update('qaisar@squidex.io', 'Qaisar');
expect(user_2.email).toEqual('qaisar@squidex.io');
expect(user_2.displayName).toEqual('Qaisar');
});
it('should update isLocked property when locking', () => {
const user_1 = new UserDto('1', 'sebastian@squidex.io', 'Sebastian', false);
const user_2 = user_1.lock();
expect(user_2.isLocked).toBeTruthy();
});
it('should update isLocked property when unlocking', () => {
const user_1 = new UserDto('1', 'sebastian@squidex.io', 'Sebastian', true);
const user_2 = user_1.unlock();
expect(user_2.isLocked).toBeFalsy();
});
});
describe('UsersService', () => { describe('UsersService', () => {
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@ -34,7 +63,7 @@ describe('UsersService', () => {
it('should make get request to get many users', it('should make get request to get many users',
inject([UsersService, HttpTestingController], (usersService: UsersService, httpMock: HttpTestingController) => { inject([UsersService, HttpTestingController], (usersService: UsersService, httpMock: HttpTestingController) => {
let users: UserDto[] | null = null; let users: PublicUserDto[] | null = null;
usersService.getUsers().subscribe(result => { usersService.getUsers().subscribe(result => {
users = result; users = result;
@ -48,31 +77,25 @@ describe('UsersService', () => {
req.flush([ req.flush([
{ {
id: '123', id: '123',
email: 'mail1@domain.com', displayName: 'User1'
displayName: 'User1',
pictureUrl: 'path/to/image1',
isLocked: true
}, },
{ {
id: '456', id: '456',
email: 'mail2@domain.com', displayName: 'User2'
displayName: 'User2',
pictureUrl: 'path/to/image2',
isLocked: true
} }
]); ]);
expect(users).toEqual( expect(users).toEqual(
[ [
new UserDto('123', 'mail1@domain.com', 'User1', 'path/to/image1', true), new PublicUserDto('123', 'User1'),
new UserDto('456', 'mail2@domain.com', 'User2', 'path/to/image2', true) new PublicUserDto('456', 'User2')
]); ]);
})); }));
it('should make get request with query to get many users', it('should make get request with query to get many users',
inject([UsersService, HttpTestingController], (usersService: UsersService, httpMock: HttpTestingController) => { inject([UsersService, HttpTestingController], (usersService: UsersService, httpMock: HttpTestingController) => {
let users: UserDto[] | null = null; let users: PublicUserDto[] | null = null;
usersService.getUsers('my-query').subscribe(result => { usersService.getUsers('my-query').subscribe(result => {
users = result; users = result;
@ -86,31 +109,25 @@ describe('UsersService', () => {
req.flush([ req.flush([
{ {
id: '123', id: '123',
email: 'mail1@domain.com', displayName: 'User1'
displayName: 'User1',
pictureUrl: 'path/to/image1',
isLocked: true
}, },
{ {
id: '456', id: '456',
email: 'mail2@domain.com', displayName: 'User2'
displayName: 'User2',
pictureUrl: 'path/to/image2',
isLocked: true
} }
]); ]);
expect(users).toEqual( expect(users).toEqual(
[ [
new UserDto('123', 'mail1@domain.com', 'User1', 'path/to/image1', true), new PublicUserDto('123', 'User1'),
new UserDto('456', 'mail2@domain.com', 'User2', 'path/to/image2', true) new PublicUserDto('456', 'User2')
]); ]);
})); }));
it('should make get request to get single user', it('should make get request to get single user',
inject([UsersService, HttpTestingController], (usersService: UsersService, httpMock: HttpTestingController) => { inject([UsersService, HttpTestingController], (usersService: UsersService, httpMock: HttpTestingController) => {
let user: UserDto | null = null; let user: PublicUserDto | null = null;
usersService.getUser('123').subscribe(result => { usersService.getUser('123').subscribe(result => {
user = result; user = result;
@ -121,14 +138,192 @@ describe('UsersService', () => {
expect(req.request.method).toEqual('GET'); expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({ id: '123', displayName: 'User1' });
expect(user).toEqual(new PublicUserDto('123', 'User1'));
}));
});
describe('UserManagementService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
HttpClientTestingModule
],
providers: [
UserManagementService,
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') }
]
});
});
afterEach(inject([HttpTestingController], (httpMock: HttpTestingController) => {
httpMock.verify();
}));
it('should make get request to get many users',
inject([UserManagementService, HttpTestingController], (userManagementService: UserManagementService, httpMock: HttpTestingController) => {
let users: UsersDto | null = null;
userManagementService.getUsers(20, 30).subscribe(result => {
users = result;
});
const req = httpMock.expectOne('http://service/p/api/user-management?take=20&skip=30&query=');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({
total: 100,
items: [
{
id: '123',
email: 'mail1@domain.com',
displayName: 'User1',
isLocked: true
},
{
id: '456',
email: 'mail2@domain.com',
displayName: 'User2',
isLocked: true
}
]
});
expect(users).toEqual(
new UsersDto(100, [
new UserDto('123', 'mail1@domain.com', 'User1', true),
new UserDto('456', 'mail2@domain.com', 'User2', true)
]));
}));
it('should make get request with query to get many users',
inject([UserManagementService, HttpTestingController], (userManagementService: UserManagementService, httpMock: HttpTestingController) => {
let users: UsersDto | null = null;
userManagementService.getUsers(20, 30, 'my-query').subscribe(result => {
users = result;
});
const req = httpMock.expectOne('http://service/p/api/user-management?take=20&skip=30&query=my-query');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({
total: 100,
items: [
{
id: '123',
email: 'mail1@domain.com',
displayName: 'User1',
pictureUrl: 'path/to/image1',
isLocked: true
},
{
id: '456',
email: 'mail2@domain.com',
displayName: 'User2',
pictureUrl: 'path/to/image2',
isLocked: true
}
]
});
expect(users).toEqual(
new UsersDto(100, [
new UserDto('123', 'mail1@domain.com', 'User1', true),
new UserDto('456', 'mail2@domain.com', 'User2', true)
]));
}));
it('should make get request to get single user',
inject([UserManagementService, HttpTestingController], (userManagementService: UserManagementService, httpMock: HttpTestingController) => {
let user: UserDto | null = null;
userManagementService.getUser('123').subscribe(result => {
user = result;
});
const req = httpMock.expectOne('http://service/p/api/user-management/123');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({ req.flush({
id: '123', id: '123',
email: 'mail1@domain.com', email: 'mail1@domain.com',
displayName: 'User1', displayName: 'User1',
pictureUrl: 'path/to/image1',
isLocked: true isLocked: true
}); });
expect(user).toEqual(new UserDto('123', 'mail1@domain.com', 'User1', 'path/to/image1', true)); expect(user).toEqual(new UserDto('123', 'mail1@domain.com', 'User1', true));
}));
it('should make post request to create user',
inject([UserManagementService, HttpTestingController], (userManagementService: UserManagementService, httpMock: HttpTestingController) => {
const dto = new CreateUserDto('mail@squidex.io', 'Squidex User', 'password');
let user: UserDto | null = null;
userManagementService.postUser(dto).subscribe(result => {
user = result;
});
const req = httpMock.expectOne('http://service/p/api/user-management');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({ id: '123' });
expect(user).toEqual(new UserDto('123', dto.email, dto.displayName, false));
}));
it('should make put request to update user',
inject([UserManagementService, HttpTestingController], (userManagementService: UserManagementService, httpMock: HttpTestingController) => {
const dto = new UpdateUserDto('mail@squidex.io', 'Squidex User', 'password');
userManagementService.putUser('123', dto).subscribe();
const req = httpMock.expectOne('http://service/p/api/user-management/123');
expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({});
}));
it('should make put request to lock user',
inject([UserManagementService, HttpTestingController], (userManagementService: UserManagementService, httpMock: HttpTestingController) => {
userManagementService.lockUser('123').subscribe();
const req = httpMock.expectOne('http://service/p/api/user-management/123/lock');
expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({});
}));
it('should make put request to unlock user',
inject([UserManagementService, HttpTestingController], (userManagementService: UserManagementService, httpMock: HttpTestingController) => {
userManagementService.unlockUser('123').subscribe();
const req = httpMock.expectOne('http://service/p/api/user-management/123/unlock');
expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({});
})); }));
}); });

129
src/Squidex/app/shared/services/users.service.ts

@ -13,15 +13,60 @@ import '@app/framework/angular/http/http-extensions';
import { ApiUrlConfig, HTTP } from '@app/framework'; import { ApiUrlConfig, HTTP } from '@app/framework';
export class UsersDto {
constructor(
public readonly total: number,
public readonly items: UserDto[]
) {
}
}
export class UserDto { export class UserDto {
constructor( constructor(
public readonly id: string, public readonly id: string,
public readonly email: string, public readonly email: string,
public readonly displayName: string, public readonly displayName: string,
public readonly pictureUrl: string | null,
public readonly isLocked: boolean public readonly isLocked: boolean
) { ) {
} }
public update(email: string, displayName: string): UserDto {
return new UserDto(this.id, email, displayName, this.isLocked);
}
public lock(): UserDto {
return new UserDto(this.id, this.email, this.displayName, true);
}
public unlock(): UserDto {
return new UserDto(this.id, this.email, this.displayName, false);
}
}
export class CreateUserDto {
constructor(
public readonly email: string,
public readonly displayName: string,
public readonly password: string
) {
}
}
export class UpdateUserDto {
constructor(
public readonly email: string,
public readonly displayName: string,
public readonly password: string
) {
}
}
export class PublicUserDto {
constructor(
public readonly id: string,
public readonly displayName: string
) {
}
} }
@Injectable() @Injectable()
@ -32,7 +77,7 @@ export class UsersService {
) { ) {
} }
public getUsers(query?: string): Observable<UserDto[]> { public getUsers(query?: string): Observable<PublicUserDto[]> {
const url = this.apiUrl.buildUrl(`api/users?query=${query || ''}`); const url = this.apiUrl.buildUrl(`api/users?query=${query || ''}`);
return HTTP.getVersioned<any>(this.http, url) return HTTP.getVersioned<any>(this.http, url)
@ -42,19 +87,61 @@ export class UsersService {
const items: any[] = body; const items: any[] = body;
return items.map(item => { return items.map(item => {
return new PublicUserDto(
item.id,
item.displayName);
});
})
.pretifyError('Failed to load users. Please reload.');
}
public getUser(id: string): Observable<PublicUserDto> {
const url = this.apiUrl.buildUrl(`api/users/${id}`);
return HTTP.getVersioned<any>(this.http, url)
.map(response => {
const body = response.payload.body;
return new PublicUserDto(
body.id,
body.displayName);
})
.pretifyError('Failed to load user. Please reload.');
}
}
@Injectable()
export class UserManagementService {
constructor(
private readonly http: HttpClient,
private readonly apiUrl: ApiUrlConfig
) {
}
public getUsers(take: number, skip: number, query?: string): Observable<UsersDto> {
const url = this.apiUrl.buildUrl(`api/user-management?take=${take}&skip=${skip}&query=${query || ''}`);
return HTTP.getVersioned<any>(this.http, url)
.map(response => {
const body = response.payload.body;
const items: any[] = body.items;
const users = items.map(item => {
return new UserDto( return new UserDto(
item.id, item.id,
item.email, item.email,
item.displayName, item.displayName,
item.pictureUrl,
item.isLocked); item.isLocked);
}); });
return new UsersDto(body.total, users);
}) })
.pretifyError('Failed to load users. Please reload.'); .pretifyError('Failed to load users. Please reload.');
} }
public getUser(id: string): Observable<UserDto> { public getUser(id: string): Observable<UserDto> {
const url = this.apiUrl.buildUrl(`api/users/${id}`); const url = this.apiUrl.buildUrl(`api/user-management/${id}`);
return HTTP.getVersioned<any>(this.http, url) return HTTP.getVersioned<any>(this.http, url)
.map(response => { .map(response => {
@ -64,9 +151,41 @@ export class UsersService {
body.id, body.id,
body.email, body.email,
body.displayName, body.displayName,
body.pictureUrl,
body.isLocked); body.isLocked);
}) })
.pretifyError('Failed to load user. Please reload.'); .pretifyError('Failed to load user. Please reload.');
} }
public postUser(dto: CreateUserDto): Observable<UserDto> {
const url = this.apiUrl.buildUrl('api/user-management');
return HTTP.postVersioned<any>(this.http, url, dto)
.map(response => {
const body = response.payload.body;
return new UserDto(body.id, dto.email, dto.displayName, false);
})
.pretifyError('Failed to create user. Please reload.');
}
public putUser(id: string, dto: UpdateUserDto): Observable<any> {
const url = this.apiUrl.buildUrl(`api/user-management/${id}`);
return HTTP.putVersioned(this.http, url, dto)
.pretifyError('Failed to update user. Please reload.');
}
public lockUser(id: string): Observable<any> {
const url = this.apiUrl.buildUrl(`api/user-management/${id}/lock`);
return HTTP.putVersioned(this.http, url, {})
.pretifyError('Failed to load users. Please retry.');
}
public unlockUser(id: string): Observable<any> {
const url = this.apiUrl.buildUrl(`api/user-management/${id}/unlock`);
return HTTP.putVersioned(this.http, url, {})
.pretifyError('Failed to load users. Please retry.');
}
} }

12
tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs

@ -27,6 +27,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
private readonly IAppProvider appProvider = A.Fake<IAppProvider>(); private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
private readonly IAppPlansProvider appPlansProvider = A.Fake<IAppPlansProvider>(); private readonly IAppPlansProvider appPlansProvider = A.Fake<IAppPlansProvider>();
private readonly IAppPlanBillingManager appPlansBillingManager = A.Fake<IAppPlanBillingManager>(); private readonly IAppPlanBillingManager appPlansBillingManager = A.Fake<IAppPlanBillingManager>();
private readonly IUser user = A.Fake<IUser>();
private readonly IUserResolver userResolver = A.Fake<IUserResolver>(); private readonly IUserResolver userResolver = A.Fake<IUserResolver>();
private readonly string contributorId = Guid.NewGuid().ToString(); private readonly string contributorId = Guid.NewGuid().ToString();
private readonly string clientId = "client"; private readonly string clientId = "client";
@ -46,10 +47,13 @@ namespace Squidex.Domain.Apps.Entities.Apps
public AppGrainTests() public AppGrainTests()
{ {
A.CallTo(() => appProvider.GetAppAsync(AppName)) A.CallTo(() => appProvider.GetAppAsync(AppName))
.Returns((IAppEntity)null); .Returns((IAppEntity)null);
A.CallTo(() => userResolver.FindByIdAsync(contributorId)) A.CallTo(() => user.Id)
.Returns(A.Fake<IUser>()); .Returns(contributorId);
A.CallTo(() => userResolver.FindByIdOrEmailAsync(contributorId))
.Returns(user);
initialPatterns = new InitialPatterns initialPatterns = new InitialPatterns
{ {
@ -163,7 +167,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
var result = await sut.ExecuteAsync(CreateCommand(command)); var result = await sut.ExecuteAsync(CreateCommand(command));
result.ShouldBeEquivalent(new EntitySavedResult(5)); result.ShouldBeEquivalent(EntityCreatedResult.Create(contributorId, 5));
Assert.Equal(AppContributorPermission.Editor, sut.Snapshot.Contributors[contributorId]); Assert.Equal(AppContributorPermission.Editor, sut.Snapshot.Contributors[contributorId]);

14
tests/Squidex.Domain.Apps.Entities.Tests/Apps/Billing/ConfigAppLimitsProviderTests.cs

@ -41,7 +41,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.Billing
Name = "Basic", Name = "Basic",
MaxApiCalls = 150000, MaxApiCalls = 150000,
MaxAssetSize = 1024 * 1024 * 2, MaxAssetSize = 1024 * 1024 * 2,
MaxContributors = 5 MaxContributors = 5,
YearlyCosts = "100€",
YearlyId = "basic_yearly"
}; };
private static readonly ConfigAppLimitsPlan[] Plans = { BasicPlan, FreePlan }; private static readonly ConfigAppLimitsPlan[] Plans = { BasicPlan, FreePlan };
@ -76,6 +78,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.Billing
plan.ShouldBeEquivalentTo(BasicPlan); plan.ShouldBeEquivalentTo(BasicPlan);
} }
[Fact]
public void Should_return_fitting_yearly_app_plan()
{
var sut = new ConfigAppPlansProvider(Plans);
var plan = sut.GetPlanForApp(CreateApp("basic_yearly"));
plan.ShouldBeEquivalentTo(BasicPlan);
}
[Fact] [Fact]
public void Should_smallest_plan_if_none_fits() public void Should_smallest_plan_if_none_fits()
{ {

34
tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppContributorsTests.cs

@ -20,14 +20,29 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
{ {
public class GuardAppContributorsTests public class GuardAppContributorsTests
{ {
private readonly IUser user1 = A.Fake<IUser>();
private readonly IUser user2 = A.Fake<IUser>();
private readonly IUser user3 = A.Fake<IUser>();
private readonly IUserResolver users = A.Fake<IUserResolver>(); private readonly IUserResolver users = A.Fake<IUserResolver>();
private readonly IAppLimitsPlan appPlan = A.Fake<IAppLimitsPlan>(); private readonly IAppLimitsPlan appPlan = A.Fake<IAppLimitsPlan>();
private readonly AppContributors contributors_0 = AppContributors.Empty; private readonly AppContributors contributors_0 = AppContributors.Empty;
public GuardAppContributorsTests() public GuardAppContributorsTests()
{ {
A.CallTo(() => users.FindByIdAsync(A<string>.Ignored)) A.CallTo(() => user1.Id).Returns("1");
.Returns(A.Fake<IUser>()); A.CallTo(() => user2.Id).Returns("2");
A.CallTo(() => user3.Id).Returns("3");
A.CallTo(() => users.FindByIdOrEmailAsync("1")).Returns(user1);
A.CallTo(() => users.FindByIdOrEmailAsync("2")).Returns(user2);
A.CallTo(() => users.FindByIdOrEmailAsync("3")).Returns(user3);
A.CallTo(() => users.FindByIdOrEmailAsync("1@email.com")).Returns(user1);
A.CallTo(() => users.FindByIdOrEmailAsync("2@email.com")).Returns(user2);
A.CallTo(() => users.FindByIdOrEmailAsync("3@email.com")).Returns(user3);
A.CallTo(() => users.FindByIdOrEmailAsync("notfound"))
.Returns(Task.FromResult<IUser>(null));
A.CallTo(() => appPlan.MaxContributors) A.CallTo(() => appPlan.MaxContributors)
.Returns(10); .Returns(10);
@ -62,10 +77,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
[Fact] [Fact]
public Task CanAssign_should_throw_exception_if_user_not_found() public Task CanAssign_should_throw_exception_if_user_not_found()
{ {
A.CallTo(() => users.FindByIdAsync(A<string>.Ignored)) var command = new AssignContributor { ContributorId = "notfound", Permission = (AppContributorPermission)10 };
.Returns(Task.FromResult<IUser>(null));
var command = new AssignContributor { ContributorId = "1", Permission = (AppContributorPermission)10 };
return Assert.ThrowsAsync<ValidationException>(() => GuardAppContributors.CanAssign(contributors_0, command, users, appPlan)); return Assert.ThrowsAsync<ValidationException>(() => GuardAppContributors.CanAssign(contributors_0, command, users, appPlan));
} }
@ -84,6 +96,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
return Assert.ThrowsAsync<ValidationException>(() => GuardAppContributors.CanAssign(contributors_2, command, users, appPlan)); return Assert.ThrowsAsync<ValidationException>(() => GuardAppContributors.CanAssign(contributors_2, command, users, appPlan));
} }
[Fact]
public async Task CanAssign_assign_if_if_user_added_by_email()
{
var command = new AssignContributor { ContributorId = "1@email.com" };
await GuardAppContributors.CanAssign(contributors_0, command, users, appPlan);
Assert.Equal("1", command.ContributorId);
}
[Fact] [Fact]
public Task CanAssign_should_not_throw_exception_if_user_found() public Task CanAssign_should_not_throw_exception_if_user_found()
{ {

2
tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppTests.cs

@ -27,7 +27,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
A.CallTo(() => apps.GetAppAsync("new-app")) A.CallTo(() => apps.GetAppAsync("new-app"))
.Returns(Task.FromResult<IAppEntity>(null)); .Returns(Task.FromResult<IAppEntity>(null));
A.CallTo(() => users.FindByIdAsync(A<string>.Ignored)) A.CallTo(() => users.FindByIdOrEmailAsync(A<string>.Ignored))
.Returns(A.Fake<IUser>()); .Returns(A.Fake<IUser>());
A.CallTo(() => appPlans.GetPlan("free")) A.CallTo(() => appPlans.GetPlan("free"))

1
tests/Squidex.Infrastructure.Tests/EventSourcing/Grains/OrleansEventNotifierTests.cs

@ -29,7 +29,6 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
[Fact] [Fact]
public void Should_wakeup_manager_with_stream_name() public void Should_wakeup_manager_with_stream_name()
{ {
sut.Initialize();
sut.NotifyEventsStored("my-stream"); sut.NotifyEventsStored("my-stream");
A.CallTo(() => manager.ActivateAsync("my-stream")) A.CallTo(() => manager.ActivateAsync("my-stream"))

39
tests/Squidex.Infrastructure.Tests/Orleans/BootstrapTests.cs

@ -6,10 +6,13 @@
// All rights reserved. // All rights reserved.
// ========================================================================== // ==========================================================================
using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Orleans; using Orleans;
using Orleans.Runtime;
using Squidex.Infrastructure.Tasks;
using Xunit; using Xunit;
namespace Squidex.Infrastructure.Orleans namespace Squidex.Infrastructure.Orleans
@ -37,5 +40,41 @@ namespace Squidex.Infrastructure.Orleans
A.CallTo(() => grain.ActivateAsync()) A.CallTo(() => grain.ActivateAsync())
.MustHaveHappened(); .MustHaveHappened();
} }
[Fact]
public async Task Should_fail_on_non_rejection_exception()
{
A.CallTo(() => grain.ActivateAsync())
.Throws(new InvalidOperationException());
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.Execute(CancellationToken.None));
}
[Fact]
public async Task Should_retry_after_rejection_exception()
{
A.CallTo(() => grain.ActivateAsync())
.Returns(TaskHelper.Done);
A.CallTo(() => grain.ActivateAsync())
.Throws(new OrleansException()).Once();
await sut.Execute(CancellationToken.None);
A.CallTo(() => grain.ActivateAsync())
.MustHaveHappened(Repeated.Exactly.Twice);
}
[Fact]
public async Task Should_fail_after_10_rejection_exception()
{
A.CallTo(() => grain.ActivateAsync())
.Throws(new OrleansException());
await Assert.ThrowsAsync<OrleansException>(() => sut.Execute(CancellationToken.None));
A.CallTo(() => grain.ActivateAsync())
.MustHaveHappened(Repeated.Exactly.Times(10));
}
} }
} }

Loading…
Cancel
Save