Browse Source

Feature/client contingent (#563)

* T

* Temporary

* Temp.

* Limits

* Localization fix.

* Test improvement.

* Fixes.

* Tests fixed

* TS build fix.

* Use client library.
pull/566/head
Sebastian Stehle 5 years ago
committed by GitHub
parent
commit
2ee47638b0
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      backend/i18n/frontend_en.json
  2. 6
      backend/i18n/frontend_nl.json
  3. 4
      backend/i18n/source/backend__ignore.json
  4. 4
      backend/i18n/source/frontend__ignore.json
  5. 4
      backend/i18n/source/frontend_en.json
  6. 20
      backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs
  7. 6
      backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClients.cs
  8. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs
  9. 6
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs
  10. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/ContentSchemaBuilder.cs
  11. 4
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateClient.cs
  12. 12
      backend/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppClients.cs
  13. 21
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs
  14. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs
  15. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs
  16. 15
      backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateCommandMiddleware.cs
  17. 2
      backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs
  18. 4
      backend/src/Squidex.Domain.Apps.Events/Apps/AppClientUpdated.cs
  19. 2
      backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs
  20. 5
      backend/src/Squidex.Infrastructure/StringExtensions.cs
  21. 8
      backend/src/Squidex.Infrastructure/UsageTracking/ApiUsageTracker.cs
  22. 11
      backend/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs
  23. 12
      backend/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs
  24. 4
      backend/src/Squidex.Infrastructure/UsageTracking/IApiUsageTracker.cs
  25. 4
      backend/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs
  26. 5
      backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs
  27. 24
      backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs
  28. 4
      backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs
  29. 15
      backend/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs
  30. 10
      backend/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateClientDto.cs
  31. 4
      backend/src/Squidex/Config/Authentication/IdentityServerServices.cs
  32. 2
      backend/src/Squidex/Program.cs
  33. 2
      backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientJsonTests.cs
  34. 30
      backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientsTests.cs
  35. 22
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppClientsTests.cs
  36. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppRolesTests.cs
  37. 36
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/UsageGateTests.cs
  38. 3
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs
  39. 4
      backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs
  40. 9
      backend/tests/Squidex.Infrastructure.Tests/UsageTracking/ApiUsageTrackerTests.cs
  41. 30
      backend/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs
  42. 17
      backend/tests/Squidex.Infrastructure.Tests/UsageTracking/CachingUsageTrackerTests.cs
  43. 8
      backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs
  44. 4
      backend/tests/Squidex.Web.Tests/Pipeline/AppResolverTests.cs
  45. 41
      backend/tests/Squidex.Web.Tests/Pipeline/CleanupHostMiddlewareTests.cs
  46. 2
      frontend/app/app.module.ts
  47. 23
      frontend/app/features/settings/pages/clients/client.component.html
  48. 20
      frontend/app/features/settings/pages/clients/client.component.ts
  49. 10
      frontend/app/framework/angular/forms/editors/date-time-editor.component.html
  50. 4
      frontend/app/framework/utils/date-time.spec.ts
  51. 4
      frontend/app/shared/services/clients.service.spec.ts
  52. 6
      frontend/app/shared/services/clients.service.ts

6
backend/i18n/frontend_en.json

@ -134,6 +134,8 @@
"clients.addFailed": "Failed to add client. Please reload.", "clients.addFailed": "Failed to add client. Please reload.",
"clients.allowAnonymous": "Allow anonymous access.", "clients.allowAnonymous": "Allow anonymous access.",
"clients.allowAnonymousHint": "Allow access to the API without an access token to all resources that are configured via the role of this client. Do not give more than one client anonymous access.", "clients.allowAnonymousHint": "Allow access to the API without an access token to all resources that are configured via the role of this client. Do not give more than one client anonymous access.",
"clients.apiCallsLimit": "Max API Calls",
"clients.apiCallsLimitHint": "Limit the number of API calls this client can make per month to protect your API contingent for other clients that are more important.",
"clients.clientIdValidationMessage": "Name can only contain letters, numbers, dashes and spaces.", "clients.clientIdValidationMessage": "Name can only contain letters, numbers, dashes and spaces.",
"clients.clientNamePlaceholder": "Enter client name", "clients.clientNamePlaceholder": "Enter client name",
"clients.connect": "Connect", "clients.connect": "Connect",
@ -209,10 +211,12 @@
"common.create": "Create", "common.create": "Create",
"common.created": "Created", "common.created": "Created",
"common.date": "Date", "common.date": "Date",
"common.dateTimeEditor.local": "Local",
"common.dateTimeEditor.now": "Now", "common.dateTimeEditor.now": "Now",
"common.dateTimeEditor.nowTooltip": "Use Now", "common.dateTimeEditor.nowTooltip": "Use Now (UTC)",
"common.dateTimeEditor.today": "Today", "common.dateTimeEditor.today": "Today",
"common.dateTimeEditor.todayTooltip": "Use Today (UTC)", "common.dateTimeEditor.todayTooltip": "Use Today (UTC)",
"common.dateTimeEditor.utc": "UTC",
"common.delete": "Delete", "common.delete": "Delete",
"common.description": "Description", "common.description": "Description",
"common.displayName": "Display Name", "common.displayName": "Display Name",

6
backend/i18n/frontend_nl.json

@ -134,6 +134,8 @@
"clients.addFailed": "Toevoegen van client is mislukt. Laad opnieuw.", "clients.addFailed": "Toevoegen van client is mislukt. Laad opnieuw.",
"clients.allowAnonymous": "Sta anonieme toegang toe.", "clients.allowAnonymous": "Sta anonieme toegang toe.",
"clients.allowAnonymousHint": "Sta toegang tot de API toe zonder toegangstoken voor alle bronnen die zijn geconfigureerd via de rol van deze client. Geef niet meer dan één client anonieme toegang.", "clients.allowAnonymousHint": "Sta toegang tot de API toe zonder toegangstoken voor alle bronnen die zijn geconfigureerd via de rol van deze client. Geef niet meer dan één client anonieme toegang.",
"clients.apiCallsLimit": "Max API Calls",
"clients.apiCallsLimitHint": "Limit the number of API calls this client can make per month to protect your API contingent for other clients that are more important.",
"clients.clientIdValidationMessage": "Naam mag alleen letters, cijfers, streepjes en spaties bevatten.", "clients.clientIdValidationMessage": "Naam mag alleen letters, cijfers, streepjes en spaties bevatten.",
"clients.clientNamePlaceholder": "Voer de naam van de klant in", "clients.clientNamePlaceholder": "Voer de naam van de klant in",
"clients.connect": "Verbinden", "clients.connect": "Verbinden",
@ -209,10 +211,12 @@
"common.create": "Maken", "common.create": "Maken",
"common.created": "Gemaakt", "common.created": "Gemaakt",
"common.date": "Datum", "common.date": "Datum",
"common.dateTimeEditor.local": "Local",
"common.dateTimeEditor.now": "Nu", "common.dateTimeEditor.now": "Nu",
"common.dateTimeEditor.nowTooltip": "Nu gebruiken", "common.dateTimeEditor.nowTooltip": "Nu gebruiken (UTC)",
"common.dateTimeEditor.today": "Vandaag", "common.dateTimeEditor.today": "Vandaag",
"common.dateTimeEditor.todayTooltip": "Gebruik vandaag (UTC)", "common.dateTimeEditor.todayTooltip": "Gebruik vandaag (UTC)",
"common.dateTimeEditor.utc": "UTC",
"common.delete": "Verwijderen", "common.delete": "Verwijderen",
"common.description": "Beschrijving", "common.description": "Beschrijving",
"common.displayName": "Weergavenaam", "common.displayName": "Weergavenaam",

4
backend/i18n/source/backend__ignore.json

@ -151,10 +151,10 @@
"/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/ElasticSearchTextIndex.cs": [ "/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/ElasticSearchTextIndex.cs": [
"*" "*"
], ],
"/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IndexManager_Impl.cs": [ "/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IndexManager.cs": [
"*" "*"
], ],
"/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IndexManager.cs": [ "/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IndexManager_Impl.cs": [
"*" "*"
], ],
"/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailSender.cs": [ "/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailSender.cs": [

4
backend/i18n/source/frontend__ignore.json

@ -19,8 +19,8 @@
"#{{index + 1}}" "#{{index + 1}}"
], ],
"/features/content/shared/forms/field-editor.component.html": [ "/features/content/shared/forms/field-editor.component.html": [
"{{field.displayName}} {{displaySuffix}}", "*",
"*" "{{field.displayName}} {{displaySuffix}}"
], ],
"/features/content/shared/references/references-editor.component.html": [ "/features/content/shared/references/references-editor.component.html": [
"·" "·"

4
backend/i18n/source/frontend_en.json

@ -134,6 +134,8 @@
"clients.addFailed": "Failed to add client. Please reload.", "clients.addFailed": "Failed to add client. Please reload.",
"clients.allowAnonymous": "Allow anonymous access.", "clients.allowAnonymous": "Allow anonymous access.",
"clients.allowAnonymousHint": "Allow access to the API without an access token to all resources that are configured via the role of this client. Do not give more than one client anonymous access.", "clients.allowAnonymousHint": "Allow access to the API without an access token to all resources that are configured via the role of this client. Do not give more than one client anonymous access.",
"clients.apiCallsLimit": "Max API Calls",
"clients.apiCallsLimitHint": "Limit the number of API calls this client can make per month to protect your API contingent for other clients that are more important.",
"clients.clientIdValidationMessage": "Name can only contain letters, numbers, dashes and spaces.", "clients.clientIdValidationMessage": "Name can only contain letters, numbers, dashes and spaces.",
"clients.clientNamePlaceholder": "Enter client name", "clients.clientNamePlaceholder": "Enter client name",
"clients.connect": "Connect", "clients.connect": "Connect",
@ -209,10 +211,12 @@
"common.create": "Create", "common.create": "Create",
"common.created": "Created", "common.created": "Created",
"common.date": "Date", "common.date": "Date",
"common.dateTimeEditor.local": "Local",
"common.dateTimeEditor.now": "Now", "common.dateTimeEditor.now": "Now",
"common.dateTimeEditor.nowTooltip": "Use Now (UTC)", "common.dateTimeEditor.nowTooltip": "Use Now (UTC)",
"common.dateTimeEditor.today": "Today", "common.dateTimeEditor.today": "Today",
"common.dateTimeEditor.todayTooltip": "Use Today (UTC)", "common.dateTimeEditor.todayTooltip": "Use Today (UTC)",
"common.dateTimeEditor.utc": "UTC",
"common.delete": "Delete", "common.delete": "Delete",
"common.description": "Description", "common.description": "Description",
"common.displayName": "Display Name", "common.displayName": "Display Name",

20
backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs

@ -17,25 +17,35 @@ namespace Squidex.Domain.Apps.Core.Apps
public string Secret { get; } public string Secret { get; }
public bool AllowAnonymous { get; set; } public long ApiCallsLimit { get; }
public AppClient(string name, string secret, string role, bool allowAnonymous) public long ApiTrafficLimit { get; }
public bool AllowAnonymous { get; }
public AppClient(string name, string secret, string role, long apiCallsLimit = 0, long apiTrafficLimit = 0, bool allowAnonymous = false)
: base(name) : base(name)
{ {
Guard.NotNullOrEmpty(secret, nameof(secret)); Guard.NotNullOrEmpty(secret, nameof(secret));
Guard.NotNullOrEmpty(role, nameof(role)); Guard.NotNullOrEmpty(role, nameof(role));
Guard.GreaterEquals(apiCallsLimit, 0, nameof(apiCallsLimit));
Guard.GreaterEquals(apiTrafficLimit, 0, nameof(apiTrafficLimit));
Secret = secret;
Role = role; Role = role;
Secret = secret; ApiCallsLimit = apiCallsLimit;
ApiTrafficLimit = apiTrafficLimit;
AllowAnonymous = allowAnonymous; AllowAnonymous = allowAnonymous;
} }
[Pure] [Pure]
public AppClient Update(string? name, string? role, bool? allowAnonymous) public AppClient Update(string? name, string? role, long? apiCallsLimit, long? apiTrafficLimit, bool? allowAnonymous)
{ {
return new AppClient(name.Or(Name), Secret, role.Or(Role), allowAnonymous ?? AllowAnonymous); return new AppClient(name.Or(Name), Secret, role.Or(Role), apiCallsLimit ?? ApiCallsLimit, apiTrafficLimit ?? ApiTrafficLimit, allowAnonymous ?? AllowAnonymous);
} }
} }
} }

6
backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClients.cs

@ -53,11 +53,11 @@ namespace Squidex.Domain.Apps.Core.Apps
throw new ArgumentException("Id already exists.", nameof(id)); throw new ArgumentException("Id already exists.", nameof(id));
} }
return With<AppClients>(id, new AppClient(id, secret, Role.Editor, false)); return Add(id, new AppClient(id, secret, Role.Editor));
} }
[Pure] [Pure]
public AppClients Update(string id, string? name = null, string? role = null, bool? allowAnonymous = false) public AppClients Update(string id, string? name = null, string? role = null, long? apiCallsLimit = null, long? apiTrafficLimit = null, bool? allowAnonymous = false)
{ {
Guard.NotNullOrEmpty(id, nameof(id)); Guard.NotNullOrEmpty(id, nameof(id));
@ -66,7 +66,7 @@ namespace Squidex.Domain.Apps.Core.Apps
return this; return this;
} }
return With<AppClients>(id, client.Update(name, role, allowAnonymous)); return With<AppClients>(id, client.Update(name, role, apiCallsLimit, apiTrafficLimit, allowAnonymous));
} }
} }
} }

2
backend/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs

@ -36,7 +36,7 @@ namespace Squidex.Domain.Apps.Core.Contents
IReadOnlyDictionary<Status, WorkflowStep>? steps, IReadOnlyDictionary<Status, WorkflowStep>? steps,
IReadOnlyList<Guid>? schemaIds = null, IReadOnlyList<Guid>? schemaIds = null,
string? name = null) string? name = null)
: base(name ?? DefaultName) : base(name.Or(DefaultName))
{ {
Initial = initial; Initial = initial;

6
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs

@ -41,7 +41,7 @@ namespace Squidex.Domain.Apps.Core.Schemas
public static string DisplayName(this IField field) public static string DisplayName(this IField field)
{ {
return field.RawProperties.Label.WithFallback(field.TypeName()); return field.RawProperties.Label.Or(field.TypeName());
} }
public static string TypeName(this Schema schema) public static string TypeName(this Schema schema)
@ -51,12 +51,12 @@ namespace Squidex.Domain.Apps.Core.Schemas
public static string DisplayName(this Schema schema) public static string DisplayName(this Schema schema)
{ {
return schema.Properties.Label.WithFallback(schema.TypeName()); return schema.Properties.Label.Or(schema.TypeName());
} }
public static string DisplayNameUnchanged(this Schema schema) public static string DisplayNameUnchanged(this Schema schema)
{ {
return schema.Properties.Label.WithFallback(schema.Name); return schema.Properties.Label.Or(schema.Name);
} }
public static Guid SingleId(this ReferencesFieldProperties properties) public static Guid SingleId(this ReferencesFieldProperties properties)

2
backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/ContentSchemaBuilder.cs

@ -18,7 +18,7 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema
Guard.NotNull(schema, nameof(schema)); Guard.NotNull(schema, nameof(schema));
Guard.NotNull(dataSchema, nameof(dataSchema)); Guard.NotNull(dataSchema, nameof(dataSchema));
var schemaName = schema.Properties.Label.WithFallback(schema.Name); var schemaName = schema.Properties.Label.Or(schema.Name);
var contentSchema = new JsonSchema var contentSchema = new JsonSchema
{ {

4
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateClient.cs

@ -15,6 +15,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Commands
public string? Role { get; set; } public string? Role { get; set; }
public long? ApiCallsLimit { get; set; }
public long? ApiTrafficLimit { get; set; }
public bool? AllowAnonymous { get; set; } public bool? AllowAnonymous { get; set; }
} }
} }

12
backend/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppClients.cs

@ -62,7 +62,17 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
if (command.Role != null && !roles.Contains(command.Role)) if (command.Role != null && !roles.Contains(command.Role))
{ {
e(Not.Valid("Role"), nameof(command.Role)); e(Not.Valid(nameof(command.Role)), nameof(command.Role));
}
if (command.ApiCallsLimit != null && command.ApiCallsLimit < 0)
{
e(Not.GreaterEqualsThan(nameof(command.ApiCallsLimit), "0"), nameof(command.ApiCallsLimit));
}
if (command.ApiTrafficLimit != null && command.ApiTrafficLimit < 0)
{
e(Not.GreaterEqualsThan(nameof(command.ApiTrafficLimit), "0"), nameof(command.ApiTrafficLimit));
} }
}); });
} }

21
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs

@ -38,19 +38,26 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
usageLimitNotifier = grainFactory.GetGrain<IUsageNotifierGrain>(SingleGrain.Id); usageLimitNotifier = grainFactory.GetGrain<IUsageNotifierGrain>(SingleGrain.Id);
} }
public virtual async Task<bool> IsBlockedAsync(IAppEntity app, DateTime today) public virtual async Task<bool> IsBlockedAsync(IAppEntity app, string? clientId, DateTime today)
{ {
Guard.NotNull(app, nameof(app)); Guard.NotNull(app, nameof(app));
var isLocked = false; var appId = app.Id;
var isBlocked = false;
if (clientId != null && app.Clients.TryGetValue(clientId, out var client) && client.ApiCallsLimit > 0)
{
var usage = await apiUsageTracker.GetMonthCallsAsync(appId.ToString(), today, clientId);
isBlocked = usage >= client.ApiCallsLimit;
}
var (plan, _) = appPlansProvider.GetPlanForApp(app); var (plan, _) = appPlansProvider.GetPlanForApp(app);
if (plan.MaxApiCalls > 0 || plan.BlockingApiCalls > 0) if (plan.MaxApiCalls > 0 || plan.BlockingApiCalls > 0)
{ {
var appId = app.Id; var usage = await apiUsageTracker.GetMonthCallsAsync(appId.ToString(), today, null);
var usage = await apiUsageTracker.GetMonthCallsAsync(appId.ToString(), today);
if (IsAboutToBeLocked(today, plan.MaxApiCalls, usage) && !HasNotifiedBefore(app.Id)) if (IsAboutToBeLocked(today, plan.MaxApiCalls, usage) && !HasNotifiedBefore(app.Id))
{ {
@ -70,10 +77,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
TrackNotified(appId); TrackNotified(appId);
} }
isLocked = plan.BlockingApiCalls > 0 && usage > plan.BlockingApiCalls; isBlocked = isBlocked || plan.BlockingApiCalls > 0 && usage > plan.BlockingApiCalls;
} }
return isLocked; return isBlocked;
} }
private bool HasNotifiedBefore(Guid appId) private bool HasNotifiedBefore(Guid appId)

2
backend/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs

@ -85,7 +85,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.State
return UpdateClients(e, (ev, c) => c.Add(ev.Id, ev.Secret)); return UpdateClients(e, (ev, c) => c.Add(ev.Id, ev.Secret));
case AppClientUpdated e: case AppClientUpdated e:
return UpdateClients(e, (ev, c) => c.Update(ev.Id, ev.Name, ev.Role, ev.AllowAnonymous)); return UpdateClients(e, (ev, c) => c.Update(ev.Id, ev.Name, ev.Role, ev.ApiCallsLimit, ev.ApiTrafficLimit, ev.AllowAnonymous));
case AppClientRevoked e: case AppClientRevoked e:
return UpdateClients(e, (ev, c) => c.Revoke(ev.Id)); return UpdateClients(e, (ev, c) => c.Revoke(ev.Id));

2
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs

@ -34,7 +34,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
var key = GetKey(appId); var key = GetKey(appId);
var counters = await usageTracker.GetAsync(key, SummaryDate, SummaryDate); var counters = await usageTracker.GetAsync(key, SummaryDate, SummaryDate, null);
return counters.GetInt64(CounterTotalSize); return counters.GetInt64(CounterTotalSize);
} }

15
backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateCommandMiddleware.cs

@ -74,11 +74,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var command = SimpleMapper.Map(bulkUpdates, new CreateContent { Data = job.Data }); var command = SimpleMapper.Map(bulkUpdates, new CreateContent { Data = job.Data });
var content = serviceProvider.GetRequiredService<ContentDomainObject>(); await InsertAsync(command);
content.Setup(command.ContentId);
await content.ExecuteAsync(command);
result.ContentId = command.ContentId; result.ContentId = command.ContentId;
} }
@ -151,6 +147,15 @@ namespace Squidex.Domain.Apps.Entities.Contents
} }
} }
private async Task InsertAsync(CreateContent command)
{
var content = serviceProvider.GetRequiredService<ContentDomainObject>();
content.Setup(command.ContentId);
await content.ExecuteAsync(command);
}
private async Task<Guid?> FindIdAsync(Context context, string schema, BulkUpdateJob job) private async Task<Guid?> FindIdAsync(Context context, string schema, BulkUpdateJob job)
{ {
var id = job.Id; var id = job.Id;

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

@ -83,7 +83,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking
if (!target.Triggered.HasValue || target.Triggered < from) if (!target.Triggered.HasValue || target.Triggered < from)
{ {
var costs = await usageTracker.GetMonthCallsAsync(target.AppId.Id.ToString(), today); var costs = await usageTracker.GetMonthCallsAsync(target.AppId.Id.ToString(), today, null);
var limit = target.Limits; var limit = target.Limits;

4
backend/src/Squidex.Domain.Apps.Events/Apps/AppClientUpdated.cs

@ -18,6 +18,10 @@ namespace Squidex.Domain.Apps.Events.Apps
public string? Role { get; set; } public string? Role { get; set; }
public long? ApiCallsLimit { get; set; }
public long? ApiTrafficLimit { get; set; }
public bool? AllowAnonymous { get; set; } public bool? AllowAnonymous { get; set; }
} }
} }

2
backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs

@ -35,7 +35,7 @@ namespace Squidex.Infrastructure.EventSourcing
this.connection = connection; this.connection = connection;
this.serializer = serializer; this.serializer = serializer;
this.prefix = prefix.Trim(' ', '-').WithFallback("squidex"); this.prefix = prefix.Trim(' ', '-').Or("squidex");
projectionClient = new ProjectionClient(connection, prefix, projectionHost); projectionClient = new ProjectionClient(connection, prefix, projectionHost);
} }

5
backend/src/Squidex.Infrastructure/StringExtensions.cs

@ -547,11 +547,6 @@ namespace Squidex.Infrastructure
return !string.IsNullOrWhiteSpace(value) ? value.Trim() : fallback; return !string.IsNullOrWhiteSpace(value) ? value.Trim() : fallback;
} }
public static string WithFallback(this string? value, string fallback)
{
return !string.IsNullOrWhiteSpace(value) ? value.Trim() : fallback;
}
public static string ToPascalCase(this string value) public static string ToPascalCase(this string value)
{ {
if (value.Length == 0) if (value.Length == 0)

8
backend/src/Squidex.Infrastructure/UsageTracking/ApiUsageTracker.cs

@ -23,20 +23,20 @@ namespace Squidex.Infrastructure.UsageTracking
this.usageTracker = usageTracker; this.usageTracker = usageTracker;
} }
public async Task<long> GetMonthCallsAsync(string key, DateTime date) public async Task<long> GetMonthCallsAsync(string key, DateTime date, string? category)
{ {
var apiKey = GetKey(key); var apiKey = GetKey(key);
var counters = await usageTracker.GetForMonthAsync(apiKey, date); var counters = await usageTracker.GetForMonthAsync(apiKey, date, category);
return counters.GetInt64(CounterTotalCalls); return counters.GetInt64(CounterTotalCalls);
} }
public async Task<long> GetMonthBytesAsync(string key, DateTime date) public async Task<long> GetMonthBytesAsync(string key, DateTime date, string? category)
{ {
var apiKey = GetKey(key); var apiKey = GetKey(key);
var counters = await usageTracker.GetForMonthAsync(apiKey, date); var counters = await usageTracker.GetForMonthAsync(apiKey, date, category);
return counters.GetInt64(CounterTotalBytes); return counters.GetInt64(CounterTotalBytes);
} }

11
backend/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs

@ -141,15 +141,15 @@ namespace Squidex.Infrastructure.UsageTracking
return result; return result;
} }
public Task<Counters> GetForMonthAsync(string key, DateTime date) public Task<Counters> GetForMonthAsync(string key, DateTime date, string? category)
{ {
var dateFrom = new DateTime(date.Year, date.Month, 1); var dateFrom = new DateTime(date.Year, date.Month, 1);
var dateTo = dateFrom.AddMonths(1).AddDays(-1); var dateTo = dateFrom.AddMonths(1).AddDays(-1);
return GetAsync(key, dateFrom, dateTo); return GetAsync(key, dateFrom, dateTo, category);
} }
public async Task<Counters> GetAsync(string key, DateTime fromDate, DateTime toDate) public async Task<Counters> GetAsync(string key, DateTime fromDate, DateTime toDate, string? category)
{ {
Guard.NotNullOrEmpty(key, nameof(key)); Guard.NotNullOrEmpty(key, nameof(key));
@ -157,6 +157,11 @@ namespace Squidex.Infrastructure.UsageTracking
var queried = await usageRepository.QueryAsync(key, fromDate, toDate); var queried = await usageRepository.QueryAsync(key, fromDate, toDate);
if (category != null)
{
queried = queried.Where(x => string.Equals(x.Category, category, StringComparison.OrdinalIgnoreCase)).ToList();
}
var result = new Counters(); var result = new Counters();
foreach (var usage in queried) foreach (var usage in queried)

12
backend/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs

@ -40,31 +40,31 @@ namespace Squidex.Infrastructure.UsageTracking
return inner.TrackAsync(date, key, category, counters); return inner.TrackAsync(date, key, category, counters);
} }
public Task<Counters> GetForMonthAsync(string key, DateTime date) public Task<Counters> GetForMonthAsync(string key, DateTime date, string? category)
{ {
Guard.NotNull(key, nameof(key)); Guard.NotNull(key, nameof(key));
var cacheKey = string.Join("$", "Usage", nameof(GetForMonthAsync), key, date); var cacheKey = string.Join("$", "Usage", nameof(GetForMonthAsync), key, date, category);
return Cache.GetOrCreateAsync(cacheKey, entry => return Cache.GetOrCreateAsync(cacheKey, entry =>
{ {
entry.AbsoluteExpirationRelativeToNow = CacheDuration; entry.AbsoluteExpirationRelativeToNow = CacheDuration;
return inner.GetForMonthAsync(key, date); return inner.GetForMonthAsync(key, date, category);
}); });
} }
public Task<Counters> GetAsync(string key, DateTime fromDate, DateTime toDate) public Task<Counters> GetAsync(string key, DateTime fromDate, DateTime toDate, string? category)
{ {
Guard.NotNull(key, nameof(key)); Guard.NotNull(key, nameof(key));
var cacheKey = string.Join("$", "Usage", nameof(GetAsync), key, fromDate, toDate); var cacheKey = string.Join("$", "Usage", nameof(GetAsync), key, fromDate, toDate, category);
return Cache.GetOrCreateAsync(cacheKey, entry => return Cache.GetOrCreateAsync(cacheKey, entry =>
{ {
entry.AbsoluteExpirationRelativeToNow = CacheDuration; entry.AbsoluteExpirationRelativeToNow = CacheDuration;
return inner.GetAsync(key, fromDate, toDate); return inner.GetAsync(key, fromDate, toDate, category);
}); });
} }
} }

4
backend/src/Squidex.Infrastructure/UsageTracking/IApiUsageTracker.cs

@ -15,9 +15,9 @@ namespace Squidex.Infrastructure.UsageTracking
{ {
Task TrackAsync(DateTime date, string key, string? category, double weight, long elapsedMs, long bytes); Task TrackAsync(DateTime date, string key, string? category, double weight, long elapsedMs, long bytes);
Task<long> GetMonthCallsAsync(string key, DateTime date); Task<long> GetMonthCallsAsync(string key, DateTime date, string? category);
Task<long> GetMonthBytesAsync(string key, DateTime date); Task<long> GetMonthBytesAsync(string key, DateTime date, string? category);
Task<(ApiStatsSummary, Dictionary<string, List<ApiStats>> Details)> QueryAsync(string key, DateTime fromDate, DateTime toDate); Task<(ApiStatsSummary, Dictionary<string, List<ApiStats>> Details)> QueryAsync(string key, DateTime fromDate, DateTime toDate);
} }

4
backend/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs

@ -15,9 +15,9 @@ namespace Squidex.Infrastructure.UsageTracking
{ {
Task TrackAsync(DateTime date, string key, string? category, Counters counters); Task TrackAsync(DateTime date, string key, string? category, Counters counters);
Task<Counters> GetForMonthAsync(string key, DateTime date); Task<Counters> GetForMonthAsync(string key, DateTime date, string? category);
Task<Counters> GetAsync(string key, DateTime fromDate, DateTime toDate); Task<Counters> GetAsync(string key, DateTime fromDate, DateTime toDate, string? category);
Task<Dictionary<string, List<(DateTime, Counters)>>> QueryAsync(string key, DateTime fromDate, DateTime toDate); Task<Dictionary<string, List<(DateTime, Counters)>>> QueryAsync(string key, DateTime fromDate, DateTime toDate);
} }

5
backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs

@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Mvc.Filters;
using Squidex.Domain.Apps.Entities.Apps.Plans; using Squidex.Domain.Apps.Entities.Apps.Plans;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Security;
namespace Squidex.Web.Pipeline namespace Squidex.Web.Pipeline
{ {
@ -52,7 +53,9 @@ namespace Squidex.Web.Pipeline
{ {
using (Profiler.Trace("CheckUsage")) using (Profiler.Trace("CheckUsage"))
{ {
var isBlocked = await usageGate.IsBlockedAsync(app, DateTime.Today); var (_, clientId) = context.HttpContext.User.GetClient();
var isBlocked = await usageGate.IsBlockedAsync(app, clientId, DateTime.Today);
if (isBlocked) if (isBlocked)
{ {

24
backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs

@ -5,40 +5,30 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace Squidex.Web.Pipeline namespace Squidex.Web.Pipeline
{ {
public class CleanupHostMiddleware public class CleanupHostMiddleware
{ {
private readonly RequestDelegate next; private readonly RequestDelegate next;
private readonly HostString host;
public CleanupHostMiddleware(RequestDelegate next) public CleanupHostMiddleware(RequestDelegate next, IOptions<UrlsOptions> options)
{ {
this.next = next; this.next = next;
host = new HostString(new Uri(options.Value.BaseUrl).Host);
} }
public Task InvokeAsync(HttpContext context) public Task InvokeAsync(HttpContext context)
{ {
var request = context.Request; context.Request.Host = host;
if (request.Host.HasValue && (HasHttpsPort(request) || HasHttpPort(request)))
{
request.Host = new HostString(request.Host.Host);
}
return next(context); return next(context);
} }
private static bool HasHttpPort(HttpRequest request)
{
return request.Scheme == "http" && request.Host.Port == 80;
}
private static bool HasHttpsPort(HttpRequest request)
{
return request.Scheme == "https" && request.Host.Port == 443;
}
} }
} }

4
backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs

@ -60,6 +60,8 @@ namespace Squidex.Web.Pipeline
bytes += context.Request.ContentLength.Value; bytes += context.Request.ContentLength.Value;
} }
var (_, clientId) = context.User.GetClient();
var request = default(RequestLog); var request = default(RequestLog);
request.Bytes = bytes; request.Bytes = bytes;
@ -68,7 +70,7 @@ namespace Squidex.Web.Pipeline
request.RequestMethod = context.Request.Method; request.RequestMethod = context.Request.Method;
request.RequestPath = context.Request.Path; request.RequestPath = context.Request.Path;
request.Timestamp = clock.GetCurrentInstant(); request.Timestamp = clock.GetCurrentInstant();
request.UserClientId = context.User.OpenIdClientId(); request.UserClientId = clientId;
request.UserId = context.User.OpenIdSubject(); request.UserId = context.User.OpenIdSubject();
await logStore.LogAsync(appId.Id, request); await logStore.LogAsync(appId.Id, request);

15
backend/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs

@ -37,6 +37,21 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// </summary> /// </summary>
public string? Role { get; set; } public string? Role { get; set; }
/// <summary>
/// The number of allowed api calls per month for this client.
/// </summary>
public long ApiCallsLimit { get; set; }
/// <summary>
/// The number of allowed api traffic bytes per month for this client.
/// </summary>
public long ApiTrafficLimit { get; set; }
/// <summary>
/// True to allow anonymous access without an access token for this client.
/// </summary>
public bool AllowAnonymous { get; set; }
public static ClientDto FromClient(string id, AppClient client) public static ClientDto FromClient(string id, AppClient client)
{ {
var result = SimpleMapper.Map(client, new ClientDto { Id = id }); var result = SimpleMapper.Map(client, new ClientDto { Id = id });

10
backend/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateClientDto.cs

@ -29,6 +29,16 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// </summary> /// </summary>
public bool? AllowAnonymous { get; set; } public bool? AllowAnonymous { get; set; }
/// <summary>
/// The number of allowed api calls per month for this client.
/// </summary>
public long? ApiCallsLimit { get; set; }
/// <summary>
/// The number of allowed api traffic bytes per month for this client.
/// </summary>
public long? ApiTrafficLimit { get; set; }
public UpdateClient ToCommand(string clientId) public UpdateClient ToCommand(string clientId)
{ {
return SimpleMapper.Map(this, new UpdateClient { Id = clientId }); return SimpleMapper.Map(this, new UpdateClient { Id = clientId });

4
backend/src/Squidex/Config/Authentication/IdentityServerServices.cs

@ -46,8 +46,12 @@ namespace Squidex.Config.Authentication
} }
else else
{ {
var urlsOptions = config.GetSection("urls").Get<UrlsOptions>();
authBuilder.AddLocalApi(options => authBuilder.AddLocalApi(options =>
{ {
options.ClaimsIssuer = urlsOptions.BuildUrl("/identity-server", false);
options.ExpectedScope = apiScope; options.ExpectedScope = apiScope;
}); });
} }

2
backend/src/Squidex/Program.cs

@ -69,6 +69,8 @@ namespace Squidex
serverOptions.ListenAnyIP( serverOptions.ListenAnyIP(
5001, 5001,
listenOptions => listenOptions.UseHttps("../../../dev/squidex-dev.pfx", "password")); listenOptions => listenOptions.UseHttps("../../../dev/squidex-dev.pfx", "password"));
serverOptions.ListenAnyIP(5000);
} }
}); });

2
backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientJsonTests.cs

@ -29,7 +29,7 @@ namespace Squidex.Domain.Apps.Core.Model.Apps
clients = clients.Update("3", name: "My Client 3"); clients = clients.Update("3", name: "My Client 3");
clients = clients.Update("2", name: "My Client 2"); clients = clients.Update("2", name: "My Client 2");
clients = clients.Update("1", allowAnonymous: true); clients = clients.Update("1", allowAnonymous: true, apiCallsLimit: 3);
clients = clients.Revoke("4"); clients = clients.Revoke("4");

30
backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientsTests.cs

@ -23,15 +23,15 @@ namespace Squidex.Domain.Apps.Core.Model.Apps
{ {
var clients_1 = clients_0.Add("2", "my-secret"); var clients_1 = clients_0.Add("2", "my-secret");
clients_1["2"].Should().BeEquivalentTo(new AppClient("2", "my-secret", Role.Editor, false)); clients_1["2"].Should().BeEquivalentTo(new AppClient("2", "my-secret", Role.Editor));
} }
[Fact] [Fact]
public void Should_assign_clients_with_permission() public void Should_assign_clients_with_permission()
{ {
var clients_1 = clients_0.Add("2", new AppClient("my-name", "my-secret", Role.Reader, false)); var clients_1 = clients_0.Add("2", new AppClient("my-name", "my-secret", Role.Reader));
clients_1["2"].Should().BeEquivalentTo(new AppClient("my-name", "my-secret", Role.Reader, false)); clients_1["2"].Should().BeEquivalentTo(new AppClient("my-name", "my-secret", Role.Reader));
} }
[Fact] [Fact]
@ -47,7 +47,7 @@ namespace Squidex.Domain.Apps.Core.Model.Apps
{ {
var clients_1 = clients_0.Add("2", "my-secret"); var clients_1 = clients_0.Add("2", "my-secret");
clients_1.Add("2", new AppClient("my-name", "my-secret", "my-role", false)); clients_1.Add("2", new AppClient("my-name", "my-secret", "my-role"));
} }
[Fact] [Fact]
@ -55,7 +55,7 @@ namespace Squidex.Domain.Apps.Core.Model.Apps
{ {
var client_1 = clients_0.Update("1", role: Role.Reader); var client_1 = clients_0.Update("1", role: Role.Reader);
client_1["1"].Should().BeEquivalentTo(new AppClient("1", "my-secret", Role.Reader, false)); client_1["1"].Should().BeEquivalentTo(new AppClient("1", "my-secret", Role.Reader));
} }
[Fact] [Fact]
@ -63,7 +63,7 @@ namespace Squidex.Domain.Apps.Core.Model.Apps
{ {
var client_1 = clients_0.Update("1", name: "New-Name"); var client_1 = clients_0.Update("1", name: "New-Name");
client_1["1"].Should().BeEquivalentTo(new AppClient("New-Name", "my-secret", Role.Editor, false)); client_1["1"].Should().BeEquivalentTo(new AppClient("New-Name", "my-secret", Role.Editor));
} }
[Fact] [Fact]
@ -71,7 +71,23 @@ namespace Squidex.Domain.Apps.Core.Model.Apps
{ {
var client_1 = clients_0.Update("1", allowAnonymous: true); var client_1 = clients_0.Update("1", allowAnonymous: true);
client_1["1"].Should().BeEquivalentTo(new AppClient("1", "my-secret", Role.Editor, true)); client_1["1"].Should().BeEquivalentTo(new AppClient("1", "my-secret", Role.Editor, 0, 0, true));
}
[Fact]
public void Should_update_client_with_allow_api_calls_limit()
{
var client_1 = clients_0.Update("1", apiCallsLimit: 1000);
client_1["1"].Should().BeEquivalentTo(new AppClient("1", "my-secret", Role.Editor, 1000, 0, false));
}
[Fact]
public void Should_update_client_with_allow_api_traffic_limit()
{
var client_1 = clients_0.Update("1", apiTrafficLimit: 1000);
client_1["1"].Should().BeEquivalentTo(new AppClient("1", "my-secret", Role.Editor, 0, 1000, false));
} }
[Fact] [Fact]

22
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppClientsTests.cs

@ -107,6 +107,28 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
new ValidationError("Role is not a valid value.", "Role")); new ValidationError("Role is not a valid value.", "Role"));
} }
[Fact]
public void UpdateClient_should_throw_exception_if_api_calls_limit_is_less_than_zero()
{
var command = new UpdateClient { Id = "ios", ApiCallsLimit = -10 };
var clients_1 = clients_0.Add("ios", "secret");
ValidationAssert.Throws(() => GuardAppClients.CanUpdate(clients_1, command, roles),
new ValidationError("ApiCallsLimit must be greater or equal to 0.", "ApiCallsLimit"));
}
[Fact]
public void UpdateClient_should_throw_exception_if_api_traffic_limit_is_less_than_zero()
{
var command = new UpdateClient { Id = "ios", ApiTrafficLimit = -10 };
var clients_1 = clients_0.Add("ios", "secret");
ValidationAssert.Throws(() => GuardAppClients.CanUpdate(clients_1, command, roles),
new ValidationError("ApiTrafficLimit must be greater or equal to 0.", "ApiTrafficLimit"));
}
[Fact] [Fact]
public void UpdateClient_should_not_throw_exception_if_client_has_same_name() public void UpdateClient_should_not_throw_exception_if_client_has_same_name()
{ {

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppRolesTests.cs

@ -87,7 +87,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
var command = new DeleteRole { Name = roleName }; var command = new DeleteRole { Name = roleName };
ValidationAssert.Throws(() => GuardAppRoles.CanDelete(roles_1, command, contributors, clients.Add("1", new AppClient("client", "1", roleName, false))), ValidationAssert.Throws(() => GuardAppRoles.CanDelete(roles_1, command, contributors, clients.Add("1", new AppClient("client", "1", roleName))),
new ValidationError("Cannot remove a role when a client is assigned.")); new ValidationError("Cannot remove a role when a client is assigned."));
} }

36
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/UsageGateTests.cs

@ -9,6 +9,7 @@ using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Orleans; using Orleans;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Orleans;
@ -27,6 +28,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
private readonly IUsageNotifierGrain usageNotifierGrain = A.Fake<IUsageNotifierGrain>(); private readonly IUsageNotifierGrain usageNotifierGrain = A.Fake<IUsageNotifierGrain>();
private readonly DateTime today = new DateTime(2020, 04, 10); private readonly DateTime today = new DateTime(2020, 04, 10);
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app"); private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
private readonly string clientId = Guid.NewGuid().ToString();
private readonly UsageGate sut; private readonly UsageGate sut;
private long apiCallsBlocking; private long apiCallsBlocking;
private long apiCallsMax; private long apiCallsMax;
@ -51,20 +53,38 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
A.CallTo(() => appPlan.BlockingApiCalls) A.CallTo(() => appPlan.BlockingApiCalls)
.ReturnsLazily(x => apiCallsBlocking); .ReturnsLazily(x => apiCallsBlocking);
A.CallTo(() => usageTracker.GetMonthCallsAsync(appId.Id.ToString(), today)) A.CallTo(() => usageTracker.GetMonthCallsAsync(appId.Id.ToString(), today, A<string>._))
.ReturnsLazily(x => Task.FromResult(apiCallsCurrent)); .ReturnsLazily(x => Task.FromResult(apiCallsCurrent));
sut = new UsageGate(appPlansProvider, usageTracker, grainFactory); sut = new UsageGate(appPlansProvider, usageTracker, grainFactory);
} }
[Fact] [Fact]
public async Task Should_return_true_if_over_blocking_limt() public async Task Should_return_true_if_over_client_limit()
{
A.CallTo(() => appEntity.Clients)
.Returns(AppClients.Empty.Add(clientId, new AppClient(clientId, clientId, Role.Developer, 100)));
apiCallsCurrent = 1000;
apiCallsBlocking = 1600;
apiCallsMax = 1600;
var isBlocked = await sut.IsBlockedAsync(appEntity, clientId, today);
Assert.True(isBlocked);
A.CallTo(() => usageNotifierGrain.NotifyAsync(A<UsageNotification>.Ignored))
.MustHaveHappened();
}
[Fact]
public async Task Should_return_true_if_over_blocking_limit()
{ {
apiCallsCurrent = 1000; apiCallsCurrent = 1000;
apiCallsBlocking = 600; apiCallsBlocking = 600;
apiCallsMax = 600; apiCallsMax = 600;
var isBlocked = await sut.IsBlockedAsync(appEntity, today); var isBlocked = await sut.IsBlockedAsync(appEntity, clientId, today);
Assert.True(isBlocked); Assert.True(isBlocked);
@ -79,7 +99,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
apiCallsBlocking = 1600; apiCallsBlocking = 1600;
apiCallsMax = 1600; apiCallsMax = 1600;
var isBlocked = await sut.IsBlockedAsync(appEntity, today); var isBlocked = await sut.IsBlockedAsync(appEntity, clientId, today);
Assert.False(isBlocked); Assert.False(isBlocked);
@ -94,7 +114,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
apiCallsBlocking = 5000; apiCallsBlocking = 5000;
apiCallsMax = 3000; apiCallsMax = 3000;
var isBlocked = await sut.IsBlockedAsync(appEntity, today); var isBlocked = await sut.IsBlockedAsync(appEntity, clientId, today);
Assert.False(isBlocked); Assert.False(isBlocked);
@ -109,7 +129,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
apiCallsBlocking = 5000; apiCallsBlocking = 5000;
apiCallsMax = 0; apiCallsMax = 0;
var isBlocked = await sut.IsBlockedAsync(appEntity, today); var isBlocked = await sut.IsBlockedAsync(appEntity, clientId, today);
Assert.False(isBlocked); Assert.False(isBlocked);
@ -124,8 +144,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
apiCallsBlocking = 5000; apiCallsBlocking = 5000;
apiCallsMax = 3000; apiCallsMax = 3000;
await sut.IsBlockedAsync(appEntity, today); await sut.IsBlockedAsync(appEntity, clientId, today);
await sut.IsBlockedAsync(appEntity, today); await sut.IsBlockedAsync(appEntity, clientId, today);
A.CallTo(() => usageNotifierGrain.NotifyAsync(A<UsageNotification>.Ignored)) A.CallTo(() => usageNotifierGrain.NotifyAsync(A<UsageNotification>.Ignored))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();

3
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs

@ -13,6 +13,7 @@ using FakeItEasy;
using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.Contents.Queries.Steps; using Squidex.Domain.Apps.Entities.Contents.Queries.Steps;
using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -22,7 +23,7 @@ using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents.Queries namespace Squidex.Domain.Apps.Entities.Contents.Queries
{ {
public class ResolveReferencesTests public class ResolveReferencesTests : IClassFixture<TranslationsFixture>
{ {
private readonly IContentQueryService contentQuery = A.Fake<IContentQueryService>(); private readonly IContentQueryService contentQuery = A.Fake<IContentQueryService>();
private readonly IRequestCache requestCache = A.Fake<IRequestCache>(); private readonly IRequestCache requestCache = A.Fake<IRequestCache>();

4
backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs

@ -30,7 +30,7 @@ namespace Squidex.Infrastructure
[InlineData(" ")] [InlineData(" ")]
public void Should_provide_fallback_if_invalid(string value) public void Should_provide_fallback_if_invalid(string value)
{ {
Assert.Equal("fallback", value.WithFallback("fallback")); Assert.Equal("fallback", value.Or("fallback"));
} }
[Theory] [Theory]
@ -130,7 +130,7 @@ namespace Squidex.Infrastructure
{ {
const string value = "value"; const string value = "value";
Assert.Equal(value, value.WithFallback("fallback")); Assert.Equal(value, value.Or("fallback"));
} }
[Theory] [Theory]

9
backend/tests/Squidex.Infrastructure.Tests/UsageTracking/ApiUsageTrackerTests.cs

@ -18,6 +18,7 @@ namespace Squidex.Infrastructure.UsageTracking
{ {
private readonly IUsageTracker usageTracker = A.Fake<IUsageTracker>(); private readonly IUsageTracker usageTracker = A.Fake<IUsageTracker>();
private readonly string key = Guid.NewGuid().ToString(); private readonly string key = Guid.NewGuid().ToString();
private readonly string category = Guid.NewGuid().ToString();
private readonly DateTime date = DateTime.Today; private readonly DateTime date = DateTime.Today;
private readonly ApiUsageTracker sut; private readonly ApiUsageTracker sut;
@ -55,10 +56,10 @@ namespace Squidex.Infrastructure.UsageTracking
[ApiUsageTracker.CounterTotalCalls] = 4 [ApiUsageTracker.CounterTotalCalls] = 4
}; };
A.CallTo(() => usageTracker.GetForMonthAsync($"{key}_API", date)) A.CallTo(() => usageTracker.GetForMonthAsync($"{key}_API", date, category))
.Returns(counters); .Returns(counters);
var result = await sut.GetMonthCallsAsync(key, date); var result = await sut.GetMonthCallsAsync(key, date, category);
Assert.Equal(4, result); Assert.Equal(4, result);
} }
@ -71,10 +72,10 @@ namespace Squidex.Infrastructure.UsageTracking
[ApiUsageTracker.CounterTotalBytes] = 14 [ApiUsageTracker.CounterTotalBytes] = 14
}; };
A.CallTo(() => usageTracker.GetForMonthAsync($"{key}_API", date)) A.CallTo(() => usageTracker.GetForMonthAsync($"{key}_API", date, category))
.Returns(counters); .Returns(counters);
var result = await sut.GetMonthBytesAsync(key, date); var result = await sut.GetMonthBytesAsync(key, date, category);
Assert.Equal(14, result); Assert.Equal(14, result);
} }

30
backend/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs

@ -49,7 +49,7 @@ namespace Squidex.Infrastructure.UsageTracking
{ {
sut.Dispose(); sut.Dispose();
await Assert.ThrowsAsync<ObjectDisposedException>(() => sut.GetForMonthAsync(key, date)); await Assert.ThrowsAsync<ObjectDisposedException>(() => sut.GetForMonthAsync(key, date, null));
} }
[Fact] [Fact]
@ -57,7 +57,7 @@ namespace Squidex.Infrastructure.UsageTracking
{ {
sut.Dispose(); sut.Dispose();
await Assert.ThrowsAsync<ObjectDisposedException>(() => sut.GetAsync(key, date, date)); await Assert.ThrowsAsync<ObjectDisposedException>(() => sut.GetAsync(key, date, date, null));
} }
[Fact] [Fact]
@ -70,17 +70,20 @@ namespace Squidex.Infrastructure.UsageTracking
{ {
new StoredUsage("category1", date.AddDays(1), Counters(a: 10, b: 15)), new StoredUsage("category1", date.AddDays(1), Counters(a: 10, b: 15)),
new StoredUsage("category1", date.AddDays(3), Counters(a: 13, b: 18)), new StoredUsage("category1", date.AddDays(3), Counters(a: 13, b: 18)),
new StoredUsage("category1", date.AddDays(5), Counters(a: 15)), new StoredUsage("category2", date.AddDays(5), Counters(a: 15)),
new StoredUsage("category1", date.AddDays(7), Counters(b: 22)) new StoredUsage("category2", date.AddDays(7), Counters(b: 22))
}; };
A.CallTo(() => usageStore.QueryAsync(key, dateFrom, dateTo)) A.CallTo(() => usageStore.QueryAsync(key, dateFrom, dateTo))
.Returns(originalData); .Returns(originalData);
var result = await sut.GetForMonthAsync(key, date); var result1 = await sut.GetForMonthAsync(key, date, null);
var result2 = await sut.GetForMonthAsync(key, date, "category2");
Assert.Equal(38, result["A"]); Assert.Equal(38, result1["A"]);
Assert.Equal(55, result["B"]); Assert.Equal(55, result1["B"]);
Assert.Equal(22, result2["B"]);
} }
[Fact] [Fact]
@ -93,17 +96,20 @@ namespace Squidex.Infrastructure.UsageTracking
{ {
new StoredUsage("category1", date.AddDays(1), Counters(a: 10, b: 15)), new StoredUsage("category1", date.AddDays(1), Counters(a: 10, b: 15)),
new StoredUsage("category1", date.AddDays(3), Counters(a: 13, b: 18)), new StoredUsage("category1", date.AddDays(3), Counters(a: 13, b: 18)),
new StoredUsage("category1", date.AddDays(5), Counters(a: 15)), new StoredUsage("category2", date.AddDays(5), Counters(a: 15)),
new StoredUsage("category1", date.AddDays(7), Counters(b: 22)) new StoredUsage("category2", date.AddDays(7), Counters(b: 22))
}; };
A.CallTo(() => usageStore.QueryAsync(key, dateFrom, dateTo)) A.CallTo(() => usageStore.QueryAsync(key, dateFrom, dateTo))
.Returns(originalData); .Returns(originalData);
var result = await sut.GetAsync(key, dateFrom, dateTo); var result1 = await sut.GetAsync(key, dateFrom, dateTo, null);
var result2 = await sut.GetAsync(key, dateFrom, dateTo, "category2");
Assert.Equal(38, result1["A"]);
Assert.Equal(55, result1["B"]);
Assert.Equal(38, result["A"]); Assert.Equal(22, result2["B"]);
Assert.Equal(55, result["B"]);
} }
[Fact] [Fact]

17
backend/tests/Squidex.Infrastructure.Tests/UsageTracking/CachingUsageTrackerTests.cs

@ -18,6 +18,7 @@ namespace Squidex.Infrastructure.UsageTracking
{ {
private readonly MemoryCache cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); private readonly MemoryCache cache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private readonly string key = Guid.NewGuid().ToString(); private readonly string key = Guid.NewGuid().ToString();
private readonly string category = Guid.NewGuid().ToString();
private readonly DateTime date = DateTime.Today; private readonly DateTime date = DateTime.Today;
private readonly IUsageTracker inner = A.Fake<IUsageTracker>(); private readonly IUsageTracker inner = A.Fake<IUsageTracker>();
private readonly IUsageTracker sut; private readonly IUsageTracker sut;
@ -55,16 +56,16 @@ namespace Squidex.Infrastructure.UsageTracking
{ {
var counters = new Counters(); var counters = new Counters();
A.CallTo(() => inner.GetForMonthAsync(key, date)) A.CallTo(() => inner.GetForMonthAsync(key, date, category))
.Returns(counters); .Returns(counters);
var result1 = await sut.GetForMonthAsync(key, date); var result1 = await sut.GetForMonthAsync(key, date, category);
var result2 = await sut.GetForMonthAsync(key, date); var result2 = await sut.GetForMonthAsync(key, date, category);
Assert.Same(counters, result1); Assert.Same(counters, result1);
Assert.Same(counters, result2); Assert.Same(counters, result2);
A.CallTo(() => inner.GetForMonthAsync(key, DateTime.Today)) A.CallTo(() => inner.GetForMonthAsync(key, DateTime.Today, category))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
} }
@ -76,16 +77,16 @@ namespace Squidex.Infrastructure.UsageTracking
var dateFrom = date; var dateFrom = date;
var dateTo = dateFrom.AddDays(10); var dateTo = dateFrom.AddDays(10);
A.CallTo(() => inner.GetAsync(key, dateFrom, dateTo)) A.CallTo(() => inner.GetAsync(key, dateFrom, dateTo, category))
.Returns(counters); .Returns(counters);
var result1 = await sut.GetAsync(key, dateFrom, dateTo); var result1 = await sut.GetAsync(key, dateFrom, dateTo, category);
var result2 = await sut.GetAsync(key, dateFrom, dateTo); var result2 = await sut.GetAsync(key, dateFrom, dateTo, category);
Assert.Same(counters, result1); Assert.Same(counters, result1);
Assert.Same(counters, result2); Assert.Same(counters, result2);
A.CallTo(() => inner.GetAsync(key, dateFrom, dateTo)) A.CallTo(() => inner.GetAsync(key, dateFrom, dateTo, category))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
} }

8
backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs

@ -55,7 +55,7 @@ namespace Squidex.Web.Pipeline
SetupApp(); SetupApp();
A.CallTo(() => usageGate.IsBlockedAsync(appEntity, DateTime.Today)) A.CallTo(() => usageGate.IsBlockedAsync(appEntity, A<string>._, DateTime.Today))
.Returns(true); .Returns(true);
await sut.OnActionExecutionAsync(actionContext, next); await sut.OnActionExecutionAsync(actionContext, next);
@ -71,7 +71,7 @@ namespace Squidex.Web.Pipeline
SetupApp(); SetupApp();
A.CallTo(() => usageGate.IsBlockedAsync(appEntity, DateTime.Today)) A.CallTo(() => usageGate.IsBlockedAsync(appEntity, A<string>._, DateTime.Today))
.Returns(false); .Returns(false);
await sut.OnActionExecutionAsync(actionContext, next); await sut.OnActionExecutionAsync(actionContext, next);
@ -90,7 +90,7 @@ namespace Squidex.Web.Pipeline
Assert.True(isNextCalled); Assert.True(isNextCalled);
A.CallTo(() => usageGate.IsBlockedAsync(appEntity, DateTime.Today)) A.CallTo(() => usageGate.IsBlockedAsync(appEntity, A<string>._, DateTime.Today))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -103,7 +103,7 @@ namespace Squidex.Web.Pipeline
Assert.True(isNextCalled); Assert.True(isNextCalled);
A.CallTo(() => usageGate.IsBlockedAsync(appEntity, DateTime.Today)) A.CallTo(() => usageGate.IsBlockedAsync(appEntity, A<string>._, DateTime.Today))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }

4
backend/tests/Squidex.Web.Tests/Pipeline/AppResolverTests.cs

@ -227,7 +227,7 @@ namespace Squidex.Web.Pipeline
return userIdentity; return userIdentity;
} }
private static IAppEntity CreateApp(string name, string? appUser = null, string? appClient = null, bool? allowAnonymous = null) private static IAppEntity CreateApp(string name, string? appUser = null, string? appClient = null, long? apiCallsLimit = null, bool? allowAnonymous = null)
{ {
var appEntity = A.Fake<IAppEntity>(); var appEntity = A.Fake<IAppEntity>();
@ -245,7 +245,7 @@ namespace Squidex.Web.Pipeline
if (appClient != null) if (appClient != null)
{ {
A.CallTo(() => appEntity.Clients) A.CallTo(() => appEntity.Clients)
.Returns(AppClients.Empty.Add(appClient, "secret").Update(appClient, allowAnonymous: allowAnonymous)); .Returns(AppClients.Empty.Add(appClient, "secret").Update(appClient, apiCallsLimit: apiCallsLimit, allowAnonymous: allowAnonymous));
} }
else else
{ {

41
backend/tests/Squidex.Web.Tests/Pipeline/CleanupHostMiddlewareTests.cs

@ -7,6 +7,7 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Xunit; using Xunit;
#pragma warning disable RECS0092 // Convert field to readonly #pragma warning disable RECS0092 // Convert field to readonly
@ -15,7 +16,7 @@ namespace Squidex.Web.Pipeline
{ {
public class CleanupHostMiddlewareTests public class CleanupHostMiddlewareTests
{ {
private readonly CleanupHostMiddleware sut; private readonly RequestDelegate next;
private bool isNextCalled; private bool isNextCalled;
public CleanupHostMiddlewareTests() public CleanupHostMiddlewareTests()
@ -27,48 +28,24 @@ namespace Squidex.Web.Pipeline
return Task.CompletedTask; return Task.CompletedTask;
} }
sut = new CleanupHostMiddleware(Next); next = Next;
} }
[Fact] [Fact]
public async Task Should_cleanup_host_if_https_schema_contains_default_port() public async Task Should_override_host_from_urls_options()
{ {
var httpContext = new DefaultHttpContext(); var options = Options.Create(new UrlsOptions { BaseUrl = "https://cloud.squidex.io" });
httpContext.Request.Scheme = "https";
httpContext.Request.Host = new HostString("host", 443);
await sut.InvokeAsync(httpContext);
Assert.Null(httpContext.Request.Host.Port);
Assert.True(isNextCalled);
}
[Fact]
public async Task Should_cleanup_host_if_http_schema_contains_default_port()
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Scheme = "http";
httpContext.Request.Host = new HostString("host", 80);
await sut.InvokeAsync(httpContext); var sut = new CleanupHostMiddleware(next, options);
Assert.Null(httpContext.Request.Host.Port);
Assert.True(isNextCalled);
}
[Fact]
public async Task Should_not_cleanup_host_if_http_schema_contains_other_port()
{
var httpContext = new DefaultHttpContext(); var httpContext = new DefaultHttpContext();
httpContext.Request.Scheme = "http"; httpContext.Request.Scheme = "https";
httpContext.Request.Host = new HostString("host", 8080); httpContext.Request.Host = new HostString("host", 443);
await sut.InvokeAsync(httpContext); await sut.InvokeAsync(httpContext);
Assert.Equal(8080, httpContext.Request.Host.Port); Assert.Equal("cloud.squidex.io", httpContext.Request.Host.Value);
Assert.True(isNextCalled); Assert.True(isNextCalled);
} }
} }

2
frontend/app/app.module.ts

@ -17,7 +17,7 @@ import { routing } from './app.routes';
import { ApiUrlConfig, CurrencyConfig, DateHelper, DecimalSeparatorConfig, LocalizerService, SqxFrameworkModule, SqxSharedModule, TitlesConfig, UIOptions } from './shared'; import { ApiUrlConfig, CurrencyConfig, DateHelper, DecimalSeparatorConfig, LocalizerService, SqxFrameworkModule, SqxSharedModule, TitlesConfig, UIOptions } from './shared';
import { SqxShellModule } from './shell'; import { SqxShellModule } from './shell';
DateHelper.setlocale(window['options'].more.culture); DateHelper.setlocale(window['options']?.more?.culture);
export function configApiUrl() { export function configApiUrl() {
const baseElements = document.getElementsByTagName('base'); const baseElements = document.getElementsByTagName('base');

23
frontend/app/features/settings/pages/clients/client.component.html

@ -51,7 +51,7 @@
{{ 'common.role' | sqxTranslate }} {{ 'common.role' | sqxTranslate }}
</label> </label>
<div class="col cell-input"> <div class="col cell-input">
<select class="form-control" [disabled]="!client.canUpdate" [ngModel]="client.role" (ngModelChange)="update($event)"> <select class="form-control" [disabled]="!client.canUpdate" [ngModel]="client.role" (ngModelChange)="updateRole($event)">
<option *ngFor="let role of clientRoles; trackBy: trackByRole" [ngValue]="role.name">{{role.name}}</option> <option *ngFor="let role of clientRoles; trackBy: trackByRole" [ngValue]="role.name">{{role.name}}</option>
</select> </select>
</div> </div>
@ -60,7 +60,7 @@
<div class="form-group row"> <div class="form-group row">
<div class="col cell-input offset-3"> <div class="col cell-input offset-3">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="{{client.id}}_allowAnonymous" [disabled]="!client.canUpdate" [ngModel]="client.allowAnonymous" (ngModelChange)="updateAccess($event)"> <input class="form-check-input" type="checkbox" id="{{client.id}}_allowAnonymous" [disabled]="!client.canUpdate" [ngModel]="client.allowAnonymous" (ngModelChange)="updateAllowAnonymous($event)">
<label class="form-check-label" for="{{client.id}}_allowAnonymous"> <label class="form-check-label" for="{{client.id}}_allowAnonymous">
{{ 'clients.allowAnonymous' | sqxTranslate }} {{ 'clients.allowAnonymous' | sqxTranslate }}
@ -73,11 +73,28 @@
</div> </div>
<div class="col-auto cell-actions"></div> <div class="col-auto cell-actions"></div>
</div> </div>
<div class="form-group row">
<label class="col-3 col-form-label">
{{ 'clients.apiCallsLimit' | sqxTranslate }}
</label>
<div class="col cell-input">
<input type="number" class="form-control" min="0" [disabled]="!client.canUpdate" [(ngModel)]="apiCallsLimit" />
<sqx-form-hint>{{ 'clients.apiCallsLimitHint' | sqxTranslate }}</sqx-form-hint>
</div>
<div class="col-auto cell-input" *ngIf="client.canUpdate">
<button class="btn btn-primary" (click)="updateApiCallsLimit()">
{{ 'common.save' | sqxTranslate }}
</button>
</div>
<div class="col-auto cell-actions"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
<ng-container *sqxModal="connectDialog"> <ng-container *sqxModal="connectDialog">
<sqx-client-connect-form [client]="client" (complete)="connectDialog.hide()"> <sqx-client-connect-form [client]="client"
(complete)="connectDialog.hide()">
</sqx-client-connect-form> </sqx-client-connect-form>
</ng-container> </ng-container>

20
frontend/app/features/settings/pages/clients/client.component.ts

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { AppsState, ClientDto, ClientsState, DialogModel, RoleDto } from '@app/shared'; import { AppsState, ClientDto, ClientsState, DialogModel, RoleDto } from '@app/shared';
@Component({ @Component({
@ -14,13 +14,15 @@ import { AppsState, ClientDto, ClientsState, DialogModel, RoleDto } from '@app/s
templateUrl: './client.component.html', templateUrl: './client.component.html',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ClientComponent { export class ClientComponent implements OnChanges {
@Input() @Input()
public client: ClientDto; public client: ClientDto;
@Input() @Input()
public clientRoles: ReadonlyArray<RoleDto>; public clientRoles: ReadonlyArray<RoleDto>;
public apiCallsLimit: number;
public connectDialog = new DialogModel(); public connectDialog = new DialogModel();
constructor( constructor(
@ -29,18 +31,28 @@ export class ClientComponent {
) { ) {
} }
public ngOnChanges(changes: SimpleChanges ) {
if (changes['client']) {
this.apiCallsLimit = this.client.apiCallsLimit;
}
}
public revoke() { public revoke() {
this.clientsState.revoke(this.client); this.clientsState.revoke(this.client);
} }
public update(role: string) { public updateRole(role: string) {
this.clientsState.update(this.client, { role }); this.clientsState.update(this.client, { role });
} }
public updateAccess(allowAnonymous: boolean) { public updateAllowAnonymous(allowAnonymous: boolean) {
this.clientsState.update(this.client, { allowAnonymous }); this.clientsState.update(this.client, { allowAnonymous });
} }
public updateApiCallsLimit() {
this.clientsState.update(this.client, { apiCallsLimit: this.client.apiCallsLimit });
}
public rename(name: string) { public rename(name: string) {
this.clientsState.update(this.client, { name }); this.clientsState.update(this.client, { name });
} }

10
frontend/app/framework/angular/forms/editors/date-time-editor.component.html

@ -2,13 +2,11 @@
<div class="form-inline"> <div class="form-inline">
<div class="form-group mr-1"> <div class="form-group mr-1">
<div *ngIf="!isCompact && isDateTimeMode && shouldShowDateTimeModeButton"> <div *ngIf="!isCompact && isDateTimeMode && shouldShowDateTimeModeButton">
<button type="button" class="btn btn-text-secondary btn-sm btn-time-mode" (click)="setLocalMode(false)" <button type="button" class="btn btn-text-secondary btn-sm btn-time-mode" (click)="setLocalMode(false)" *ngIf="isLocalMode">
*ngIf="isLocalMode"> {{ 'common.dateTimeEditor.local' | sqxTranslate }}
Local
</button> </button>
<button type="button" class="btn btn-text-secondary btn-sm btn-time-mode" (click)="setLocalMode(true)" <button type="button" class="btn btn-text-secondary btn-sm btn-time-mode" (click)="setLocalMode(true)" *ngIf="!isLocalMode">
*ngIf="!isLocalMode"> {{ 'common.dateTimeEditor.utc' | sqxTranslate }}
UTC
</button> </button>
</div> </div>
<div class="input-group"> <div class="input-group">

4
frontend/app/framework/utils/date-time.spec.ts

@ -13,6 +13,10 @@ describe('DateTime', () => {
const today2 = DateTime.today(); const today2 = DateTime.today();
const now = DateTime.now(); const now = DateTime.now();
beforeEach(() => {
DateHelper.setlocale(null);
});
it('should parse from iso string', () => { it('should parse from iso string', () => {
const actual = DateTime.parseISO('2013-10-16T12:13:14.125', false); const actual = DateTime.parseISO('2013-10-16T12:13:14.125', false);

4
frontend/app/shared/services/clients.service.spec.ts

@ -165,6 +165,8 @@ describe('ClientsService', () => {
name: `Client ${id}`, name: `Client ${id}`,
role: `Role${id}`, role: `Role${id}`,
secret: `secret${id}`, secret: `secret${id}`,
apiCallsLimit: id * 512,
apiTrafficLimit: id * 5120,
allowAnonymous: true, allowAnonymous: true,
_links: { _links: {
update: { method: 'PUT', href: `/clients/id${id}` } update: { method: 'PUT', href: `/clients/id${id}` }
@ -192,5 +194,5 @@ export function createClient(id: number) {
update: { method: 'PUT', href: `/clients/id${id}` } update: { method: 'PUT', href: `/clients/id${id}` }
}; };
return new ClientDto(links, `id${id}`, `Client ${id}`, `secret${id}`, `Role${id}`, true); return new ClientDto(links, `id${id}`, `Client ${id}`, `secret${id}`, `Role${id}`, id * 512, id * 5120, true);
} }

6
frontend/app/shared/services/clients.service.ts

@ -30,6 +30,8 @@ export class ClientDto {
public readonly name: string, public readonly name: string,
public readonly secret: string, public readonly secret: string,
public readonly role: string, public readonly role: string,
public readonly apiCallsLimit: number,
public readonly apiTrafficLimit: number,
public readonly allowAnonymous: boolean public readonly allowAnonymous: boolean
) { ) {
this._links = links; this._links = links;
@ -54,6 +56,8 @@ export interface CreateClientDto {
export interface UpdateClientDto { export interface UpdateClientDto {
readonly name?: string; readonly name?: string;
readonly role?: string; readonly role?: string;
readonly apiCallsLimit?: number;
readonly apiTrafficLimit?: number;
readonly allowAnonymous?: boolean; readonly allowAnonymous?: boolean;
} }
@ -147,6 +151,8 @@ function parseClients(response: any): ClientsPayload {
item.name || item.id, item.name || item.id,
item.secret, item.secret,
item.role, item.role,
item.apiCallsLimit,
item.apiTrafficLimit,
item.allowAnonymous)); item.allowAnonymous));
const _links = response._links; const _links = response._links;

Loading…
Cancel
Save