Browse Source

First implementation for billing management

pull/65/head
Sebastian Stehle 9 years ago
parent
commit
0c59af7adb
  1. 2
      src/Squidex.Core/Squidex.Core.csproj
  2. 18
      src/Squidex.Events/Apps/AppPlanChanged.cs
  3. 2
      src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj
  4. 6
      src/Squidex.Read.MongoDb/Apps/MongoAppEntity.cs
  5. 9
      src/Squidex.Read.MongoDb/Apps/MongoAppRepository_EventHandling.cs
  6. 4
      src/Squidex.Read.MongoDb/Squidex.Read.MongoDb.csproj
  7. 4
      src/Squidex.Read/Apps/IAppEntity.cs
  8. 4
      src/Squidex.Read/Apps/Services/IAppLimitsPlan.cs
  9. 26
      src/Squidex.Read/Apps/Services/IAppPlanBillingManager.cs
  10. 8
      src/Squidex.Read/Apps/Services/IAppPlansProvider.cs
  11. 1
      src/Squidex.Read/Apps/Services/Implementations/CachingAppProvider.cs
  12. 4
      src/Squidex.Read/Apps/Services/Implementations/ConfigAppLimitsPlan.cs
  13. 23
      src/Squidex.Read/Apps/Services/Implementations/ConfigAppLimitsProvider.cs
  14. 42
      src/Squidex.Read/Apps/Services/Implementations/NoopAppPlanBillingManager.cs
  15. 33
      src/Squidex.Write/Apps/AppCommandHandler.cs
  16. 32
      src/Squidex.Write/Apps/AppDomainObject.cs
  17. 15
      src/Squidex.Write/Apps/Commands/ChangePlan.cs
  18. 9
      src/Squidex/Config/Domain/ReadModule.cs
  19. 2
      src/Squidex/Config/Swagger/SwaggerServices.cs
  20. 15
      src/Squidex/Config/Swagger/XmlResponseTypesProcessor.cs
  21. 8
      src/Squidex/Controllers/Api/Apps/AppContributorsController.cs
  22. 8
      src/Squidex/Controllers/Api/Assets/AssetsController.cs
  23. 95
      src/Squidex/Controllers/Api/Plans/AppPlansController.cs
  24. 40
      src/Squidex/Controllers/Api/Plans/Models/AppPlansDto.cs
  25. 21
      src/Squidex/Controllers/Api/Plans/Models/ChangePlanDto.cs
  26. 43
      src/Squidex/Controllers/Api/Plans/Models/PlanDto.cs
  27. 10
      src/Squidex/Controllers/Api/Statistics/UsagesController.cs
  28. 9
      src/Squidex/Controllers/ContentApi/Generator/SchemasSwaggerGenerator.cs
  29. 40
      src/Squidex/Controllers/UI/Profile/PortalController.cs
  30. 8
      src/Squidex/Pipeline/AppApiFilter.cs
  31. 6
      src/Squidex/Squidex.csproj
  32. 1
      src/Squidex/app/features/settings/declarations.ts
  33. 6
      src/Squidex/app/features/settings/module.ts
  34. 2
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html
  35. 9
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.scss
  36. 71
      src/Squidex/app/features/settings/pages/plans/plans-page.component.html
  37. 35
      src/Squidex/app/features/settings/pages/plans/plans-page.component.scss
  38. 108
      src/Squidex/app/features/settings/pages/plans/plans-page.component.ts
  39. 6
      src/Squidex/app/features/settings/settings-area.component.html
  40. 1
      src/Squidex/app/shared/declarations-base.ts
  41. 2
      src/Squidex/app/shared/module.ts
  42. 87
      src/Squidex/app/shared/services/plans.service.ts
  43. 29
      src/Squidex/app/theme/_panels.scss
  44. 2
      tests/Squidex.Core.Tests/Squidex.Core.Tests.csproj
  45. 2
      tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj
  46. 20
      tests/Squidex.Read.Tests/Apps/ConfigAppLimitsProviderTests.cs
  47. 4
      tests/Squidex.Read.Tests/Squidex.Read.Tests.csproj
  48. 9
      tests/Squidex.Write.Tests/Apps/AppCommandHandlerTests.cs
  49. 4
      tests/Squidex.Write.Tests/Squidex.Write.Tests.csproj

2
src/Squidex.Core/Squidex.Core.csproj

@ -14,7 +14,7 @@
<PackageReference Include="protobuf-net" Version="2.2.1" /> <PackageReference Include="protobuf-net" Version="2.2.1" />
<PackageReference Include="System.Collections.Immutable" Version="1.3.1" /> <PackageReference Include="System.Collections.Immutable" Version="1.3.1" />
<PackageReference Include="NodaTime" Version="2.0.2" /> <PackageReference Include="NodaTime" Version="2.0.2" />
<PackageReference Include="NJsonSchema" Version="9.1.2" /> <PackageReference Include="NJsonSchema" Version="8.33.6323.36213" />
<PackageReference Include="System.ValueTuple" Version="4.3.1" /> <PackageReference Include="System.ValueTuple" Version="4.3.1" />
</ItemGroup> </ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'netstandard1.6' "> <ItemGroup Condition=" '$(TargetFramework)' == 'netstandard1.6' ">

18
src/Squidex.Events/Apps/AppPlanChanged.cs

@ -0,0 +1,18 @@
// ==========================================================================
// AppPlanChanged.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using Squidex.Infrastructure;
namespace Squidex.Events.Apps
{
[TypeName("AppPlanChanged")]
public sealed class AppPlanChanged : AppEvent
{
public string PlanId { get; set; }
}
}

2
src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj

@ -10,6 +10,6 @@
<ProjectReference Include="..\Squidex.Infrastructure\Squidex.Infrastructure.csproj" /> <ProjectReference Include="..\Squidex.Infrastructure\Squidex.Infrastructure.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="2.4.3" /> <PackageReference Include="MongoDB.Driver" Version="2.4.4" />
</ItemGroup> </ItemGroup>
</Project> </Project>

6
src/Squidex.Read.MongoDb/Apps/MongoAppEntity.cs

@ -34,7 +34,11 @@ namespace Squidex.Read.MongoDb.Apps
[BsonIgnoreIfDefault] [BsonIgnoreIfDefault]
[BsonElement] [BsonElement]
public int PlanId { get; set; } public string PlanId { get; set; }
[BsonIgnoreIfDefault]
[BsonElement]
public string PlanOwner { get; set; }
[BsonRequired] [BsonRequired]
[BsonElement] [BsonElement]

9
src/Squidex.Read.MongoDb/Apps/MongoAppRepository_EventHandling.cs

@ -97,6 +97,15 @@ namespace Squidex.Read.MongoDb.Apps
}); });
} }
protected Task On(AppPlanChanged @event, EnvelopeHeaders headers)
{
return Collection.UpdateAsync(@event, headers, a =>
{
a.PlanOwner = @event.Actor.Identifier;
a.PlanId = @event.PlanId;
});
}
protected Task On(AppContributorAssigned @event, EnvelopeHeaders headers) protected Task On(AppContributorAssigned @event, EnvelopeHeaders headers)
{ {
return Collection.UpdateAsync(@event, headers, a => return Collection.UpdateAsync(@event, headers, a =>

4
src/Squidex.Read.MongoDb/Squidex.Read.MongoDb.csproj

@ -15,10 +15,10 @@
<ProjectReference Include="..\Squidex.Read\Squidex.Read.csproj" /> <ProjectReference Include="..\Squidex.Read\Squidex.Read.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="IdentityServer4" Version="1.5.1" /> <PackageReference Include="IdentityServer4" Version="1.5.2" />
<PackageReference Include="Microsoft.AspNetCore.Identity" Version="1.1.2" /> <PackageReference Include="Microsoft.AspNetCore.Identity" Version="1.1.2" />
<PackageReference Include="Microsoft.AspNetCore.Identity.MongoDB" Version="1.0.2" /> <PackageReference Include="Microsoft.AspNetCore.Identity.MongoDB" Version="1.0.2" />
<PackageReference Include="MongoDB.Driver" Version="2.4.3" /> <PackageReference Include="MongoDB.Driver" Version="2.4.4" />
</ItemGroup> </ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'netstandard1.6' "> <ItemGroup Condition=" '$(TargetFramework)' == 'netstandard1.6' ">
<PackageReference Include="Microsoft.OData.Core" Version="6.15.0" /> <PackageReference Include="Microsoft.OData.Core" Version="6.15.0" />

4
src/Squidex.Read/Apps/IAppEntity.cs

@ -15,7 +15,9 @@ namespace Squidex.Read.Apps
{ {
string Name { get; } string Name { get; }
int PlanId { get; } string PlanId { get; }
string PlanOwner { get; }
LanguagesConfig LanguagesConfig { get; } LanguagesConfig LanguagesConfig { get; }

4
src/Squidex.Read/Apps/Services/IAppLimitsPlan.cs

@ -10,8 +10,12 @@ namespace Squidex.Read.Apps.Services
{ {
public interface IAppLimitsPlan public interface IAppLimitsPlan
{ {
string Id { get; }
string Name { get; } string Name { get; }
string Costs { get; }
long MaxApiCalls { get; } long MaxApiCalls { get; }
long MaxAssetSize { get; } long MaxAssetSize { get; }

26
src/Squidex.Read/Apps/Services/IAppPlanBillingManager.cs

@ -0,0 +1,26 @@
// ==========================================================================
// IAppPlanBillingManager.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System;
using System.Threading.Tasks;
namespace Squidex.Read.Apps.Services
{
public interface IAppPlanBillingManager
{
bool HasPortal { get; }
string FreePlanId { get; }
Task ChangePlanAsync(string userId, Guid appId, string appName, string planId);
Task<bool> HasPaymentOptionsAsync(string userId);
Task<string> GetPortalLinkAsync(string userId);
}
}

8
src/Squidex.Read/Apps/Services/IAppLimitsProvider.cs → src/Squidex.Read/Apps/Services/IAppPlansProvider.cs

@ -1,5 +1,5 @@
// ========================================================================== // ==========================================================================
// IAppLimitsProvider.cs // IAppPlansProvider.cs
// Squidex Headless CMS // Squidex Headless CMS
// ========================================================================== // ==========================================================================
// Copyright (c) Squidex Group // Copyright (c) Squidex Group
@ -10,12 +10,14 @@ using System.Collections.Generic;
namespace Squidex.Read.Apps.Services namespace Squidex.Read.Apps.Services
{ {
public interface IAppLimitsProvider public interface IAppPlansProvider
{ {
IEnumerable<IAppLimitsPlan> GetAvailablePlans(); IEnumerable<IAppLimitsPlan> GetAvailablePlans();
bool IsConfiguredPlan(string planId);
IAppLimitsPlan GetPlanForApp(IAppEntity entity); IAppLimitsPlan GetPlanForApp(IAppEntity entity);
IAppLimitsPlan GetPlan(int planId); IAppLimitsPlan GetPlan(string planId);
} }
} }

1
src/Squidex.Read/Apps/Services/Implementations/CachingAppProvider.cs

@ -102,6 +102,7 @@ namespace Squidex.Read.Apps.Services.Implementations
if (@event.Payload is AppClientAttached || if (@event.Payload is AppClientAttached ||
@event.Payload is AppClientRenamed || @event.Payload is AppClientRenamed ||
@event.Payload is AppClientRevoked || @event.Payload is AppClientRevoked ||
@event.Payload is AppPlanChanged ||
@event.Payload is AppContributorAssigned || @event.Payload is AppContributorAssigned ||
@event.Payload is AppContributorRemoved || @event.Payload is AppContributorRemoved ||
@event.Payload is AppCreated || @event.Payload is AppCreated ||

4
src/Squidex.Read/Apps/Services/Implementations/ConfigAppLimitsPlan.cs

@ -10,8 +10,12 @@ namespace Squidex.Read.Apps.Services.Implementations
{ {
public sealed class ConfigAppLimitsPlan : IAppLimitsPlan public sealed class ConfigAppLimitsPlan : IAppLimitsPlan
{ {
public string Id { get; set; }
public string Name { get; set; } public string Name { get; set; }
public string Costs { get; set; }
public long MaxApiCalls { get; set; } public long MaxApiCalls { get; set; }
public long MaxAssetSize { get; set; } public long MaxAssetSize { get; set; }

23
src/Squidex.Read/Apps/Services/Implementations/ConfigAppLimitsProvider.cs

@ -6,13 +6,14 @@
// All rights reserved. // All rights reserved.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Squidex.Infrastructure; using Squidex.Infrastructure;
namespace Squidex.Read.Apps.Services.Implementations namespace Squidex.Read.Apps.Services.Implementations
{ {
public sealed class ConfigAppLimitsProvider : IAppLimitsProvider public sealed class ConfigAppPlansProvider : IAppPlansProvider
{ {
private static readonly ConfigAppLimitsPlan Infinite = new ConfigAppLimitsPlan private static readonly ConfigAppLimitsPlan Infinite = new ConfigAppLimitsPlan
{ {
@ -22,18 +23,18 @@ namespace Squidex.Read.Apps.Services.Implementations
MaxContributors = -1 MaxContributors = -1
}; };
private readonly List<ConfigAppLimitsPlan> config; private readonly Dictionary<string, ConfigAppLimitsPlan> config;
public ConfigAppLimitsProvider(IEnumerable<ConfigAppLimitsPlan> config) public ConfigAppPlansProvider(IEnumerable<ConfigAppLimitsPlan> config)
{ {
Guard.NotNull(config, nameof(config)); Guard.NotNull(config, nameof(config));
this.config = config.Select(c => c.Clone()).OrderBy(x => x.MaxApiCalls).ToList(); this.config = config.Select(c => c.Clone()).OrderBy(x => x.MaxApiCalls).ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase);
} }
public IEnumerable<IAppLimitsPlan> GetAvailablePlans() public IEnumerable<IAppLimitsPlan> GetAvailablePlans()
{ {
return config; return config.Values;
} }
public IAppLimitsPlan GetPlanForApp(IAppEntity app) public IAppLimitsPlan GetPlanForApp(IAppEntity app)
@ -43,14 +44,14 @@ namespace Squidex.Read.Apps.Services.Implementations
return GetPlan(app.PlanId); return GetPlan(app.PlanId);
} }
public IAppLimitsPlan GetPlan(int planId) public IAppLimitsPlan GetPlan(string planId)
{ {
if (planId >= 0 && planId < config.Count) return config.GetOrDefault(planId ?? string.Empty) ?? config.Values.First() ?? Infinite;
{ }
return config[planId];
}
return config.FirstOrDefault() ?? Infinite; public bool IsConfiguredPlan(string planId)
{
return planId != null && config.ContainsKey(planId);
} }
} }
} }

42
src/Squidex.Read/Apps/Services/Implementations/NoopAppPlanBillingManager.cs

@ -0,0 +1,42 @@
// ==========================================================================
// NoopAppPlanBillingManager.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Read.Apps.Services.Implementations
{
public sealed class NoopAppPlanBillingManager : IAppPlanBillingManager
{
public bool HasPortal
{
get { return false; }
}
public string FreePlanId
{
get { return "free"; }
}
public Task ChangePlanAsync(string userId, Guid appId, string appName, string planId)
{
return TaskHelper.Done;
}
public Task<bool> HasPaymentOptionsAsync(string userId)
{
return TaskHelper.True;
}
public Task<string> GetPortalLinkAsync(string userId)
{
return Task.FromResult(string.Empty);
}
}
}

33
src/Squidex.Write/Apps/AppCommandHandler.cs

@ -24,14 +24,16 @@ namespace Squidex.Write.Apps
{ {
private readonly IAggregateHandler handler; private readonly IAggregateHandler handler;
private readonly IAppRepository appRepository; private readonly IAppRepository appRepository;
private readonly IAppLimitsProvider appLimitsProvider; private readonly IAppPlansProvider appPlansProvider;
private readonly IAppPlanBillingManager appPlansBillingManager;
private readonly IUserResolver userResolver; private readonly IUserResolver userResolver;
private readonly ClientKeyGenerator keyGenerator; private readonly ClientKeyGenerator keyGenerator;
public AppCommandHandler( public AppCommandHandler(
IAggregateHandler handler, IAggregateHandler handler,
IAppRepository appRepository, IAppRepository appRepository,
IAppLimitsProvider appLimitsProvider, IAppPlansProvider appPlansProvider,
IAppPlanBillingManager appPlansBillingManager,
IUserResolver userResolver, IUserResolver userResolver,
ClientKeyGenerator keyGenerator) ClientKeyGenerator keyGenerator)
{ {
@ -39,13 +41,15 @@ namespace Squidex.Write.Apps
Guard.NotNull(keyGenerator, nameof(keyGenerator)); Guard.NotNull(keyGenerator, nameof(keyGenerator));
Guard.NotNull(appRepository, nameof(appRepository)); Guard.NotNull(appRepository, nameof(appRepository));
Guard.NotNull(userResolver, nameof(userResolver)); Guard.NotNull(userResolver, nameof(userResolver));
Guard.NotNull(appLimitsProvider, nameof(appLimitsProvider)); Guard.NotNull(appPlansProvider, nameof(appPlansProvider));
Guard.NotNull(appPlansBillingManager, nameof(appPlansBillingManager));
this.handler = handler; this.handler = handler;
this.keyGenerator = keyGenerator; this.keyGenerator = keyGenerator;
this.userResolver = userResolver; this.userResolver = userResolver;
this.appRepository = appRepository; this.appRepository = appRepository;
this.appLimitsProvider = appLimitsProvider; this.appPlansProvider = appPlansProvider;
this.appPlansBillingManager = appPlansBillingManager;
} }
protected async Task On(CreateApp command, CommandContext context) protected async Task On(CreateApp command, CommandContext context)
@ -81,7 +85,7 @@ namespace Squidex.Write.Apps
await handler.UpdateAsync<AppDomainObject>(context, a => await handler.UpdateAsync<AppDomainObject>(context, a =>
{ {
var oldContributors = a.ContributorCount; var oldContributors = a.ContributorCount;
var maxContributors = appLimitsProvider.GetPlan(a.PlanId).MaxContributors; var maxContributors = appPlansProvider.GetPlan(a.PlanId).MaxContributors;
a.AssignContributor(command); a.AssignContributor(command);
@ -94,6 +98,25 @@ namespace Squidex.Write.Apps
}); });
} }
protected Task On(ChangePlan command, CommandContext context)
{
if (!appPlansProvider.IsConfiguredPlan(command.PlanId))
{
var error =
new ValidationError($"The plan '{command.PlanId}' does not exists",
nameof(CreateApp.Name));
throw new ValidationException("Cannot change plan", error);
}
return handler.UpdateAsync<AppDomainObject>(context, async a =>
{
a.ChangePlan(command);
await appPlansBillingManager.ChangePlanAsync(command.Actor.Identifier, a.Id, a.Name, command.PlanId);
});
}
protected Task On(AttachClient command, CommandContext context) protected Task On(AttachClient command, CommandContext context)
{ {
return handler.UpdateAsync<AppDomainObject>(context, a => return handler.UpdateAsync<AppDomainObject>(context, a =>

32
src/Squidex.Write/Apps/AppDomainObject.cs

@ -30,15 +30,17 @@ namespace Squidex.Write.Apps
private readonly AppClients clients = new AppClients(); private readonly AppClients clients = new AppClients();
private LanguagesConfig languagesConfig = LanguagesConfig.Empty; private LanguagesConfig languagesConfig = LanguagesConfig.Empty;
private string name; private string name;
private string planId;
private RefToken planOwner;
public string Name public string Name
{ {
get { return name; } get { return name; }
} }
public int PlanId public string PlanId
{ {
get { return 0; } get { return planId; }
} }
public int ContributorCount public int ContributorCount
@ -101,6 +103,13 @@ namespace Squidex.Write.Apps
languagesConfig = languagesConfig.Update(@event.Language, @event.IsOptional, @event.IsMaster, @event.Fallback); languagesConfig = languagesConfig.Update(@event.Language, @event.IsOptional, @event.IsMaster, @event.Fallback);
} }
protected void On(AppPlanChanged @event)
{
planId = @event.PlanId;
planOwner = string.IsNullOrWhiteSpace(planId) ? null : @event.Actor;
}
protected override void DispatchEvent(Envelope<IEvent> @event) protected override void DispatchEvent(Envelope<IEvent> @event)
{ {
this.DispatchAction(@event.Payload); this.DispatchAction(@event.Payload);
@ -210,6 +219,17 @@ namespace Squidex.Write.Apps
return this; return this;
} }
public AppDomainObject ChangePlan(ChangePlan command)
{
Guard.NotNull(command, nameof(command));
ThrowIfOtherUser(command);;
RaiseEvent(SimpleMapper.Map(command, new AppPlanChanged()));
return this;
}
private void RaiseEvent(AppEvent @event) private void RaiseEvent(AppEvent @event)
{ {
if (@event.AppId == null) if (@event.AppId == null)
@ -230,6 +250,14 @@ namespace Squidex.Write.Apps
return new AppContributorAssigned { AppId = id, ContributorId = command.Actor.Identifier, Permission = PermissionLevel.Owner }; return new AppContributorAssigned { AppId = id, ContributorId = command.Actor.Identifier, Permission = PermissionLevel.Owner };
} }
private void ThrowIfOtherUser(ChangePlan command)
{
if (!string.IsNullOrWhiteSpace(command.PlanId) && planOwner != null && !planOwner.Equals(command.Actor))
{
throw new DomainException("Plan can only be changed from current user.");
}
}
private void ThrowIfNotCreated() private void ThrowIfNotCreated()
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))

15
src/Squidex.Write/Apps/Commands/ChangePlan.cs

@ -0,0 +1,15 @@
// ==========================================================================
// ChangePlanCommand.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
namespace Squidex.Write.Apps.Commands
{
public sealed class ChangePlan : AppAggregateCommand
{
public string PlanId { get; set; }
}
}

9
src/Squidex/Config/Domain/ReadModule.cs

@ -46,8 +46,13 @@ namespace Squidex.Config.Domain
.AsSelf() .AsSelf()
.SingleInstance(); .SingleInstance();
builder.RegisterType<ConfigAppLimitsProvider>() builder.RegisterType<ConfigAppPlansProvider>()
.As<IAppLimitsProvider>() .As<IAppPlansProvider>()
.AsSelf()
.SingleInstance();
builder.RegisterType<NoopAppPlanBillingManager>()
.As<IAppPlanBillingManager>()
.AsSelf() .AsSelf()
.SingleInstance(); .SingleInstance();

2
src/Squidex/Config/Swagger/SwaggerServices.cs

@ -87,7 +87,7 @@ namespace Squidex.Config.Swagger
settings.DocumentProcessors.Add(new XmlTagProcessor()); settings.DocumentProcessors.Add(new XmlTagProcessor());
settings.OperationProcessors.Add(new XmlTagProcessor()); settings.OperationProcessors.Add(new XmlTagProcessor());
settings.OperationProcessors.Add(new XmlResponseTypesProcessor(settings)); settings.OperationProcessors.Add(new XmlResponseTypesProcessor());
return settings; return settings;
} }

15
src/Squidex/Config/Swagger/XmlResponseTypesProcessor.cs

@ -15,7 +15,6 @@ using NJsonSchema.Infrastructure;
using NSwag; using NSwag;
using NSwag.SwaggerGeneration.Processors; using NSwag.SwaggerGeneration.Processors;
using NSwag.SwaggerGeneration.Processors.Contexts; using NSwag.SwaggerGeneration.Processors.Contexts;
using NSwag.SwaggerGeneration.WebApi;
using Squidex.Controllers.Api; using Squidex.Controllers.Api;
// ReSharper disable UseObjectOrCollectionInitializer // ReSharper disable UseObjectOrCollectionInitializer
@ -26,13 +25,6 @@ namespace Squidex.Config.Swagger
{ {
private static readonly Regex ResponseRegex = new Regex("(?<Code>[0-9]{3}) => (?<Description>.*)", RegexOptions.Compiled); private static readonly Regex ResponseRegex = new Regex("(?<Code>[0-9]{3}) => (?<Description>.*)", RegexOptions.Compiled);
private readonly WebApiToSwaggerGeneratorSettings settings;
public XmlResponseTypesProcessor(WebApiToSwaggerGeneratorSettings settings)
{
this.settings = settings;
}
public async Task<bool> ProcessAsync(OperationProcessorContext context) public async Task<bool> ProcessAsync(OperationProcessorContext context)
{ {
var hasOkResponse = false; var hasOkResponse = false;
@ -70,7 +62,7 @@ namespace Squidex.Config.Swagger
return true; return true;
} }
private async Task AddInternalErrorResponseAsync(OperationProcessorContext context, SwaggerOperation operation) private static async Task AddInternalErrorResponseAsync(OperationProcessorContext context, SwaggerOperation operation)
{ {
if (operation.Responses.ContainsKey("500")) if (operation.Responses.ContainsKey("500"))
{ {
@ -78,12 +70,11 @@ namespace Squidex.Config.Swagger
} }
var errorType = typeof(ErrorDto); var errorType = typeof(ErrorDto);
var errorContract = settings.ActualContractResolver.ResolveContract(errorType); var errorSchema = JsonObjectTypeDescription.FromType(errorType, new Attribute[0], EnumHandling.String);
var errorScheme = JsonObjectTypeDescription.FromType(errorType, errorContract, new Attribute[0], EnumHandling.String);
var response = new SwaggerResponse { Description = "Operation failed." }; var response = new SwaggerResponse { Description = "Operation failed." };
response.Schema = await context.SwaggerGenerator.GenerateAndAppendSchemaFromTypeAsync(errorType, errorScheme.IsNullable, null); response.Schema = await context.SwaggerGenerator.GenerateAndAppendSchemaFromTypeAsync(errorType, errorSchema.IsNullable, null);
operation.Responses.Add("500", response); operation.Responses.Add("500", response);
} }

8
src/Squidex/Controllers/Api/Apps/AppContributorsController.cs

@ -29,12 +29,12 @@ namespace Squidex.Controllers.Api.Apps
[SwaggerTag("Apps")] [SwaggerTag("Apps")]
public class AppContributorsController : ControllerBase public class AppContributorsController : ControllerBase
{ {
private readonly IAppLimitsProvider appLimitsProvider; private readonly IAppPlansProvider appPlansProvider;
public AppContributorsController(ICommandBus commandBus, IAppLimitsProvider appLimitsProvider) public AppContributorsController(ICommandBus commandBus, IAppPlansProvider appPlansProvider)
: base(commandBus) : base(commandBus)
{ {
this.appLimitsProvider = appLimitsProvider; this.appPlansProvider = appPlansProvider;
} }
/// <summary> /// <summary>
@ -53,7 +53,7 @@ namespace Squidex.Controllers.Api.Apps
{ {
var contributors = App.Contributors.Select(x => SimpleMapper.Map(x, new ContributorDto())).ToArray(); var contributors = App.Contributors.Select(x => SimpleMapper.Map(x, new ContributorDto())).ToArray();
var response = new ContributorsDto { Contributors = contributors, MaxContributors = appLimitsProvider.GetPlanForApp(App).MaxContributors }; var response = new ContributorsDto { Contributors = contributors, MaxContributors = appPlansProvider.GetPlanForApp(App).MaxContributors };
Response.Headers["ETag"] = new StringValues(App.Version.ToString()); Response.Headers["ETag"] = new StringValues(App.Version.ToString());

8
src/Squidex/Controllers/Api/Assets/AssetsController.cs

@ -38,21 +38,21 @@ namespace Squidex.Controllers.Api.Assets
{ {
private readonly IAssetRepository assetRepository; private readonly IAssetRepository assetRepository;
private readonly IAssetStatsRepository assetStatsRepository; private readonly IAssetStatsRepository assetStatsRepository;
private readonly IAppLimitsProvider appLimitProvider; private readonly IAppPlansProvider appPlanProvider;
private readonly AssetConfig assetsConfig; private readonly AssetConfig assetsConfig;
public AssetsController( public AssetsController(
ICommandBus commandBus, ICommandBus commandBus,
IAssetRepository assetRepository, IAssetRepository assetRepository,
IAssetStatsRepository assetStatsRepository, IAssetStatsRepository assetStatsRepository,
IAppLimitsProvider appLimitProvider, IAppPlansProvider appPlanProvider,
IOptions<AssetConfig> assetsConfig) IOptions<AssetConfig> assetsConfig)
: base(commandBus) : base(commandBus)
{ {
this.assetsConfig = assetsConfig.Value; this.assetsConfig = assetsConfig.Value;
this.assetRepository = assetRepository; this.assetRepository = assetRepository;
this.assetStatsRepository = assetStatsRepository; this.assetStatsRepository = assetStatsRepository;
this.appLimitProvider = appLimitProvider; this.appPlanProvider = appPlanProvider;
} }
/// <summary> /// <summary>
@ -257,7 +257,7 @@ namespace Squidex.Controllers.Api.Assets
throw new ValidationException("Cannot create asset.", error); throw new ValidationException("Cannot create asset.", error);
} }
var plan = appLimitProvider.GetPlanForApp(App); var plan = appPlanProvider.GetPlanForApp(App);
var currentSize = await assetStatsRepository.GetTotalSizeAsync(App.Id); var currentSize = await assetStatsRepository.GetTotalSizeAsync(App.Id);

95
src/Squidex/Controllers/Api/Plans/AppPlansController.cs

@ -0,0 +1,95 @@
// ==========================================================================
// AppPlansController.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
using NSwag.Annotations;
using Squidex.Controllers.Api.Plans.Models;
using Squidex.Infrastructure.CQRS.Commands;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Security;
using Squidex.Pipeline;
using Squidex.Read.Apps.Services;
using Squidex.Write.Apps.Commands;
namespace Squidex.Controllers.Api.Plans
{
/// <summary>
/// Manages and configures plans.
/// </summary>
[ApiExceptionFilter]
[AppApi]
[SwaggerTag("Plans")]
public class AppPlansController : ControllerBase
{
private readonly IAppPlansProvider appPlansProvider;
private readonly IAppPlanBillingManager appPlansBillingManager;
public AppPlansController(ICommandBus commandBus, IAppPlansProvider appPlansProvider, IAppPlanBillingManager appPlansBillingManager)
: base(commandBus)
{
this.appPlansProvider = appPlansProvider;
this.appPlansBillingManager = appPlansBillingManager;
}
/// <summary>
/// Get app plan information.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <returns>
/// 200 => App plan information returned.
/// 404 => App not found.
/// </returns>
[MustBeAppOwner]
[HttpGet]
[Route("apps/{app}/plans/")]
[ProducesResponseType(typeof(AppPlansDto), 200)]
[ApiCosts(0.5)]
public async Task<IActionResult> GetPlans(string app)
{
var userId = User.FindFirst(OpenIdClaims.Subject).Value;
var response = new AppPlansDto
{
Plans = appPlansProvider.GetAvailablePlans().Select(x => SimpleMapper.Map(x, new PlanDto())).ToList(),
PlanOwner = App.PlanOwner,
HasPortal = appPlansBillingManager.HasPortal,
HasConfigured = await appPlansBillingManager.HasPaymentOptionsAsync(userId),
CurrentPlanId = !string.IsNullOrWhiteSpace(App.PlanId) ? App.PlanId : appPlansBillingManager.FreePlanId
};
Response.Headers["ETag"] = new StringValues(App.Version.ToString());
return Ok(response);
}
/// <summary>
/// Change the app plan.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">Plan object that needs to be changed.</param>
/// <returns>
/// 204 => Plan changed.
/// 400 => Plan not owned by user.
/// 404 => App not found.
/// </returns>
[MustBeAppOwner]
[HttpPut]
[Route("apps/{app}/plan/")]
[ProducesResponseType(typeof(ErrorDto), 400)]
[ApiCosts(0.5)]
public async Task<IActionResult> ChangePlanAsync(string app, [FromBody] ChangePlanDto request)
{
await CommandBus.PublishAsync(SimpleMapper.Map(request, new ChangePlan()));
return NoContent();
}
}
}

40
src/Squidex/Controllers/Api/Plans/Models/AppPlansDto.cs

@ -0,0 +1,40 @@
// ==========================================================================
// AppPlansDto.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System.Collections.Generic;
namespace Squidex.Controllers.Api.Plans.Models
{
public class AppPlansDto
{
/// <summary>
/// The available plans.
/// </summary>
public List<PlanDto> Plans { get; set; }
/// <summary>
/// The current plan id.
/// </summary>
public string CurrentPlanId { get; set; }
/// <summary>
/// The plan owner.
/// </summary>
public string PlanOwner { get; set; }
/// <summary>
/// Indicates if there is a billing portal.
/// </summary>
public bool HasPortal { get; set; }
/// <summary>
/// Indicates if the user has payment options entered so that the plan can be changed.
/// </summary>
public bool HasConfigured { get; set; }
}
}

21
src/Squidex/Controllers/Api/Plans/Models/ChangePlanDto.cs

@ -0,0 +1,21 @@
// ==========================================================================
// ChangePlanDto.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
namespace Squidex.Controllers.Api.Plans.Models
{
public class ChangePlanDto
{
/// <summary>
/// The new plan id.
/// </summary>
[Required]
public string PlanId { get; set; }
}
}

43
src/Squidex/Controllers/Api/Plans/Models/PlanDto.cs

@ -0,0 +1,43 @@
// ==========================================================================
// PlanDto.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
namespace Squidex.Controllers.Api.Plans.Models
{
public class PlanDto
{
/// <summary>
/// The id of the plan.
/// </summary>
public string Id { get; set; }
/// <summary>
/// The name of the plan.
/// </summary>
public string Name { get; set; }
/// <summary>
/// The monthly costs of the plan.
/// </summary>
public string Costs { get; set; }
/// <summary>
/// The maximum number of API calls.
/// </summary>
public long MaxApiCalls { get; set; }
/// <summary>
/// The maximum allowed asset size.
/// </summary>
public long MaxAssetSize { get; set; }
/// <summary>
/// The maximum number of contributors.
/// </summary>
public int MaxContributors { get; set; }
}
}

10
src/Squidex/Controllers/Api/Statistics/UsagesController.cs

@ -30,19 +30,19 @@ namespace Squidex.Controllers.Api.Statistics
public class UsagesController : ControllerBase public class UsagesController : ControllerBase
{ {
private readonly IUsageTracker usageTracker; private readonly IUsageTracker usageTracker;
private readonly IAppLimitsProvider appLimitProvider; private readonly IAppPlansProvider appPlanProvider;
private readonly IAssetStatsRepository assetStatsRepository; private readonly IAssetStatsRepository assetStatsRepository;
public UsagesController( public UsagesController(
ICommandBus commandBus, ICommandBus commandBus,
IUsageTracker usageTracker, IUsageTracker usageTracker,
IAppLimitsProvider appLimitProvider, IAppPlansProvider appPlanProvider,
IAssetStatsRepository assetStatsRepository) IAssetStatsRepository assetStatsRepository)
: base(commandBus) : base(commandBus)
{ {
this.usageTracker = usageTracker; this.usageTracker = usageTracker;
this.appLimitProvider = appLimitProvider; this.appPlanProvider = appPlanProvider;
this.assetStatsRepository = assetStatsRepository; this.assetStatsRepository = assetStatsRepository;
} }
@ -62,7 +62,7 @@ namespace Squidex.Controllers.Api.Statistics
{ {
var count = await usageTracker.GetMonthlyCalls(App.Id.ToString(), DateTime.Today); var count = await usageTracker.GetMonthlyCalls(App.Id.ToString(), DateTime.Today);
var plan = appLimitProvider.GetPlanForApp(App); var plan = appPlanProvider.GetPlanForApp(App);
return Ok(new CurrentCallsDto { Count = count, MaxAllowed = plan.MaxApiCalls }); return Ok(new CurrentCallsDto { Count = count, MaxAllowed = plan.MaxApiCalls });
} }
@ -117,7 +117,7 @@ namespace Squidex.Controllers.Api.Statistics
{ {
var size = await assetStatsRepository.GetTotalSizeAsync(App.Id); var size = await assetStatsRepository.GetTotalSizeAsync(App.Id);
var plan = appLimitProvider.GetPlanForApp(App); var plan = appPlanProvider.GetPlanForApp(App);
return Ok(new CurrentStorageDto { Size = size, MaxAllowed = plan.MaxAssetSize }); return Ok(new CurrentStorageDto { Size = size, MaxAllowed = plan.MaxAssetSize });
} }

9
src/Squidex/Controllers/ContentApi/Generator/SchemasSwaggerGenerator.cs

@ -34,7 +34,6 @@ namespace Squidex.Controllers.ContentApi.Generator
{ {
public sealed class SchemasSwaggerGenerator public sealed class SchemasSwaggerGenerator
{ {
private readonly SwaggerOwinSettings swaggerSettings;
private readonly SwaggerJsonSchemaGenerator schemaGenerator; private readonly SwaggerJsonSchemaGenerator schemaGenerator;
private readonly SwaggerDocument document = new SwaggerDocument { Tags = new List<SwaggerTag>() }; private readonly SwaggerDocument document = new SwaggerDocument { Tags = new List<SwaggerTag>() };
private readonly HttpContext context; private readonly HttpContext context;
@ -60,8 +59,6 @@ namespace Squidex.Controllers.ContentApi.Generator
schemaBodyDescription = SwaggerHelper.LoadDocs("schemabody"); schemaBodyDescription = SwaggerHelper.LoadDocs("schemabody");
schemaQueryDescription = SwaggerHelper.LoadDocs("schemaquery"); schemaQueryDescription = SwaggerHelper.LoadDocs("schemaquery");
this.swaggerSettings = swaggerSettings;
} }
public async Task<SwaggerDocument> Generate(IAppEntity targetApp, IEnumerable<ISchemaEntity> schemas) public async Task<SwaggerDocument> Generate(IAppEntity targetApp, IEnumerable<ISchemaEntity> schemas)
@ -133,8 +130,7 @@ namespace Squidex.Controllers.ContentApi.Generator
private async Task GenerateBasicSchemas() private async Task GenerateBasicSchemas()
{ {
var errorType = typeof(ErrorDto); var errorType = typeof(ErrorDto);
var errorContract = swaggerSettings.ActualContractResolver.ResolveContract(errorType); var errorSchema = JsonObjectTypeDescription.FromType(errorType, new Attribute[0], EnumHandling.String);
var errorSchema = JsonObjectTypeDescription.FromType(errorType, errorContract, new Attribute[0], EnumHandling.String);
errorDtoSchema = await swaggerGenerator.GenerateAndAppendSchemaFromTypeAsync(errorType, errorSchema.IsNullable, null); errorDtoSchema = await swaggerGenerator.GenerateAndAppendSchemaFromTypeAsync(errorType, errorSchema.IsNullable, null);
} }
@ -179,7 +175,8 @@ namespace Squidex.Controllers.ContentApi.Generator
document.Tags.Add( document.Tags.Add(
new SwaggerTag new SwaggerTag
{ {
Name = schemaName, Description = $"API to managed {schemaName} contents." Name = schemaName,
Description = $"API to managed {schemaName} contents."
}); });
var dataSchema = AppendSchema($"{schemaIdentifier}Dto", schema.BuildJsonSchema(app.PartitionResolver, AppendSchema)); var dataSchema = AppendSchema($"{schemaIdentifier}Dto", schema.BuildJsonSchema(app.PartitionResolver, AppendSchema));

40
src/Squidex/Controllers/UI/Profile/PortalController.cs

@ -0,0 +1,40 @@
// ==========================================================================
// PortalController.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NSwag.Annotations;
using Squidex.Infrastructure.Security;
using Squidex.Read.Apps.Services;
namespace Squidex.Controllers.UI.Profile
{
[Authorize]
[SwaggerIgnore]
public class PortalController : Controller
{
private readonly IAppPlanBillingManager appPlansBillingManager;
public PortalController(IAppPlanBillingManager appPlansBillingManager)
{
this.appPlansBillingManager = appPlansBillingManager;
}
[HttpGet]
[Route("/account/portal")]
public async Task<IActionResult> Portal()
{
var userId = User.FindFirst(OpenIdClaims.Subject).Value;
var redirectUrl = await appPlansBillingManager.GetPortalLinkAsync(userId);
return Redirect(redirectUrl);
}
}
}

8
src/Squidex/Pipeline/AppApiFilter.cs

@ -26,13 +26,13 @@ namespace Squidex.Pipeline
public sealed class AppApiFilter : IAsyncAuthorizationFilter public sealed class AppApiFilter : IAsyncAuthorizationFilter
{ {
private readonly IAppProvider appProvider; private readonly IAppProvider appProvider;
private readonly IAppLimitsProvider appLimitProvider; private readonly IAppPlansProvider appPlanProvider;
private readonly IUsageTracker usageTracker; private readonly IUsageTracker usageTracker;
public AppApiFilter(IAppProvider appProvider, IAppLimitsProvider appLimitProvider, IUsageTracker usageTracker) public AppApiFilter(IAppProvider appProvider, IAppPlansProvider appPlanProvider, IUsageTracker usageTracker)
{ {
this.appProvider = appProvider; this.appProvider = appProvider;
this.appLimitProvider = appLimitProvider; this.appPlanProvider = appPlanProvider;
this.usageTracker = usageTracker; this.usageTracker = usageTracker;
} }
@ -63,7 +63,7 @@ namespace Squidex.Pipeline
return; return;
} }
var plan = appLimitProvider.GetPlanForApp(app); var plan = appPlanProvider.GetPlanForApp(app);
var usage = await usageTracker.GetMonthlyCalls(app.Id.ToString(), DateTime.Today); var usage = await usageTracker.GetMonthlyCalls(app.Id.ToString(), DateTime.Today);

6
src/Squidex/Squidex.csproj

@ -41,7 +41,7 @@
<PackageReference Include="AspNet.Security.OAuth.GitHub" Version="1.0.0-rc1-final" /> <PackageReference Include="AspNet.Security.OAuth.GitHub" Version="1.0.0-rc1-final" />
<PackageReference Include="Autofac" Version="4.6.0" /> <PackageReference Include="Autofac" Version="4.6.0" />
<PackageReference Include="Autofac.Extensions.DependencyInjection" Version="4.1.0" /> <PackageReference Include="Autofac.Extensions.DependencyInjection" Version="4.1.0" />
<PackageReference Include="IdentityServer4" Version="1.5.1" /> <PackageReference Include="IdentityServer4" Version="1.5.2" />
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="1.2.1" /> <PackageReference Include="IdentityServer4.AccessTokenValidation" Version="1.2.1" />
<PackageReference Include="IdentityServer4.AspNetIdentity" Version="1.0.1" /> <PackageReference Include="IdentityServer4.AspNetIdentity" Version="1.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Cookies" Version="1.1.2" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.Cookies" Version="1.1.2" />
@ -63,8 +63,8 @@
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="1.1.2" /> <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="1.1.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="1.1.2" /> <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="1.1.2" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="1.1.2" /> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="1.1.2" />
<PackageReference Include="MongoDB.Driver" Version="2.4.3" /> <PackageReference Include="MongoDB.Driver" Version="2.4.4" />
<PackageReference Include="NJsonSchema" Version="9.1.2" /> <PackageReference Include="NJsonSchema" Version="8.33.6323.36213" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="2.0.0" /> <PackageReference Include="NodaTime.Serialization.JsonNet" Version="2.0.0" />
<PackageReference Include="NSwag.AspNetCore" Version="10.6.0" /> <PackageReference Include="NSwag.AspNetCore" Version="10.6.0" />
<PackageReference Include="OpenCover" Version="4.6.519" /> <PackageReference Include="OpenCover" Version="4.6.519" />

1
src/Squidex/app/features/settings/declarations.ts

@ -10,5 +10,6 @@ export * from './pages/clients/clients-page.component';
export * from './pages/contributors/contributors-page.component'; export * from './pages/contributors/contributors-page.component';
export * from './pages/languages/language.component'; export * from './pages/languages/language.component';
export * from './pages/languages/languages-page.component'; export * from './pages/languages/languages-page.component';
export * from './pages/plans/plans-page.component';
export * from './settings-area.component'; export * from './settings-area.component';

6
src/Squidex/app/features/settings/module.ts

@ -22,6 +22,7 @@ import {
ContributorsPageComponent, ContributorsPageComponent,
LanguageComponent, LanguageComponent,
LanguagesPageComponent, LanguagesPageComponent,
PlansPageComponent,
SettingsAreaComponent SettingsAreaComponent
} from './declarations'; } from './declarations';
@ -33,6 +34,10 @@ const routes: Routes = [
{ {
path: '' path: ''
}, },
{
path: 'plans',
component: PlansPageComponent
},
{ {
path: 'clients', path: 'clients',
component: ClientsPageComponent, component: ClientsPageComponent,
@ -110,6 +115,7 @@ const routes: Routes = [
ContributorsPageComponent, ContributorsPageComponent,
LanguageComponent, LanguageComponent,
LanguagesPageComponent, LanguagesPageComponent,
PlansPageComponent,
SettingsAreaComponent SettingsAreaComponent
] ]
}) })

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

@ -13,7 +13,7 @@
<div class="panel-main"> <div class="panel-main">
<div class="panel-content panel-content-scroll"> <div class="panel-content panel-content-scroll">
<div class="contributors-limit" *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>

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

@ -5,12 +5,3 @@
font-style: italic; font-style: italic;
font-size: .8rem; font-size: .8rem;
} }
.contributors-limit {
margin: -$panel-padding;
margin-bottom: $panel-padding;
color: $color-dark-foreground;
background: $color-theme-green-dark;
border: 0;
padding: .5 * $panel-padding $panel-padding;
}

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

@ -0,0 +1,71 @@
<sqx-title message="{app} | Plans | Settings" parameter1="app" value1="{{appName() | async}}"></sqx-title>
<sqx-panel panelWidth="60rem">
<div class="panel-header">
<div class="panel-title-row">
<div class="float-right">
<button class="btn btn-link btn-decent" (click)="load(true)" title="Refresh Plans (CTRL + SHIFT + R)">
<i class="icon-reset"></i> Refresh
</button>
<sqx-shortcut keys="ctrl+shift+r" (trigger)="load(true)"></sqx-shortcut>
</div>
<h3 class="panel-title">Update Plan</h3>
</div>
<a class="panel-close" sqxParentLink>
<i class="icon-close"></i>
</a>
</div>
<div class="panel-main">
<div class="panel-content">
<div *ngIf="plans">
<div class="panel-alert panel-alert-danger" *ngIf="!plans.hasConfigured || !planOwned">
<div *ngIf="!plans.hasConfigured">
You have not configured your account yet. Go to <a target="_blank" href="{{portalUrl}}">Billing Portal</a> to add payment options.
</div>
<div *ngIf="!planOwned">
You have not created the subscription. Therefore you cannot change the plan.
</div>
</div>
<div class="clearfix">
<div class="card plan float-left" *ngFor="let plan of plans.plans">
<div class="card-block plan-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 class="card-block">
<div class="plan-fact">
{{formatCalls(plan.maxApiCalls)}} API Calls
</div>
<div class="plan-fact">
{{formatSize(plan.maxAssetSize)}} Storage
</div>
<div class="plan-fact">
{{plan.maxContributors}} Contributors
</div>
</div>
<div class="card-block">
<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 || !plans.hasConfigured || !planOwned" (click)="changePlan(plan.id)">
Change
</button>
</div>
</div>
</div>
<div *ngIf="plans.hasPortal" class="billing-portal-link">
Go to <a target="_blank" href="{{portalUrl}}">Billing Portal</a> for payment history and subscription overview.
</div>
</div>
</div>
</div>
</sqx-panel>

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

@ -0,0 +1,35 @@
@import '_vars';
@import '_mixins';
.panel-content {
padding-left: 1rem;
padding-right: 1rem;
}
.plan {
& {
min-width: 13rem;
max-width: 20rem;
margin: .5rem;
}
&-header {
border-bottom: 1px solid $color-border;
}
&-price {
color: $color-theme-blue;
}
&-selected {
pointer-events: none;
}
&-fact {
line-height: 2rem;
}
}
.billing-portal-link {
padding: 2rem .5rem 0;
}

108
src/Squidex/app/features/settings/pages/plans/plans-page.component.ts

@ -0,0 +1,108 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Sebastian Stehle. All rights reserved
*/
import { Component, OnInit } from '@angular/core';
import {
ApiUrlConfig,
AppComponentBase,
AppPlansDto,
AppsStoreService,
AuthService,
ChangePlanDto,
FileHelper,
NotificationService,
PlansService,
Version
} from 'shared';
@Component({
selector: 'sqx-plans-page',
styleUrls: ['./plans-page.component.scss'],
templateUrl: './plans-page.component.html'
})
export class PlansPageComponent extends AppComponentBase implements OnInit {
private version = new Version();
public portalUrl = this.apiUrl.buildUrl('/identity-server/account/portal');
public plans: AppPlansDto;
public planOwned = false;
public isDisabled = false;
constructor(apps: AppsStoreService, notifications: NotificationService,
private readonly authService: AuthService,
private readonly plansService: PlansService,
private readonly apiUrl: ApiUrlConfig
) {
super(notifications, apps);
}
public ngOnInit() {
this.load();
}
public load(showInfo = false) {
this.appNameOnce()
.switchMap(app => this.plansService.getPlans(app, this.version).retry(2))
.subscribe(dto => {
this.plans = dto;
this.planOwned = !dto.planOwner || (dto.planOwner === this.authService.user!.id);
if (showInfo) {
this.notifyInfo('Plans reloaded.');
}
}, error => {
this.notifyError(error);
});
}
public changePlan(planId: string) {
this.isDisabled = true;
this.appNameOnce()
.switchMap(app => this.plansService.putPlan(app, new ChangePlanDto(planId), this.version))
.subscribe(dto => {
this.plans =
new AppPlansDto(planId,
this.plans.planOwner,
this.plans.hasPortal,
this.plans.hasConfigured,
this.plans.plans);
this.isDisabled = false;
}, error => {
this.notifyError(error);
this.isDisabled = false;
});
}
public formatSize(count: number): string {
return FileHelper.fileSize(count);
}
public formatCalls(count: number): string {
if (count > 1000) {
count = count / 1000;
if (count < 10) {
count = Math.round(count * 10) / 10;
} else {
count = Math.round(count);
}
return count + 'k';
} else if (count < 0) {
return undefined;
} else {
return count.toString();
}
}
}

6
src/Squidex/app/features/settings/settings-area.component.html

@ -32,6 +32,12 @@
<i class="icon-angle-right"></i> <i class="icon-angle-right"></i>
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" routerLink="plans" routerLinkActive="active">
Update Plan
<i class="icon-angle-right"></i>
</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>

1
src/Squidex/app/shared/declarations-base.ts

@ -26,6 +26,7 @@ export * from './services/event-consumers.service';
export * from './services/help.service'; export * from './services/help.service';
export * from './services/history.service'; export * from './services/history.service';
export * from './services/languages.service'; export * from './services/languages.service';
export * from './services/plans.service';
export * from './services/schemas.service'; export * from './services/schemas.service';
export * from './services/usages.service'; export * from './services/usages.service';
export * from './services/users-provider.service'; export * from './services/users-provider.service';

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

@ -33,6 +33,7 @@ import {
LanguageService, LanguageService,
MustBeAuthenticatedGuard, MustBeAuthenticatedGuard,
MustBeNotAuthenticatedGuard, MustBeNotAuthenticatedGuard,
PlansService,
ResolveAppLanguagesGuard, ResolveAppLanguagesGuard,
ResolveContentGuard, ResolveContentGuard,
ResolvePublishedSchemaGuard, ResolvePublishedSchemaGuard,
@ -106,6 +107,7 @@ export class SqxSharedModule {
LanguageService, LanguageService,
MustBeAuthenticatedGuard, MustBeAuthenticatedGuard,
MustBeNotAuthenticatedGuard, MustBeNotAuthenticatedGuard,
PlansService,
ResolveAppLanguagesGuard, ResolveAppLanguagesGuard,
ResolveContentGuard, ResolveContentGuard,
ResolvePublishedSchemaGuard, ResolvePublishedSchemaGuard,

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

@ -0,0 +1,87 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Sebastian Stehle. All rights reserved
*/
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import 'framework/angular/http-extensions';
import { ApiUrlConfig, Version } from 'framework';
import { AuthService } from './auth.service';
export class AppPlansDto {
constructor(
public readonly currentPlanId: string,
public readonly planOwner: string,
public readonly hasPortal: boolean,
public readonly hasConfigured: boolean,
public readonly plans: PlanDto[]
) {
}
}
export class PlanDto {
constructor(
public readonly id: string,
public readonly name: string,
public readonly costs: string,
public readonly maxApiCalls: number,
public readonly maxAssetSize: number,
public readonly maxContributors: number
) {
}
}
export class ChangePlanDto {
constructor(
public readonly planId: string
) {
}
}
@Injectable()
export class PlansService {
constructor(
private readonly authService: AuthService,
private readonly apiUrl: ApiUrlConfig
) {
}
public getPlans(appName: string, version?: Version): Observable<AppPlansDto> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/plans`);
return this.authService.authGet(url, version)
.map(response => response.json())
.map(response => {
const items: any[] = response.plans;
return new AppPlansDto(
response.currentPlanId,
response.planOwner,
response.hasPortal,
response.hasConfigured,
items.map(item => {
return new PlanDto(
item.id,
item.name,
item.costs,
item.maxApiCalls,
item.maxAssetSize,
item.maxContributors);
}));
})
.catchError('Failed to load plans. Please reload.');
}
public putPlan(appName: string, dto: ChangePlanDto, version?: Version): Observable<any> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/plan`);
return this.authService.authPut(url, dto, version)
.catchError('Failed to change plan. Please reload.');
}
}

29
src/Squidex/app/theme/_panels.scss

@ -105,6 +105,35 @@
} }
} }
// Error or alert on top of the panel content.
&-alert {
& {
padding: .5 * $panel-padding $panel-padding;
margin: -$panel-padding;
margin-bottom: $panel-padding;
font-size: .9rem;
font-weight: normal;
color: $color-dark-foreground;
}
a {
color: $color-dark-foreground;
}
a,
a:hover {
text-decoration: underline;
}
&-success {
background: $color-theme-green-dark;
}
&-danger {
background: $color-theme-error-dark;
}
}
// Second column of the main row (second row) with additional links. // Second column of the main row (second row) with additional links.
&-sidebar { &-sidebar {
& { & {

2
tests/Squidex.Core.Tests/Squidex.Core.Tests.csproj

@ -12,7 +12,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentAssertions" Version="4.19.2" /> <PackageReference Include="FluentAssertions" Version="4.19.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.0.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.0.0" />
<PackageReference Include="Moq" Version="4.7.10" /> <PackageReference Include="Moq" Version="4.7.25" />
<PackageReference Include="xunit" Version="2.2.0" /> <PackageReference Include="xunit" Version="2.2.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.2.0" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.2.0" />
</ItemGroup> </ItemGroup>

2
tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj

@ -12,7 +12,7 @@
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="1.1.2" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="1.1.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="1.1.2" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="1.1.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.0.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.0.0" />
<PackageReference Include="Moq" Version="4.7.10" /> <PackageReference Include="Moq" Version="4.7.25" />
<PackageReference Include="xunit" Version="2.2.0" /> <PackageReference Include="xunit" Version="2.2.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.2.0" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.2.0" />
</ItemGroup> </ItemGroup>

20
tests/Squidex.Read.Tests/Apps/ConfigAppLimitsProviderTests.cs

@ -20,6 +20,7 @@ namespace Squidex.Read.Apps
{ {
new ConfigAppLimitsPlan new ConfigAppLimitsPlan
{ {
Id = "basic",
Name = "Basic", Name = "Basic",
MaxApiCalls = 150000, MaxApiCalls = 150000,
MaxAssetSize = 1024 * 1024 * 2, MaxAssetSize = 1024 * 1024 * 2,
@ -27,6 +28,7 @@ namespace Squidex.Read.Apps
}, },
new ConfigAppLimitsPlan new ConfigAppLimitsPlan
{ {
Id = "free",
Name = "Free", Name = "Free",
MaxApiCalls = 50000, MaxApiCalls = 50000,
MaxAssetSize = 1024 * 1024 * 10, MaxAssetSize = 1024 * 1024 * 10,
@ -37,7 +39,7 @@ namespace Squidex.Read.Apps
[Fact] [Fact]
public void Should_return_plans() public void Should_return_plans()
{ {
var sut = new ConfigAppLimitsProvider(Plans); var sut = new ConfigAppPlansProvider(Plans);
Plans.OrderBy(x => x.MaxApiCalls).ShouldBeEquivalentTo(sut.GetAvailablePlans()); Plans.OrderBy(x => x.MaxApiCalls).ShouldBeEquivalentTo(sut.GetAvailablePlans());
} }
@ -45,9 +47,9 @@ namespace Squidex.Read.Apps
[Fact] [Fact]
public void Should_return_infinite_if_nothing_configured() public void Should_return_infinite_if_nothing_configured()
{ {
var sut = new ConfigAppLimitsProvider(Enumerable.Empty<ConfigAppLimitsPlan>()); var sut = new ConfigAppPlansProvider(Enumerable.Empty<ConfigAppLimitsPlan>());
var plan = sut.GetPlanForApp(CreateApp(0)); var plan = sut.GetPlanForApp(CreateApp("my-plan"));
plan.ShouldBeEquivalentTo(new ConfigAppLimitsPlan plan.ShouldBeEquivalentTo(new ConfigAppLimitsPlan
{ {
@ -61,12 +63,13 @@ namespace Squidex.Read.Apps
[Fact] [Fact]
public void Should_return_fitting_app_plan() public void Should_return_fitting_app_plan()
{ {
var sut = new ConfigAppLimitsProvider(Plans); var sut = new ConfigAppPlansProvider(Plans);
var plan = sut.GetPlanForApp(CreateApp(1)); var plan = sut.GetPlanForApp(CreateApp("basic"));
plan.ShouldBeEquivalentTo(new ConfigAppLimitsPlan plan.ShouldBeEquivalentTo(new ConfigAppLimitsPlan
{ {
Id = "basic",
Name = "Basic", Name = "Basic",
MaxApiCalls = 150000, MaxApiCalls = 150000,
MaxAssetSize = 1024 * 1024 * 2, MaxAssetSize = 1024 * 1024 * 2,
@ -77,12 +80,13 @@ namespace Squidex.Read.Apps
[Fact] [Fact]
public void Should_smallest_plan_if_none_fits() public void Should_smallest_plan_if_none_fits()
{ {
var sut = new ConfigAppLimitsProvider(Plans); var sut = new ConfigAppPlansProvider(Plans);
var plan = sut.GetPlanForApp(CreateApp(4)); var plan = sut.GetPlanForApp(CreateApp("Enterprise"));
plan.ShouldBeEquivalentTo(new ConfigAppLimitsPlan plan.ShouldBeEquivalentTo(new ConfigAppLimitsPlan
{ {
Id = "free",
Name = "Free", Name = "Free",
MaxApiCalls = 50000, MaxApiCalls = 50000,
MaxAssetSize = 1024 * 1024 * 10, MaxAssetSize = 1024 * 1024 * 10,
@ -90,7 +94,7 @@ namespace Squidex.Read.Apps
}); });
} }
private static IAppEntity CreateApp(int plan) private static IAppEntity CreateApp(string plan)
{ {
var app = new Mock<IAppEntity>(); var app = new Mock<IAppEntity>();

4
tests/Squidex.Read.Tests/Squidex.Read.Tests.csproj

@ -14,8 +14,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentAssertions" Version="4.19.2" /> <PackageReference Include="FluentAssertions" Version="4.19.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.0.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.0.0" />
<PackageReference Include="MongoDB.Driver" Version="2.4.3" /> <PackageReference Include="MongoDB.Driver" Version="2.4.4" />
<PackageReference Include="Moq" Version="4.7.10" /> <PackageReference Include="Moq" Version="4.7.25" />
<PackageReference Include="xunit" Version="2.2.0" /> <PackageReference Include="xunit" Version="2.2.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.2.0" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.2.0" />
</ItemGroup> </ItemGroup>

9
tests/Squidex.Write.Tests/Apps/AppCommandHandlerTests.cs

@ -30,7 +30,8 @@ namespace Squidex.Write.Apps
{ {
private readonly Mock<ClientKeyGenerator> keyGenerator = new Mock<ClientKeyGenerator>(); private readonly Mock<ClientKeyGenerator> keyGenerator = new Mock<ClientKeyGenerator>();
private readonly Mock<IAppRepository> appRepository = new Mock<IAppRepository>(); private readonly Mock<IAppRepository> appRepository = new Mock<IAppRepository>();
private readonly Mock<IAppLimitsProvider> appLimitsProvider = new Mock<IAppLimitsProvider>(); private readonly Mock<IAppPlansProvider> appLimitsProvider = new Mock<IAppPlansProvider>();
private readonly Mock<IAppPlanBillingManager> appPlansBillingManager = new Mock<IAppPlanBillingManager>();
private readonly Mock<IUserResolver> userResolver = new Mock<IUserResolver>(); private readonly Mock<IUserResolver> userResolver = new Mock<IUserResolver>();
private readonly AppCommandHandler sut; private readonly AppCommandHandler sut;
private readonly AppDomainObject app; private readonly AppDomainObject app;
@ -43,7 +44,7 @@ namespace Squidex.Write.Apps
{ {
app = new AppDomainObject(AppId, -1); app = new AppDomainObject(AppId, -1);
sut = new AppCommandHandler(Handler, appRepository.Object, appLimitsProvider.Object, userResolver.Object, keyGenerator.Object); sut = new AppCommandHandler(Handler, appRepository.Object, appLimitsProvider.Object, appPlansBillingManager.Object, userResolver.Object, keyGenerator.Object);
} }
[Fact] [Fact]
@ -98,7 +99,7 @@ namespace Squidex.Write.Apps
[Fact] [Fact]
public async Task AssignContributor_throw_exception_if_reached_max_contributor_size() public async Task AssignContributor_throw_exception_if_reached_max_contributor_size()
{ {
appLimitsProvider.Setup(x => x.GetPlan(0)).Returns(new ConfigAppLimitsPlan { MaxContributors = 2 }); appLimitsProvider.Setup(x => x.GetPlan("free")).Returns(new ConfigAppLimitsPlan { MaxContributors = 2 });
CreateApp() CreateApp()
.AssignContributor(CreateCommand(new AssignContributor { ContributorId = "1" })) .AssignContributor(CreateCommand(new AssignContributor { ContributorId = "1" }))
@ -132,7 +133,7 @@ namespace Squidex.Write.Apps
[Fact] [Fact]
public async Task AssignContributor_should_assign_if_user_found() public async Task AssignContributor_should_assign_if_user_found()
{ {
appLimitsProvider.Setup(x => x.GetPlan(0)).Returns(new ConfigAppLimitsPlan { MaxContributors = -1 }); appLimitsProvider.Setup(x => x.GetPlan("free")).Returns(new ConfigAppLimitsPlan { MaxContributors = -1 });
CreateApp(); CreateApp();

4
tests/Squidex.Write.Tests/Squidex.Write.Tests.csproj

@ -13,8 +13,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentAssertions" Version="4.19.2" /> <PackageReference Include="FluentAssertions" Version="4.19.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.0.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.0.0" />
<PackageReference Include="MongoDB.Driver" Version="2.4.3" /> <PackageReference Include="MongoDB.Driver" Version="2.4.4" />
<PackageReference Include="Moq" Version="4.7.10" /> <PackageReference Include="Moq" Version="4.7.25" />
<PackageReference Include="xunit" Version="2.2.0" /> <PackageReference Include="xunit" Version="2.2.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.2.0" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.2.0" />
</ItemGroup> </ItemGroup>

Loading…
Cancel
Save