From d21862e52d7bb352f0ae165baf752aca85f72a7d Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 13 Feb 2017 22:57:24 +0100 Subject: [PATCH] Denormalizer management --- .../MongoEventConsumerInfo.cs | 33 +++ .../MongoEventConsumerInfoRepository.cs | 82 ++++++++ .../MongoRepositoryBase.cs | 6 + .../CQRS/Events/EventReceiver.cs | 119 ++++++++--- .../CQRS/Events/IEvent.cs | 1 + .../CQRS/Events/IEventConsumer.cs | 2 + ...CatchConsumer.cs => IEventConsumerInfo.cs} | 14 +- .../Events/IEventConsumerInfoRepository.cs | 30 +++ .../CQRS/Events/StoredEvent.cs | 18 +- .../InfrastructureErrors.cs | 2 + .../History/MongoHistoryEventRepository.cs | 5 - .../Schemas/MongoSchemaRepository.cs | 5 - .../Utils/MongoDbConsumerWrapper.cs | 74 ------- src/Squidex/Config/Constants.cs | 2 + .../Config/Domain/StoreMongoDbModule.cs | 40 +--- src/Squidex/Config/Domain/Usages.cs | 2 +- .../Config/Identity/IdentityServices.cs | 15 +- src/Squidex/Config/Identity/IdentityUsage.cs | 3 +- .../Config/Identity/LazyClientStore.cs | 6 +- .../EventConsumersController.cs | 71 +++++++ .../EventConsumers/Models/EventConsumerDto.cs | 21 ++ .../Pipeline/ApiExceptionFilterAttribute.cs | 22 +- src/Squidex/app/app.routes.ts | 3 + .../administration-area.component.html | 13 ++ .../administration-area.component.scss | 46 +++++ .../administration-area.component.ts | 17 ++ .../features/administration/declarations.ts | 10 + .../app/features/administration/module.ts | 48 +++++ .../event-consumers-page.component.html | 64 ++++++ .../event-consumers-page.component.scss | 10 + .../event-consumers-page.component.ts | 84 ++++++++ src/Squidex/app/shared/declarations.ts | 1 + src/Squidex/app/shared/module.ts | 2 + .../app/shared/services/auth.service.ts | 6 +- .../services/event-consumers.service.ts | 76 +++++++ .../internal/profile-menu.component.html | 8 +- .../pages/internal/profile-menu.component.ts | 6 +- .../app/theme/icomoon/fonts/icomoon.eot | Bin 12164 -> 12404 bytes .../app/theme/icomoon/fonts/icomoon.svg | 3 + .../app/theme/icomoon/fonts/icomoon.ttf | Bin 12000 -> 12240 bytes .../app/theme/icomoon/fonts/icomoon.woff | Bin 12076 -> 12316 bytes src/Squidex/app/theme/icomoon/selection.json | 195 +++++++++++++----- src/Squidex/app/theme/icomoon/style.css | 19 +- src/Squidex/wwwroot/index.html | 4 +- .../CQRS/Events/EventReceiverTests.cs | 145 ++++++++----- 45 files changed, 1057 insertions(+), 276 deletions(-) create mode 100644 src/Squidex.Infrastructure.MongoDb/MongoEventConsumerInfo.cs create mode 100644 src/Squidex.Infrastructure.MongoDb/MongoEventConsumerInfoRepository.cs rename src/Squidex.Infrastructure/CQRS/Events/{IEventCatchConsumer.cs => IEventConsumerInfo.cs} (63%) create mode 100644 src/Squidex.Infrastructure/CQRS/Events/IEventConsumerInfoRepository.cs delete mode 100644 src/Squidex.Read.MongoDb/Utils/MongoDbConsumerWrapper.cs create mode 100644 src/Squidex/Controllers/Api/EventConsumers/EventConsumersController.cs create mode 100644 src/Squidex/Controllers/Api/EventConsumers/Models/EventConsumerDto.cs create mode 100644 src/Squidex/app/features/administration/administration-area.component.html create mode 100644 src/Squidex/app/features/administration/administration-area.component.scss create mode 100644 src/Squidex/app/features/administration/administration-area.component.ts create mode 100644 src/Squidex/app/features/administration/declarations.ts create mode 100644 src/Squidex/app/features/administration/module.ts create mode 100644 src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html create mode 100644 src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.scss create mode 100644 src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts create mode 100644 src/Squidex/app/shared/services/event-consumers.service.ts diff --git a/src/Squidex.Infrastructure.MongoDb/MongoEventConsumerInfo.cs b/src/Squidex.Infrastructure.MongoDb/MongoEventConsumerInfo.cs new file mode 100644 index 000000000..561882217 --- /dev/null +++ b/src/Squidex.Infrastructure.MongoDb/MongoEventConsumerInfo.cs @@ -0,0 +1,33 @@ +// ========================================================================== +// MongoEventConsumerInfo.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using Squidex.Infrastructure.CQRS.Events; + +namespace Squidex.Infrastructure.MongoDb +{ + public sealed class MongoEventConsumerInfo : IEventConsumerInfo + { + [BsonId] + [BsonRepresentation(BsonType.String)] + public string Name { get; set; } + + [BsonElement] + [BsonIgnoreIfDefault] + public bool IsStopped { get; set; } + + [BsonElement] + [BsonIgnoreIfDefault] + public bool IsResetting { get; set; } + + [BsonElement] + [BsonRequired] + public long LastHandledEventNumber { get; set; } + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure.MongoDb/MongoEventConsumerInfoRepository.cs b/src/Squidex.Infrastructure.MongoDb/MongoEventConsumerInfoRepository.cs new file mode 100644 index 000000000..2726e3f68 --- /dev/null +++ b/src/Squidex.Infrastructure.MongoDb/MongoEventConsumerInfoRepository.cs @@ -0,0 +1,82 @@ +// ========================================================================== +// MongoEventConsumerInfoRepository.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MongoDB.Bson; +using MongoDB.Driver; +using Squidex.Infrastructure.CQRS.Events; + +namespace Squidex.Infrastructure.MongoDb +{ + public sealed class MongoEventConsumerInfoRepository : MongoRepositoryBase, IEventConsumerInfoRepository + { + public MongoEventConsumerInfoRepository(IMongoDatabase database) + : base(database) + { + } + + protected override string CollectionName() + { + return "EventPositions"; + } + + public async Task> QueryAsync() + { + var entities = await Collection.Find(new BsonDocument()).SortBy(x => x.Name).ToListAsync(); + + return entities.OfType().ToList(); + } + + public async Task FindAsync(string consumerName) + { + var entity = await Collection.Find(x => x.Name == consumerName).FirstOrDefaultAsync(); + + return entity; + } + + public async Task CreateAsync(string consumerName) + { + if (await Collection.CountAsync(x => x.Name == consumerName) == 0) + { + try + { + await Collection.InsertOneAsync(new MongoEventConsumerInfo { Name = consumerName, LastHandledEventNumber = -1 }); + } + catch (MongoWriteException ex) + { + if (ex.WriteError?.Category != ServerErrorCategory.DuplicateKey) + { + throw; + } + } + } + } + + public Task StartAsync(string consumerName) + { + return Collection.UpdateOneAsync(x => x.Name == consumerName, Update.Unset(x => x.IsStopped)); + } + + public Task StopAsync(string consumerName) + { + return Collection.UpdateOneAsync(x => x.Name == consumerName, Update.Set(x => x.IsStopped, true)); + } + + public Task SetLastHandledEventNumberAsync(string consumerName, long eventNumber) + { + return Collection.UpdateOneAsync(x => x.Name == consumerName, Update.Set(x => x.LastHandledEventNumber, eventNumber).Unset(x => x.IsResetting).Unset(x => x.IsStopped)); + } + + public Task ResetAsync(string consumerName) + { + return Collection.UpdateOneAsync(x => x.Name == consumerName, Update.Set(x => x.IsResetting, true)); + } + } +} diff --git a/src/Squidex.Infrastructure.MongoDb/MongoRepositoryBase.cs b/src/Squidex.Infrastructure.MongoDb/MongoRepositoryBase.cs index 3ad495f44..1419c3bd1 100644 --- a/src/Squidex.Infrastructure.MongoDb/MongoRepositoryBase.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoRepositoryBase.cs @@ -9,6 +9,7 @@ using System; using System.Globalization; using System.Threading.Tasks; +using MongoDB.Bson; using MongoDB.Driver; namespace Squidex.Infrastructure.MongoDb @@ -128,6 +129,11 @@ namespace Squidex.Infrastructure.MongoDb return Task.FromResult(true); } + public virtual Task ClearAsync() + { + return Collection.DeleteManyAsync(new BsonDocument()); + } + public async Task TryDropCollectionAsync() { try diff --git a/src/Squidex.Infrastructure/CQRS/Events/EventReceiver.cs b/src/Squidex.Infrastructure/CQRS/Events/EventReceiver.cs index 98cb5481d..3b1733682 100644 --- a/src/Squidex.Infrastructure/CQRS/Events/EventReceiver.cs +++ b/src/Squidex.Infrastructure/CQRS/Events/EventReceiver.cs @@ -22,6 +22,7 @@ namespace Squidex.Infrastructure.CQRS.Events private readonly EventDataFormatter formatter; private readonly IEventStore eventStore; private readonly IEventNotifier eventNotifier; + private readonly IEventConsumerInfoRepository eventConsumerInfoRepository; private readonly ILogger logger; private CompletionTimer timer; @@ -29,17 +30,20 @@ namespace Squidex.Infrastructure.CQRS.Events EventDataFormatter formatter, IEventStore eventStore, IEventNotifier eventNotifier, + IEventConsumerInfoRepository eventConsumerInfoRepository, ILogger logger) { Guard.NotNull(logger, nameof(logger)); Guard.NotNull(formatter, nameof(formatter)); Guard.NotNull(eventStore, nameof(eventStore)); Guard.NotNull(eventNotifier, nameof(eventNotifier)); + Guard.NotNull(eventConsumerInfoRepository, nameof(eventConsumerInfoRepository)); this.logger = logger; this.formatter = formatter; this.eventStore = eventStore; this.eventNotifier = eventNotifier; + this.eventConsumerInfoRepository = eventConsumerInfoRepository; } protected override void DisposeObject(bool disposing) @@ -57,7 +61,12 @@ namespace Squidex.Infrastructure.CQRS.Events } } - public void Subscribe(IEventCatchConsumer eventConsumer, int delay = 5000) + public void Trigger() + { + timer?.Trigger(); + } + + public void Subscribe(IEventConsumer eventConsumer, int delay = 5000) { Guard.NotNull(eventConsumer, nameof(eventConsumer)); @@ -66,67 +75,121 @@ namespace Squidex.Infrastructure.CQRS.Events return; } - var lastReceivedPosition = long.MinValue; + var consumerName = eventConsumer.GetType().Name; + var consumerStarted = false; timer = new CompletionTimer(delay, async ct => { - if (lastReceivedPosition == long.MinValue) + if (!consumerStarted) { - lastReceivedPosition = await eventConsumer.GetLastHandledEventNumber(); - } - - var tcs = new TaskCompletionSource(); + await eventConsumerInfoRepository.CreateAsync(consumerName); - eventStore.GetEventsAsync(lastReceivedPosition).Subscribe(storedEvent => - { - var @event = ParseEvent(storedEvent.Data); - - @event.SetEventNumber(storedEvent.EventNumber); - - DispatchConsumer(@event, eventConsumer, storedEvent.EventNumber).Wait(); + consumerStarted = true; + } - lastReceivedPosition++; - }, ex => + try { - tcs.SetException(ex); - }, () => + var status = await eventConsumerInfoRepository.FindAsync(consumerName); + + var lastHandledEventNumber = status.LastHandledEventNumber; + + if (status.IsResetting) + { + await ResetAsync(eventConsumer, consumerName); + + lastHandledEventNumber = -1; + } + else if (status.IsStopped) + { + return; + } + + var tcs = new TaskCompletionSource(); + + eventStore.GetEventsAsync(lastHandledEventNumber).Subscribe(storedEvent => + { + HandleEventAsync(eventConsumer, storedEvent, consumerName).Wait(); + }, ex => + { + tcs.SetException(ex); + }, () => + { + tcs.SetResult(true); + }, ct); + + await tcs.Task; + } + catch (Exception ex) { - tcs.SetResult(true); - }, ct); + logger.LogError(InfrastructureErrors.EventHandlingFailed, ex, "Failed to handle events"); + + await eventConsumerInfoRepository.StopAsync(consumerName); - await tcs.Task; + throw; + } }); eventNotifier.Subscribe(timer.Trigger); } - private async Task DispatchConsumer(Envelope @event, IEventCatchConsumer consumer, long eventNumber) + private async Task HandleEventAsync(IEventConsumer eventConsumer, StoredEvent storedEvent, string consumerName) + { + var @event = ParseEvent(storedEvent); + + await DispatchConsumer(@event, eventConsumer); + + await eventConsumerInfoRepository.SetLastHandledEventNumberAsync(consumerName, storedEvent.EventNumber); + } + + private async Task ResetAsync(IEventConsumer eventConsumer, string consumerName) { try { - await consumer.On(@event, eventNumber); - logger.LogDebug("[{0}]: Handled event {1} ({2})", consumer, @event.Payload, @event.Headers.EventId()); + logger.LogDebug("[{0}]: Reset started", eventConsumer); + + await eventConsumer.ClearAsync(); + await eventConsumerInfoRepository.SetLastHandledEventNumberAsync(consumerName, -1); + + logger.LogDebug("[{0}]: Reset completed", eventConsumer); } catch (Exception ex) { - logger.LogError(InfrastructureErrors.EventHandlingFailed, ex, "[{0}]: Failed to handle event {1} ({2})", consumer, @event.Payload, @event.Headers.EventId()); + logger.LogError(InfrastructureErrors.EventResetFailed, ex, "[{0}]: Reset failed", eventConsumer); throw; } } - private Envelope ParseEvent(EventData eventData) + private async Task DispatchConsumer(Envelope @event, IEventConsumer eventConsumer) { try { - var @event = formatter.Parse(eventData); + await eventConsumer.On(@event); + + logger.LogDebug("[{0}]: Handled event {1} ({2})", eventConsumer, @event.Payload, @event.Headers.EventId()); + } + catch (Exception ex) + { + logger.LogError(InfrastructureErrors.EventHandlingFailed, ex, "[{0}]: Failed to handle event {1} ({2})", eventConsumer, @event.Payload, @event.Headers.EventId()); + + throw; + } + } + + private Envelope ParseEvent(StoredEvent storedEvent) + { + try + { + var @event = formatter.Parse(storedEvent.Data); + + @event.SetEventNumber(storedEvent.EventNumber); return @event; } catch (Exception ex) { - logger.LogError(InfrastructureErrors.EventDeserializationFailed, ex, "Failed to parse event {0}", eventData.EventId); + logger.LogError(InfrastructureErrors.EventDeserializationFailed, ex, "Failed to parse event {0}", storedEvent.Data.EventId); throw; } diff --git a/src/Squidex.Infrastructure/CQRS/Events/IEvent.cs b/src/Squidex.Infrastructure/CQRS/Events/IEvent.cs index 863bfaf64..11915d798 100644 --- a/src/Squidex.Infrastructure/CQRS/Events/IEvent.cs +++ b/src/Squidex.Infrastructure/CQRS/Events/IEvent.cs @@ -5,6 +5,7 @@ // Copyright (c) Squidex Group // All rights reserved. // ========================================================================== + namespace Squidex.Infrastructure.CQRS.Events { public interface IEvent diff --git a/src/Squidex.Infrastructure/CQRS/Events/IEventConsumer.cs b/src/Squidex.Infrastructure/CQRS/Events/IEventConsumer.cs index 5389aa5d0..b6eb06cb9 100644 --- a/src/Squidex.Infrastructure/CQRS/Events/IEventConsumer.cs +++ b/src/Squidex.Infrastructure/CQRS/Events/IEventConsumer.cs @@ -12,6 +12,8 @@ namespace Squidex.Infrastructure.CQRS.Events { public interface IEventConsumer { + Task ClearAsync(); + Task On(Envelope @event); } } \ No newline at end of file diff --git a/src/Squidex.Infrastructure/CQRS/Events/IEventCatchConsumer.cs b/src/Squidex.Infrastructure/CQRS/Events/IEventConsumerInfo.cs similarity index 63% rename from src/Squidex.Infrastructure/CQRS/Events/IEventCatchConsumer.cs rename to src/Squidex.Infrastructure/CQRS/Events/IEventConsumerInfo.cs index c0403b60e..7fe026d5a 100644 --- a/src/Squidex.Infrastructure/CQRS/Events/IEventCatchConsumer.cs +++ b/src/Squidex.Infrastructure/CQRS/Events/IEventConsumerInfo.cs @@ -1,19 +1,21 @@ // ========================================================================== -// IEventCatchConsumer.cs +// IEventCatchConsumerInfo.cs // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex Group // All rights reserved. // ========================================================================== -using System.Threading.Tasks; - namespace Squidex.Infrastructure.CQRS.Events { - public interface IEventCatchConsumer + public interface IEventConsumerInfo { - Task GetLastHandledEventNumber(); + long LastHandledEventNumber { get; } + + bool IsStopped { get; } + + bool IsResetting { get; } - Task On(Envelope @event, long eventNumber); + string Name { get; } } } \ No newline at end of file diff --git a/src/Squidex.Infrastructure/CQRS/Events/IEventConsumerInfoRepository.cs b/src/Squidex.Infrastructure/CQRS/Events/IEventConsumerInfoRepository.cs new file mode 100644 index 000000000..a9a9cf788 --- /dev/null +++ b/src/Squidex.Infrastructure/CQRS/Events/IEventConsumerInfoRepository.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// IEventCatchConsumerControlStore.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Squidex.Infrastructure.CQRS.Events +{ + public interface IEventConsumerInfoRepository + { + Task> QueryAsync(); + + Task FindAsync(string consumerName); + + Task CreateAsync(string consumerName); + + Task StartAsync(string consumerName); + + Task StopAsync(string consumerName); + + Task ResetAsync(string consumerName); + + Task SetLastHandledEventNumberAsync(string consumerName, long eventNumber); + } +} diff --git a/src/Squidex.Infrastructure/CQRS/Events/StoredEvent.cs b/src/Squidex.Infrastructure/CQRS/Events/StoredEvent.cs index 478081e8c..4f8f04d6e 100644 --- a/src/Squidex.Infrastructure/CQRS/Events/StoredEvent.cs +++ b/src/Squidex.Infrastructure/CQRS/Events/StoredEvent.cs @@ -5,21 +5,31 @@ // Copyright (c) Squidex Group // All rights reserved. // ========================================================================== + namespace Squidex.Infrastructure.CQRS.Events { public sealed class StoredEvent { - public long EventNumber { get; } + private readonly long eventNumber; + private readonly EventData data; + + public long EventNumber + { + get { return eventNumber; } + } - public EventData Data { get; } + public EventData Data + { + get { return data; } + } public StoredEvent(long eventNumber, EventData data) { Guard.NotNull(data, nameof(data)); - EventNumber = eventNumber; + this.data = data; - Data = data; + this.eventNumber = eventNumber; } } } diff --git a/src/Squidex.Infrastructure/InfrastructureErrors.cs b/src/Squidex.Infrastructure/InfrastructureErrors.cs index f2b732ecc..0d006dd0b 100644 --- a/src/Squidex.Infrastructure/InfrastructureErrors.cs +++ b/src/Squidex.Infrastructure/InfrastructureErrors.cs @@ -16,6 +16,8 @@ namespace Squidex.Infrastructure public static readonly EventId CommandFailed = new EventId(20001, "CommandFailed"); + public static readonly EventId EventResetFailed = new EventId(10000, "EventResetFailed"); + public static readonly EventId EventHandlingFailed = new EventId(10001, "EventHandlingFailed"); public static readonly EventId EventDeserializationFailed = new EventId(10002, "EventDeserializationFailed"); diff --git a/src/Squidex.Read.MongoDb/History/MongoHistoryEventRepository.cs b/src/Squidex.Read.MongoDb/History/MongoHistoryEventRepository.cs index 7246c295a..c4f2adfa1 100644 --- a/src/Squidex.Read.MongoDb/History/MongoHistoryEventRepository.cs +++ b/src/Squidex.Read.MongoDb/History/MongoHistoryEventRepository.cs @@ -59,11 +59,6 @@ namespace Squidex.Read.MongoDb.History collection.Indexes.CreateOneAsync(IndexKeys.Ascending(x => x.Created), new CreateIndexOptions { ExpireAfter = TimeSpan.FromDays(365) })); } - public Task ClearAsync() - { - return TryDropCollectionAsync(); - } - public async Task> QueryEventsByChannel(Guid appId, string channelPrefix, int count) { var entities = diff --git a/src/Squidex.Read.MongoDb/Schemas/MongoSchemaRepository.cs b/src/Squidex.Read.MongoDb/Schemas/MongoSchemaRepository.cs index 12ca2b00f..58db8d1dc 100644 --- a/src/Squidex.Read.MongoDb/Schemas/MongoSchemaRepository.cs +++ b/src/Squidex.Read.MongoDb/Schemas/MongoSchemaRepository.cs @@ -47,11 +47,6 @@ namespace Squidex.Read.MongoDb.Schemas return collection.Indexes.CreateOneAsync(IndexKeys.Ascending(x => x.Name)); } - public Task ClearAsync() - { - return TryDropCollectionAsync(); - } - public async Task> QueryAllAsync(Guid appId) { var entities = await Collection.Find(s => s.AppId == appId).ToListAsync(); diff --git a/src/Squidex.Read.MongoDb/Utils/MongoDbConsumerWrapper.cs b/src/Squidex.Read.MongoDb/Utils/MongoDbConsumerWrapper.cs deleted file mode 100644 index 186e180fa..000000000 --- a/src/Squidex.Read.MongoDb/Utils/MongoDbConsumerWrapper.cs +++ /dev/null @@ -1,74 +0,0 @@ -// ========================================================================== -// MongoDbStore.cs -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex Group -// All rights reserved. -// ========================================================================== - -using System.Threading.Tasks; -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; -using MongoDB.Driver; -using Squidex.Infrastructure; -using Squidex.Infrastructure.CQRS; -using Squidex.Infrastructure.CQRS.Events; -using Squidex.Infrastructure.MongoDb; - -namespace Squidex.Read.MongoDb.Utils -{ - public sealed class EventPosition - { - [BsonId] - [BsonRepresentation(BsonType.String)] - public string Name { get; set; } - - [BsonElement] - [BsonRequired] - public long EventNumber { get; set; } - } - - public sealed class MongoDbConsumerWrapper : MongoRepositoryBase, IEventCatchConsumer - { - private static readonly UpdateOptions upsert = new UpdateOptions { IsUpsert = true }; - private readonly IEventConsumer eventConsumer; - private readonly string eventStoreName; - - public MongoDbConsumerWrapper(IMongoDatabase database, IEventConsumer eventConsumer) - : base(database) - { - Guard.NotNull(eventConsumer, nameof(eventConsumer)); - - this.eventConsumer = eventConsumer; - - eventStoreName = eventConsumer.GetType().Name; - } - - protected override string CollectionName() - { - return "EventPositions"; - } - - public async Task On(Envelope @event, long eventNumber) - { - await eventConsumer.On(@event); - - await SetLastHandledEventNumber(eventNumber); - } - - private Task SetLastHandledEventNumber(long eventNumber) - { - return Collection.ReplaceOneAsync(x => x.Name == eventStoreName, new EventPosition { Name = eventStoreName, EventNumber = eventNumber }, upsert); - } - - public async Task GetLastHandledEventNumber() - { - var collectionPosition = - await Collection - .Find(x => x.Name == eventStoreName).SortByDescending(x => x.EventNumber).Limit(1) - .FirstOrDefaultAsync(); - - return collectionPosition?.EventNumber ?? -1; - } - } -} diff --git a/src/Squidex/Config/Constants.cs b/src/Squidex/Config/Constants.cs index 19b7a0d0c..58f765584 100644 --- a/src/Squidex/Config/Constants.cs +++ b/src/Squidex/Config/Constants.cs @@ -14,6 +14,8 @@ namespace Squidex.Config public const string ApiScope = "squidex-api"; + public const string RoleScope = "role"; + public const string ProfileScope = "squidex-profile"; public const string FrontendClient = "squidex-frontend"; diff --git a/src/Squidex/Config/Domain/StoreMongoDbModule.cs b/src/Squidex/Config/Domain/StoreMongoDbModule.cs index c78beded1..4a152f644 100644 --- a/src/Squidex/Config/Domain/StoreMongoDbModule.cs +++ b/src/Squidex/Config/Domain/StoreMongoDbModule.cs @@ -15,6 +15,7 @@ using Microsoft.Extensions.Configuration; using MongoDB.Driver; using Squidex.Infrastructure; using Squidex.Infrastructure.CQRS.Events; +using Squidex.Infrastructure.MongoDb; using Squidex.Read.Apps.Repositories; using Squidex.Read.Contents.Repositories; using Squidex.Read.History.Repositories; @@ -24,7 +25,6 @@ using Squidex.Read.MongoDb.History; using Squidex.Read.MongoDb.Infrastructure; using Squidex.Read.MongoDb.Schemas; using Squidex.Read.MongoDb.Users; -using Squidex.Read.MongoDb.Utils; using Squidex.Read.Schemas.Repositories; using Squidex.Read.Users.Repositories; @@ -93,15 +93,23 @@ namespace Squidex.Config.Domain .As() .SingleInstance(); + builder.RegisterType() + .WithParameter(ResolvedParameter.ForNamed(MongoDatabaseName)) + .As() + .AsSelf() + .SingleInstance(); + builder.RegisterType() .WithParameter(ResolvedParameter.ForNamed(MongoDatabaseName)) .As() + .As() .AsSelf() .SingleInstance(); builder.RegisterType() .WithParameter(ResolvedParameter.ForNamed(MongoDatabaseName)) .As() + .As() .As() .AsSelf() .SingleInstance(); @@ -109,6 +117,7 @@ namespace Squidex.Config.Domain builder.RegisterType() .WithParameter(ResolvedParameter.ForNamed(MongoDatabaseName)) .As() + .As() .As() .AsSelf() .SingleInstance(); @@ -116,37 +125,10 @@ namespace Squidex.Config.Domain builder.RegisterType() .WithParameter(ResolvedParameter.ForNamed(MongoDatabaseName)) .As() + .As() .As() .AsSelf() .SingleInstance(); - - builder.Register(c => - new MongoDbConsumerWrapper( - c.ResolveNamed(MongoDatabaseName), - c.Resolve())) - .As() - .SingleInstance(); - - builder.Register(c => - new MongoDbConsumerWrapper( - c.ResolveNamed(MongoDatabaseName), - c.Resolve())) - .As() - .SingleInstance(); - - builder.Register(c => - new MongoDbConsumerWrapper( - c.ResolveNamed(MongoDatabaseName), - c.Resolve())) - .As() - .SingleInstance(); - - builder.Register(c => - new MongoDbConsumerWrapper( - c.ResolveNamed(MongoDatabaseName), - c.Resolve())) - .As() - .SingleInstance(); } } } diff --git a/src/Squidex/Config/Domain/Usages.cs b/src/Squidex/Config/Domain/Usages.cs index bc611d46b..b3d4b9da1 100644 --- a/src/Squidex/Config/Domain/Usages.cs +++ b/src/Squidex/Config/Domain/Usages.cs @@ -22,7 +22,7 @@ namespace Squidex.Config.Domain { public static IApplicationBuilder UseMyEventStore(this IApplicationBuilder app) { - var catchConsumers = app.ApplicationServices.GetServices(); + var catchConsumers = app.ApplicationServices.GetServices(); foreach (var catchConsumer in catchConsumers) { diff --git a/src/Squidex/Config/Identity/IdentityServices.cs b/src/Squidex/Config/Identity/IdentityServices.cs index d3462be1f..a52d9ff84 100644 --- a/src/Squidex/Config/Identity/IdentityServices.cs +++ b/src/Squidex/Config/Identity/IdentityServices.cs @@ -11,6 +11,7 @@ using System.Collections.Generic; using System.IO; using System.Reflection; using System.Security.Cryptography.X509Certificates; +using IdentityModel; using IdentityServer4.Models; using IdentityServer4.Stores; using Microsoft.AspNetCore.DataProtection; @@ -107,13 +108,25 @@ namespace Squidex.Config.Identity private static IEnumerable GetApiResources() { - yield return new ApiResource(Constants.ApiScope); + yield return new ApiResource(Constants.ApiScope) + { + UserClaims = new List + { + JwtClaimTypes.Role + } + }; } private static IEnumerable GetIdentityResources() { yield return new IdentityResources.OpenId(); yield return new IdentityResources.Profile(); + yield return new IdentityResources.Profile(); + yield return new IdentityResource(Constants.RoleScope, + new[] + { + JwtClaimTypes.Role + }); yield return new IdentityResource(Constants.ProfileScope, new[] { diff --git a/src/Squidex/Config/Identity/IdentityUsage.cs b/src/Squidex/Config/Identity/IdentityUsage.cs index 123ce6187..a2d8c8384 100644 --- a/src/Squidex/Config/Identity/IdentityUsage.cs +++ b/src/Squidex/Config/Identity/IdentityUsage.cs @@ -140,8 +140,7 @@ namespace Squidex.Config.Identity { var apiRequestUri = new Uri($"https://www.googleapis.com/oauth2/v2/userinfo?access_token={context.AccessToken}"); - var jsonReponseString = - await HttpClient.GetStringAsync(apiRequestUri); + var jsonReponseString = await HttpClient.GetStringAsync(apiRequestUri); var jsonResponse = JToken.Parse(jsonReponseString); var pictureUrl = jsonResponse["picture"]?.Value(); diff --git a/src/Squidex/Config/Identity/LazyClientStore.cs b/src/Squidex/Config/Identity/LazyClientStore.cs index 34f0a897f..50a3de656 100644 --- a/src/Squidex/Config/Identity/LazyClientStore.cs +++ b/src/Squidex/Config/Identity/LazyClientStore.cs @@ -77,7 +77,8 @@ namespace Squidex.Config.Identity AllowedGrantTypes = GrantTypes.ClientCredentials, AllowedScopes = new List { - Constants.ApiScope + Constants.ApiScope, + Constants.RoleScope } }; } @@ -115,7 +116,8 @@ namespace Squidex.Config.Identity IdentityServerConstants.StandardScopes.OpenId, IdentityServerConstants.StandardScopes.Profile, Constants.ApiScope, - Constants.ProfileScope + Constants.ProfileScope, + Constants.RoleScope }, RequireConsent = false }; diff --git a/src/Squidex/Controllers/Api/EventConsumers/EventConsumersController.cs b/src/Squidex/Controllers/Api/EventConsumers/EventConsumersController.cs new file mode 100644 index 000000000..1f3303292 --- /dev/null +++ b/src/Squidex/Controllers/Api/EventConsumers/EventConsumersController.cs @@ -0,0 +1,71 @@ +// ========================================================================== +// EventConsumersController.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using NSwag.Annotations; +using Squidex.Controllers.Api.EventConsumers.Models; +using Squidex.Infrastructure.CQRS.Events; +using Squidex.Infrastructure.Reflection; +using Squidex.Pipeline; + +namespace Squidex.Controllers.Api.EventConsumers +{ + [ApiExceptionFilter] + [Authorize(Roles = "administrator")] + [SwaggerIgnore] + public sealed class EventConsumersController : Controller + { + private readonly IEventConsumerInfoRepository eventConsumerRepository; + + public EventConsumersController(IEventConsumerInfoRepository eventConsumerRepository) + { + this.eventConsumerRepository = eventConsumerRepository; + } + + [HttpGet] + [Route("event-consumers/")] + public async Task GetEventConsumers() + { + var entities = await eventConsumerRepository.QueryAsync(); + + var models = entities.Select(x => SimpleMapper.Map(x, new EventConsumerDto())).ToList(); + + return Ok(models); + } + + [HttpPut] + [Route("event-consumers/{name}/start")] + public async Task Start(string name) + { + await eventConsumerRepository.StartAsync(name); + + return NoContent(); + } + + [HttpPut] + [Route("event-consumers/{name}/stop")] + public async Task Stop(string name) + { + await eventConsumerRepository.StopAsync(name); + + return NoContent(); + } + + [HttpPut] + [Route("event-consumers/{name}/reset")] + public async Task Reset(string name) + { + await eventConsumerRepository.ResetAsync(name); + + return NoContent(); + } + } +} diff --git a/src/Squidex/Controllers/Api/EventConsumers/Models/EventConsumerDto.cs b/src/Squidex/Controllers/Api/EventConsumers/Models/EventConsumerDto.cs new file mode 100644 index 000000000..6a88a000d --- /dev/null +++ b/src/Squidex/Controllers/Api/EventConsumers/Models/EventConsumerDto.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// EventConsumerDto.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Controllers.Api.EventConsumers.Models +{ + public sealed class EventConsumerDto + { + public long LastHandledEventNumber { get; set; } + + public bool IsStopped { get; set; } + + public bool IsResetting { get; set; } + + public string Name { get; set; } + } +} diff --git a/src/Squidex/Pipeline/ApiExceptionFilterAttribute.cs b/src/Squidex/Pipeline/ApiExceptionFilterAttribute.cs index d96fc54d3..5a9b7db4c 100644 --- a/src/Squidex/Pipeline/ApiExceptionFilterAttribute.cs +++ b/src/Squidex/Pipeline/ApiExceptionFilterAttribute.cs @@ -34,14 +34,24 @@ namespace Squidex.Pipeline static ApiExceptionFilterAttribute() { - AddHandler(ex => - new NotFoundResult()); + AddHandler(OnDomainObjectNotFoundException); + AddHandler(OnDomainException); + AddHandler(OnValidationException); + } - AddHandler(ex => - new BadRequestObjectResult(new ErrorDto { Message = ex.Message })); + private static IActionResult OnDomainObjectNotFoundException(DomainObjectNotFoundException ex) + { + return new NotFoundResult(); + } - AddHandler(ex => - new BadRequestObjectResult(new ErrorDto { Message = ex.Message, Details = ex.Errors.Select(e => e.Message).ToArray() })); + private static IActionResult OnDomainException(DomainException ex) + { + return new BadRequestObjectResult(new ErrorDto { Message = ex.Message }); + } + + private static IActionResult OnValidationException(ValidationException ex) + { + return new BadRequestObjectResult(new ErrorDto { Message = ex.Message, Details = ex.Errors.Select(e => e.Message).ToArray() }); } public override void OnActionExecuting(ActionExecutingContext context) diff --git a/src/Squidex/app/app.routes.ts b/src/Squidex/app/app.routes.ts index 284c812d1..819a7c4d9 100644 --- a/src/Squidex/app/app.routes.ts +++ b/src/Squidex/app/app.routes.ts @@ -36,6 +36,9 @@ export const routes: Routes = [ { path: '', loadChildren: './features/apps/module#SqxFeatureAppsModule' + }, { + path: 'administration', + loadChildren: './features/administration/module#SqxFeatureAdministrationModule' }, { path: ':appName', component: AppAreaComponent, diff --git a/src/Squidex/app/features/administration/administration-area.component.html b/src/Squidex/app/features/administration/administration-area.component.html new file mode 100644 index 000000000..0890e6dfd --- /dev/null +++ b/src/Squidex/app/features/administration/administration-area.component.html @@ -0,0 +1,13 @@ + + +
+ +
\ No newline at end of file diff --git a/src/Squidex/app/features/administration/administration-area.component.scss b/src/Squidex/app/features/administration/administration-area.component.scss new file mode 100644 index 000000000..8c3d26f9d --- /dev/null +++ b/src/Squidex/app/features/administration/administration-area.component.scss @@ -0,0 +1,46 @@ +@import '_vars'; +@import '_mixins'; + +.sidebar { + @include fixed($size-navbar-height, auto, 0, 0); + @include box-shadow-colored(2px, 0, 0, $color-dark1-border2); + min-width: $size-sidebar-width; + max-width: $size-sidebar-width; + border-right: 1px solid $color-dark1-border1; + background: $color-dark1-background; + z-index: 100; +} + +.nav { + &-icon { + font-size: 2rem; + } + + &-text { + font-size: .9rem; + } + + &-link { + & { + @include transition(color .3s ease); + padding: 1.25rem; + display: block; + text-align: center; + text-decoration: none; + color: $color-dark1-foreground; + } + + &:hover, + &.active { + color: $color-dark1-focus-foreground; + + .nav-icon { + color: $color-theme-blue; + } + } + + &.active { + background: $color-dark1-active-background; + } + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/administration-area.component.ts b/src/Squidex/app/features/administration/administration-area.component.ts new file mode 100644 index 000000000..25bcf4595 --- /dev/null +++ b/src/Squidex/app/features/administration/administration-area.component.ts @@ -0,0 +1,17 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved + */ + +import { Component } from '@angular/core'; + +@Component({ + selector: 'sqx-administration-area', + styleUrls: ['./administration-area.component.scss'], + templateUrl: './administration-area.component.html' +}) +export class AdministrationAreaComponent { + +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/declarations.ts b/src/Squidex/app/features/administration/declarations.ts new file mode 100644 index 000000000..278b1a192 --- /dev/null +++ b/src/Squidex/app/features/administration/declarations.ts @@ -0,0 +1,10 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved + */ + +export * from './pages/event-consumers/event-consumers-page.component'; + +export * from './administration-area.component'; \ No newline at end of file diff --git a/src/Squidex/app/features/administration/module.ts b/src/Squidex/app/features/administration/module.ts new file mode 100644 index 000000000..ac6885a07 --- /dev/null +++ b/src/Squidex/app/features/administration/module.ts @@ -0,0 +1,48 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved + */ + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { + SqxFrameworkModule, + SqxSharedModule +} from 'shared'; + +import { + AdministrationAreaComponent, + EventConsumersPage +} from './declarations'; + +const routes: Routes = [ + { + path: '', + component: AdministrationAreaComponent, + children: [ + { + path: '', + children: [{ + path: 'event-consumers', + component: EventConsumersPage + }] + } + ] + } +]; + +@NgModule({ + imports: [ + SqxFrameworkModule, + SqxSharedModule, + RouterModule.forChild(routes) + ], + declarations: [ + AdministrationAreaComponent, + EventConsumersPage + ] +}) +export class SqxFeatureAdministrationModule { } \ No newline at end of file diff --git a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html new file mode 100644 index 000000000..45ff5c6dd --- /dev/null +++ b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html @@ -0,0 +1,64 @@ + + + +
+
+

EventConsumers

+
+ + + + +
+ +
+
+ + + + + + + + + + + + + + + + + + +
+ Name + + Event Number + + Options +
+
+
+
\ No newline at end of file diff --git a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.scss b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.scss new file mode 100644 index 000000000..983d3e23a --- /dev/null +++ b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.scss @@ -0,0 +1,10 @@ +@import '_vars'; +@import '_mixins'; + +button { + display: inline-block; +} + +.col-right { + text-align: right; +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts new file mode 100644 index 000000000..e8a43a744 --- /dev/null +++ b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts @@ -0,0 +1,84 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved + */ + +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Observable, Subscription } from 'rxjs'; + +import { + EventConsumerDto, + EventConsumersService, + ImmutableArray +} from 'shared'; + +@Component({ + selector: 'sqx-event-consumers-page', + styleUrls: ['./event-consumers-page.component.scss'], + templateUrl: './event-consumers-page.component.html' +}) +export class EventConsumersPage implements OnInit, OnDestroy { + private subscription: Subscription; + + public eventConsumers = ImmutableArray.empty(); + + constructor( + private readonly eventConsumersService: EventConsumersService + ) { + } + + public ngOnInit() { + this.subscription = + Observable.timer(0, 4000) + .switchMap(_ => this.eventConsumersService.getEventConsumers()) + .subscribe(dtos => { + this.eventConsumers = ImmutableArray.of(dtos); + }); + } + + public ngOnDestroy() { + this.subscription.unsubscribe(); + } + + public start(name: string) { + this.eventConsumersService.startEventConsumer(name) + .subscribe(() => { + this.eventConsumers = this.eventConsumers.map(e => { + if (e.name === name) { + return new EventConsumerDto(name, e.lastHandledEventNumber, false, e.isResetting); + } else { + return e; + } + }); + }); + } + + public stop(name: string) { + this.eventConsumersService.stopEventConsumer(name) + .subscribe(() => { + this.eventConsumers = this.eventConsumers.map(e => { + if (e.name === name) { + return new EventConsumerDto(name, e.lastHandledEventNumber, true, e.isResetting); + } else { + return e; + } + }); + }); + } + + public reset(name: string) { + this.eventConsumersService.resetEventConsumer(name) + .subscribe(() => { + this.eventConsumers = this.eventConsumers.map(e => { + if (e.name === name) { + return new EventConsumerDto(name, e.lastHandledEventNumber, e.isStopped, true); + } else { + return e; + } + }); + }); + } +} + diff --git a/src/Squidex/app/shared/declarations.ts b/src/Squidex/app/shared/declarations.ts index 1c821084d..4f5797daa 100644 --- a/src/Squidex/app/shared/declarations.ts +++ b/src/Squidex/app/shared/declarations.ts @@ -26,6 +26,7 @@ export * from './services/apps-store.service'; export * from './services/apps.service'; export * from './services/auth.service'; export * from './services/contents.service'; +export * from './services/event-consumers.service'; export * from './services/history.service'; export * from './services/languages.service'; export * from './services/schemas.service'; diff --git a/src/Squidex/app/shared/module.ts b/src/Squidex/app/shared/module.ts index d832f30c9..5c70aa68c 100644 --- a/src/Squidex/app/shared/module.ts +++ b/src/Squidex/app/shared/module.ts @@ -20,6 +20,7 @@ import { AuthService, ContentsService, DashboardLinkDirective, + EventConsumersService, HistoryComponent, HistoryService, LanguageSelectorComponent, @@ -65,6 +66,7 @@ export class SqxSharedModule { AppMustExistGuard, AuthService, ContentsService, + EventConsumersService, HistoryService, LanguageService, MustBeAuthenticatedGuard, diff --git a/src/Squidex/app/shared/services/auth.service.ts b/src/Squidex/app/shared/services/auth.service.ts index 891608541..c77afb031 100644 --- a/src/Squidex/app/shared/services/auth.service.ts +++ b/src/Squidex/app/shared/services/auth.service.ts @@ -31,6 +31,10 @@ export class Profile { return this.user.profile['urn:squidex:picture']; } + public get isAdmin(): boolean { + return this.user.profile['role'] === 'administrator'; + } + public get token(): string { return `subject:${this.id}`; } @@ -74,7 +78,7 @@ export class AuthService { this.userManager = new UserManager({ client_id: 'squidex-frontend', - scope: 'squidex-api openid profile squidex-profile', + scope: 'squidex-api openid profile squidex-profile role', response_type: 'id_token token', redirect_uri: apiUrl.buildUrl('login;'), post_logout_redirect_uri: apiUrl.buildUrl('logout'), diff --git a/src/Squidex/app/shared/services/event-consumers.service.ts b/src/Squidex/app/shared/services/event-consumers.service.ts new file mode 100644 index 000000000..328e4c031 --- /dev/null +++ b/src/Squidex/app/shared/services/event-consumers.service.ts @@ -0,0 +1,76 @@ +/* + * 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 } from 'framework'; +import { AuthService } from './auth.service'; + +export class EventConsumerDto { + constructor( + public readonly name: string, + public readonly lastHandledEventNumber: number, + public readonly isStopped: boolean, + public readonly isResetting: boolean + ) { + } +} + +@Injectable() +export class EventConsumersService { + constructor( + private readonly authService: AuthService, + private readonly apiUrl: ApiUrlConfig + ) { + } + + public getEventConsumers(): Observable { + const url = this.apiUrl.buildUrl('/api/event-consumers'); + + return this.authService.authGet(url) + .map(response => response.json()) + .map(response => { + const items: any[] = response; + + return items.map(item => { + return new EventConsumerDto( + item.name, + item.lastHandledEventNumber, + item.isStopped, + item.isResetting); + }); + }) + .catchError('Failed to load event consumers. Please reload.'); + } + + public startEventConsumer(name: string): Observable { + const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/start`); + + return this.authService.authPut(url, {}) + .map(response => response.json()) + .catchError('Failed to start event consumer. Please reload.'); + } + + public stopEventConsumer(name: string): Observable { + const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/stop`); + + return this.authService.authPut(url, {}) + .map(response => response.json()) + .catchError('Failed to stop event consumer. Please reload.'); + } + + public resetEventConsumer(name: string): Observable { + const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/reset`); + + return this.authService.authPut(url, {}) + .map(response => response.json()) + .catchError('Failed to reset event consumer. Please reload.'); + } +} \ No newline at end of file diff --git a/src/Squidex/app/shell/pages/internal/profile-menu.component.html b/src/Squidex/app/shell/pages/internal/profile-menu.component.html index 647b0d595..30dc72f77 100644 --- a/src/Squidex/app/shell/pages/internal/profile-menu.component.html +++ b/src/Squidex/app/shell/pages/internal/profile-menu.component.html @@ -7,7 +7,13 @@ \ No newline at end of file diff --git a/src/Squidex/app/shell/pages/internal/profile-menu.component.ts b/src/Squidex/app/shell/pages/internal/profile-menu.component.ts index df0cc009d..fa8cb9de2 100644 --- a/src/Squidex/app/shell/pages/internal/profile-menu.component.ts +++ b/src/Squidex/app/shell/pages/internal/profile-menu.component.ts @@ -30,6 +30,8 @@ export class ProfileMenuComponent implements OnInit, OnDestroy { public profileDisplayName = ''; public profilePictureUrl = ''; + public isAdmin = false; + constructor( private readonly auth: AuthService ) { @@ -41,12 +43,14 @@ export class ProfileMenuComponent implements OnInit, OnDestroy { public ngOnInit() { this.authenticationSubscription = - this.auth.isAuthenticated.subscribe(() => { + this.auth.isAuthenticated.take(1).subscribe(() => { const user = this.auth.user; if (user) { this.profilePictureUrl = user.pictureUrl; this.profileDisplayName = user.displayName; + + this.isAdmin = user.isAdmin; } }); } diff --git a/src/Squidex/app/theme/icomoon/fonts/icomoon.eot b/src/Squidex/app/theme/icomoon/fonts/icomoon.eot index 3fe48aba04f93e93c71e5d519b65089641d64a11..56d9077361d395fb4098e0d48f00a8e53087b19a 100644 GIT binary patch delta 511 zcmZpP|B}E~V!*&~L4P8f84LTj%-s_m$^|(X7#Q{daYAx#Vu5(rrR@_>$kZ>on0bnU zfw6*tLF-FKYGMk{+7Fcs3_3YLb!Hhr0ro@8-+=rjK)y;wZb`)+K|X09{{)bKB_}^Q zkxx(@$kv?$!pEenq{pOgt|(|O$82n5$7IS}r5u`&Ws$YJJ0QWaa7LJ3xWB9HG{%HB zH3aY9f;PA!W+^p0ZSjEa-C1EX3LS-2j1@qN|J5&pbAhU1N~Uc8yFq_9bh~FBAFfj0_FY!)iN?10ICH-pe_bsPR0XF4SyS$9sV`^YXHiD z!S2j&?D70IUm3Vr!2B!6ul@rvSQ$W!$ss!CjHZ)&bWZSqWP!K=h!0E-(M=N*4HL}~ ZtpXck${4{Q2G-3qxlmtt^L^c=i~vf;hA#jB delta 268 zcmey8&=SwqqR+taKyM + + + \ No newline at end of file diff --git a/src/Squidex/app/theme/icomoon/fonts/icomoon.ttf b/src/Squidex/app/theme/icomoon/fonts/icomoon.ttf index 1bbb8122f5efcadc8ac05dbef78cd1df83841914..b657e5a6aa9456e0ea7de7e024ae9e5381b1376c 100644 GIT binary patch delta 520 zcmYL_O)LXJ6vyAplqH(hs;2SPs7i@aO{?l4wLQ4dq!Lsj5#9LcN48WrluP&2OHVWr z5(g1+BTXC}>acOqI3e5}9UX9yE^nJ)CU0hb@Bg2fH}kS^znBFA01Y4m19y0O&|_?> zt;5VUMsz79rY(`jD*)1tJQEW)(>OOGYja&p+Fm_*NZcW30Q%nJE8?>L{B0YcUqXF2 zjtTCDeIlPCcgIs(nQPwAhFnH|lGc}kqJg&{3>;xXN{N{?gvdOAODm#tO-!vEPSk%P z_u?hF^!nx&7K&}KsOiAi#jY|&+g!Fx3-<$`zRjs;tVWRa+_X$t@a3KcGQZ<)_{ o1V)7i_G!CFBNR!cKKD|SX0RJ~c7ytkO diff --git a/src/Squidex/app/theme/icomoon/fonts/icomoon.woff b/src/Squidex/app/theme/icomoon/fonts/icomoon.woff index adacdf3629ec5b19c528a529bff101c7b77fda8f..ba889e0dfe32ec814f0452d422a1f58ddc3dd2e4 100644 GIT binary patch delta 563 zcmYL@yDvjg9LIm>9CC@K+}t+xj;fH*S6Wqr)a}54V4xBMz45B1^$3Z_(mQoA$xWmZ zgBVO$3?wz&h=iCB{s*H0i}vz64RuaF=ls6k-}gLDrVgT(jgkI-Ab`VL4;6g(JZPe) zde(mu`|s?dWlyXB7?+xo+07(6b1O!OMvN613o!=>#PnYTKMNF8keSM4U?2cUcF|4{ zmB@xpbiOhP_b7S*~L#`y0Yg3DoP6a`S!)Yq7-W8OUO0UOAbE(VkIvvJay-qweMzjE-8$ zU~e-pv^V^@8P9L?m4TZDsFH!<$_9oq7(Mxpjya>zWD(sHlV9kjfjkV-WVAU-?+_yZ D$G=EE diff --git a/src/Squidex/app/theme/icomoon/selection.json b/src/Squidex/app/theme/icomoon/selection.json index e54115a52..0863b5820 100644 --- a/src/Squidex/app/theme/icomoon/selection.json +++ b/src/Squidex/app/theme/icomoon/selection.json @@ -1,6 +1,105 @@ { "IcoMoonType": "selection", "icons": [ + { + "icon": { + "paths": [ + "M889.68 166.32c-93.608-102.216-228.154-166.32-377.68-166.32-282.77 0-512 229.23-512 512h96c0-229.75 186.25-416 416-416 123.020 0 233.542 53.418 309.696 138.306l-149.696 149.694h352v-352l-134.32 134.32z", + "M928 512c0 229.75-186.25 416-416 416-123.020 0-233.542-53.418-309.694-138.306l149.694-149.694h-352v352l134.32-134.32c93.608 102.216 228.154 166.32 377.68 166.32 282.77 0 512-229.23 512-512h-96z" + ], + "attrs": [ + {}, + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "tags": [ + "loop", + "repeat", + "player", + "reload", + "refresh", + "update", + "synchronize", + "arrows" + ], + "grid": 16 + }, + "attrs": [ + {}, + {} + ], + "properties": { + "order": 1, + "id": 2, + "prevSize": 32, + "code": 59694, + "name": "reset" + }, + "setIdx": 0, + "setId": 2, + "iconIdx": 0 + }, + { + "icon": { + "paths": [ + "M128 128h320v768h-320zM576 128h320v768h-320z" + ], + "attrs": [ + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "tags": [ + "pause", + "player" + ], + "grid": 16 + }, + "attrs": [ + {} + ], + "properties": { + "order": 2, + "id": 1, + "prevSize": 32, + "code": 59695, + "name": "pause" + }, + "setIdx": 0, + "setId": 2, + "iconIdx": 1 + }, + { + "icon": { + "paths": [ + "M192 128l640 384-640 384z" + ], + "attrs": [ + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "tags": [ + "play", + "player" + ], + "grid": 16 + }, + "attrs": [ + {} + ], + "properties": { + "order": 3, + "id": 0, + "prevSize": 32, + "code": 59696, + "name": "play" + }, + "setIdx": 0, + "setId": 2, + "iconIdx": 2 + }, { "icon": { "paths": [ @@ -32,8 +131,8 @@ "code": 59693, "name": "settings2" }, - "setIdx": 0, - "setId": 2, + "setIdx": 1, + "setId": 1, "iconIdx": 0 }, { @@ -69,8 +168,8 @@ "prevSize": 32, "code": 59650 }, - "setIdx": 0, - "setId": 2, + "setIdx": 1, + "setId": 1, "iconIdx": 1 }, { @@ -100,7 +199,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 0 + "iconIdx": 2 }, { "icon": { @@ -129,7 +228,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 1 + "iconIdx": 3 }, { "icon": { @@ -158,7 +257,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 2 + "iconIdx": 4 }, { "icon": { @@ -187,7 +286,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 3 + "iconIdx": 5 }, { "icon": { @@ -216,7 +315,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 4 + "iconIdx": 6 }, { "icon": { @@ -245,7 +344,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 5 + "iconIdx": 7 }, { "icon": { @@ -274,7 +373,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 6 + "iconIdx": 8 }, { "icon": { @@ -303,7 +402,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 7 + "iconIdx": 9 }, { "icon": { @@ -332,7 +431,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 8 + "iconIdx": 10 }, { "icon": { @@ -361,7 +460,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 9 + "iconIdx": 11 }, { "icon": { @@ -390,7 +489,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 10 + "iconIdx": 12 }, { "icon": { @@ -419,7 +518,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 11 + "iconIdx": 13 }, { "icon": { @@ -448,7 +547,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 12 + "iconIdx": 14 }, { "icon": { @@ -477,7 +576,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 13 + "iconIdx": 15 }, { "icon": { @@ -506,7 +605,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 14 + "iconIdx": 16 }, { "icon": { @@ -535,7 +634,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 15 + "iconIdx": 17 }, { "icon": { @@ -564,7 +663,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 16 + "iconIdx": 18 }, { "icon": { @@ -593,7 +692,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 17 + "iconIdx": 19 }, { "icon": { @@ -622,7 +721,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 18 + "iconIdx": 20 }, { "icon": { @@ -651,7 +750,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 19 + "iconIdx": 21 }, { "icon": { @@ -680,7 +779,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 20 + "iconIdx": 22 }, { "icon": { @@ -709,7 +808,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 21 + "iconIdx": 23 }, { "icon": { @@ -738,7 +837,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 22 + "iconIdx": 24 }, { "icon": { @@ -767,7 +866,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 23 + "iconIdx": 25 }, { "icon": { @@ -796,7 +895,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 24 + "iconIdx": 26 }, { "icon": { @@ -825,7 +924,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 25 + "iconIdx": 27 }, { "icon": { @@ -854,7 +953,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 26 + "iconIdx": 28 }, { "icon": { @@ -883,7 +982,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 27 + "iconIdx": 29 }, { "icon": { @@ -912,7 +1011,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 28 + "iconIdx": 30 }, { "icon": { @@ -941,7 +1040,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 29 + "iconIdx": 31 }, { "icon": { @@ -970,7 +1069,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 30 + "iconIdx": 32 }, { "icon": { @@ -999,7 +1098,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 31 + "iconIdx": 33 }, { "icon": { @@ -1028,7 +1127,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 32 + "iconIdx": 34 }, { "icon": { @@ -1057,7 +1156,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 33 + "iconIdx": 35 }, { "icon": { @@ -1086,7 +1185,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 34 + "iconIdx": 36 }, { "icon": { @@ -1115,7 +1214,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 35 + "iconIdx": 37 }, { "icon": { @@ -1144,7 +1243,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 36 + "iconIdx": 38 }, { "icon": { @@ -1174,7 +1273,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 37 + "iconIdx": 39 }, { "icon": { @@ -1204,7 +1303,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 38 + "iconIdx": 40 }, { "icon": { @@ -1234,7 +1333,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 39 + "iconIdx": 41 }, { "icon": { @@ -1264,7 +1363,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 40 + "iconIdx": 42 }, { "icon": { @@ -1294,7 +1393,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 41 + "iconIdx": 43 }, { "icon": { @@ -1324,7 +1423,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 42 + "iconIdx": 44 }, { "icon": { @@ -1354,7 +1453,7 @@ }, "setIdx": 1, "setId": 1, - "iconIdx": 43 + "iconIdx": 45 } ], "height": 1024, diff --git a/src/Squidex/app/theme/icomoon/style.css b/src/Squidex/app/theme/icomoon/style.css index ecec17eb7..ca438434f 100644 --- a/src/Squidex/app/theme/icomoon/style.css +++ b/src/Squidex/app/theme/icomoon/style.css @@ -1,10 +1,10 @@ @font-face { font-family: 'icomoon'; - src: url('fonts/icomoon.eot?yajqm9'); - src: url('fonts/icomoon.eot?yajqm9#iefix') format('embedded-opentype'), - url('fonts/icomoon.ttf?yajqm9') format('truetype'), - url('fonts/icomoon.woff?yajqm9') format('woff'), - url('fonts/icomoon.svg?yajqm9#icomoon') format('svg'); + src: url('fonts/icomoon.eot?oo54rp'); + src: url('fonts/icomoon.eot?oo54rp#iefix') format('embedded-opentype'), + url('fonts/icomoon.ttf?oo54rp') format('truetype'), + url('fonts/icomoon.woff?oo54rp') format('woff'), + url('fonts/icomoon.svg?oo54rp#icomoon') format('svg'); font-weight: normal; font-style: normal; } @@ -24,6 +24,15 @@ -moz-osx-font-smoothing: grayscale; } +.icon-reset:before { + content: "\e92e"; +} +.icon-pause:before { + content: "\e92f"; +} +.icon-play:before { + content: "\e930"; +} .icon-settings2:before { content: "\e92d"; } diff --git a/src/Squidex/wwwroot/index.html b/src/Squidex/wwwroot/index.html index ca9f277ba..0018360d2 100644 --- a/src/Squidex/wwwroot/index.html +++ b/src/Squidex/wwwroot/index.html @@ -11,6 +11,7 @@