diff --git a/Squidex.sln.DotSettings b/Squidex.sln.DotSettings index d044c8020..ff47dbd96 100644 --- a/Squidex.sln.DotSettings +++ b/Squidex.sln.DotSettings @@ -40,7 +40,7 @@ <?xml version="1.0" encoding="utf-16"?><Profile name="Typescript"><JsInsertSemicolon>True</JsInsertSemicolon><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><CorrectVariableKindsDescriptor>True</CorrectVariableKindsDescriptor><VariablesToInnerScopesDescriptor>True</VariablesToInnerScopesDescriptor><StringToTemplatesDescriptor>True</StringToTemplatesDescriptor><RemoveRedundantQualifiersTs>True</RemoveRedundantQualifiersTs><OptimizeImportsTs>True</OptimizeImportsTs></Profile> SingleQuoted - ========================================================================= + ========================================================================== $FILENAME$ Squidex Headless CMS ========================================================================== diff --git a/src/Squidex.Events/Apps/AppClientKeyCreated.cs b/src/Squidex.Events/Apps/AppClientKeyCreated.cs new file mode 100644 index 000000000..384e50ae7 --- /dev/null +++ b/src/Squidex.Events/Apps/AppClientKeyCreated.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// AppClientKeyCreated.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Squidex.Infrastructure; +using Squidex.Infrastructure.CQRS.Events; + +namespace Squidex.Events.Apps +{ + [TypeName("AppClientKeyCreated")] + public sealed class AppClientKeyCreated : IEvent + { + public string ClientKey { get; set; } + + public DateTime ExpiresUtc { get; set; } + } +} diff --git a/src/Squidex.Events/Apps/AppClientKeyRevoked.cs b/src/Squidex.Events/Apps/AppClientKeyRevoked.cs new file mode 100644 index 000000000..c3401aaef --- /dev/null +++ b/src/Squidex.Events/Apps/AppClientKeyRevoked.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// AppClientKeyRevoked.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Squidex.Infrastructure; +using Squidex.Infrastructure.CQRS.Events; + +namespace Squidex.Events.Apps +{ + [TypeName("AppClientKeyRevoked")] + public sealed class AppClientKeyRevoked : IEvent + { + public string ClientKey { get; set; } + + public DateTime ExpiresUtc { get; set; } + } +} diff --git a/src/Squidex.Events/Apps/AppLanguagesConfigured.cs b/src/Squidex.Events/Apps/AppLanguagesConfigured.cs new file mode 100644 index 000000000..43a0f4a86 --- /dev/null +++ b/src/Squidex.Events/Apps/AppLanguagesConfigured.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// AppLanguagesConfigured.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using Squidex.Infrastructure; +using Squidex.Infrastructure.CQRS.Events; + +namespace Squidex.Events.Apps +{ + [TypeName("AppLanguagesConfigured")] + public sealed class AppLanguagesConfigured : IEvent + { + public List Languages { get; set; } + } +} diff --git a/src/Squidex.Infrastructure/CQRS/Commands/EnrichWithTimestampHandler.cs b/src/Squidex.Infrastructure/CQRS/Commands/EnrichWithTimestampHandler.cs new file mode 100644 index 000000000..58a2131cf --- /dev/null +++ b/src/Squidex.Infrastructure/CQRS/Commands/EnrichWithTimestampHandler.cs @@ -0,0 +1,42 @@ +// ========================================================================== +// EnrichWithTimestampHandler.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Threading.Tasks; + +namespace Squidex.Infrastructure.CQRS.Commands +{ + public sealed class EnrichWithTimestampHandler : ICommandHandler + { + private readonly Func timestamp; + + public EnrichWithTimestampHandler() + : this(() => DateTime.UtcNow) + { + } + + public EnrichWithTimestampHandler(Func timestamp) + { + Guard.NotNull(timestamp, nameof(timestamp)); + + this.timestamp = timestamp; + } + + public Task HandleAsync(CommandContext context) + { + var timestampCommand = context.Command as ITimestampCommand; + + if (timestampCommand != null) + { + timestampCommand.Timestamp = timestamp(); + } + + return Task.FromResult(false); + } + } +} diff --git a/src/Squidex.Infrastructure/CQRS/Commands/ITimestampCommand.cs b/src/Squidex.Infrastructure/CQRS/Commands/ITimestampCommand.cs new file mode 100644 index 000000000..f241c5878 --- /dev/null +++ b/src/Squidex.Infrastructure/CQRS/Commands/ITimestampCommand.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// ITimestampCommand.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; + +namespace Squidex.Infrastructure.CQRS.Commands +{ + public interface ITimestampCommand : ICommand + { + DateTime Timestamp { get; set; } + } +} diff --git a/src/Squidex.Infrastructure/Json/LanguageConverter.cs b/src/Squidex.Infrastructure/Json/LanguageConverter.cs new file mode 100644 index 000000000..ddc9624fd --- /dev/null +++ b/src/Squidex.Infrastructure/Json/LanguageConverter.cs @@ -0,0 +1,40 @@ +// ========================================================================= +// LanguageConverter.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Newtonsoft.Json; + +namespace Squidex.Infrastructure.Json +{ + public sealed class LanguageConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var language = value as Language; + + if (language != null) + { + writer.WriteValue(language.Iso2Code); + } + else + { + writer.WriteNull(); + } + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + return reader.TokenType == JsonToken.Null ? null : Language.GetLanguage((string) reader.Value); + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(Language); + } + } +} diff --git a/src/Squidex.Read/Apps/IAppClientKeyEntity.cs b/src/Squidex.Read/Apps/IAppClientKeyEntity.cs new file mode 100644 index 000000000..5cfc0ecef --- /dev/null +++ b/src/Squidex.Read/Apps/IAppClientKeyEntity.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// IAppClientKeyEntity.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; + +namespace Squidex.Read.Apps +{ + public interface IAppClientKeyEntity + { + string ClientKey { get; } + + DateTime ExpiresUtc { get; } + } +} diff --git a/src/Squidex.Read/Apps/IAppEntity.cs b/src/Squidex.Read/Apps/IAppEntity.cs index 8107d6aa3..86c1235fb 100644 --- a/src/Squidex.Read/Apps/IAppEntity.cs +++ b/src/Squidex.Read/Apps/IAppEntity.cs @@ -7,6 +7,7 @@ // ========================================================================== using System.Collections.Generic; +using Squidex.Infrastructure; namespace Squidex.Read.Apps { @@ -14,6 +15,10 @@ namespace Squidex.Read.Apps { string Name { get; } + IEnumerable Languages { get; } + + IEnumerable ClientKeys { get; } + IEnumerable Contributors { get; } } } diff --git a/src/Squidex.Read/Apps/Services/Implementations/CachingAppProvider.cs b/src/Squidex.Read/Apps/Services/Implementations/CachingAppProvider.cs index 5a8d1df2e..a83a1b8ed 100644 --- a/src/Squidex.Read/Apps/Services/Implementations/CachingAppProvider.cs +++ b/src/Squidex.Read/Apps/Services/Implementations/CachingAppProvider.cs @@ -64,7 +64,11 @@ namespace Squidex.Read.Apps.Services.Implementations public Task On(Envelope @event) { - if (@event.Payload is AppContributorAssigned || @event.Payload is AppContributorRemoved) + if (@event.Payload is AppContributorAssigned || + @event.Payload is AppContributorRemoved || + @event.Payload is AppClientKeyCreated || + @event.Payload is AppClientKeyRevoked || + @event.Payload is AppLanguagesConfigured) { var appName = Cache.Get(BuildNamesCacheKey(@event.Headers.AggregateId())); diff --git a/src/Squidex.Read/Schemas/Services/Implementations/CachingSchemaProvider.cs b/src/Squidex.Read/Schemas/Services/Implementations/CachingSchemaProvider.cs index ad263387b..4a051d8c0 100644 --- a/src/Squidex.Read/Schemas/Services/Implementations/CachingSchemaProvider.cs +++ b/src/Squidex.Read/Schemas/Services/Implementations/CachingSchemaProvider.cs @@ -64,7 +64,8 @@ namespace Squidex.Read.Schemas.Services.Implementations public Task On(Envelope @event) { - if (@event.Payload is SchemaUpdated || @event.Payload is SchemaDeleted) + if (@event.Payload is SchemaUpdated || + @event.Payload is SchemaDeleted) { var oldName = Cache.Get(BuildNamesCacheKey(@event.Headers.AggregateId())); diff --git a/src/Squidex.Store.MongoDb/Apps/MongoAppClientKeyEntity.cs b/src/Squidex.Store.MongoDb/Apps/MongoAppClientKeyEntity.cs new file mode 100644 index 000000000..df5514508 --- /dev/null +++ b/src/Squidex.Store.MongoDb/Apps/MongoAppClientKeyEntity.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// MongoAppClientKeyEntity.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using MongoDB.Bson.Serialization.Attributes; +using Squidex.Read.Apps; + +namespace Squidex.Store.MongoDb.Apps +{ + public class MongoAppClientKeyEntity : IAppClientKeyEntity + { + [BsonRequired] + [BsonElement] + public string ClientKey { get; set; } + + [BsonRequired] + [BsonElement] + public DateTime ExpiresUtc { get; set; } + } +} diff --git a/src/Squidex.Store.MongoDb/Apps/MongoAppEntity.cs b/src/Squidex.Store.MongoDb/Apps/MongoAppEntity.cs index dc8ec084d..7926815fe 100644 --- a/src/Squidex.Store.MongoDb/Apps/MongoAppEntity.cs +++ b/src/Squidex.Store.MongoDb/Apps/MongoAppEntity.cs @@ -7,7 +7,9 @@ // ========================================================================== using System.Collections.Generic; +using System.Linq; using MongoDB.Bson.Serialization.Attributes; +using Squidex.Infrastructure; using Squidex.Read.Apps; using Squidex.Store.MongoDb.Utils; @@ -19,10 +21,28 @@ namespace Squidex.Store.MongoDb.Apps [BsonElement] public string Name { get; set; } + [BsonRequired] + [BsonElement] + public List Languages { get; set; } + + [BsonRequired] + [BsonElement] + public List ClientKeys { get; set; } + [BsonRequired] [BsonElement] public List Contributors { get; set; } + IEnumerable IAppEntity.Languages + { + get { return Languages.Select(Language.GetLanguage); } + } + + IEnumerable IAppEntity.ClientKeys + { + get { return ClientKeys; } + } + IEnumerable IAppEntity.Contributors { get { return Contributors; } @@ -31,6 +51,8 @@ namespace Squidex.Store.MongoDb.Apps public MongoAppEntity() { Contributors = new List(); + + ClientKeys = new List(); } } } diff --git a/src/Squidex.Store.MongoDb/Apps/MongoAppRepository.cs b/src/Squidex.Store.MongoDb/Apps/MongoAppRepository.cs index d4b049419..24b634fa1 100644 --- a/src/Squidex.Store.MongoDb/Apps/MongoAppRepository.cs +++ b/src/Squidex.Store.MongoDb/Apps/MongoAppRepository.cs @@ -64,6 +64,21 @@ namespace Squidex.Store.MongoDb.Apps return Collection.UpdateAsync(headers, a => a.Contributors.RemoveAll(c => c.ContributorId == @event.ContributorId)); } + public Task On(AppLanguagesConfigured @event, EnvelopeHeaders headers) + { + return Collection.UpdateAsync(headers, a => a.Languages = @event.Languages.Select(x => x.Iso2Code).ToList()); + } + + public Task On(AppClientKeyCreated @event, EnvelopeHeaders headers) + { + return Collection.UpdateAsync(headers, a => a.ClientKeys.Add(SimpleMapper.Map(@event, new MongoAppClientKeyEntity()))); + } + + public Task On(AppClientKeyRevoked @event, EnvelopeHeaders headers) + { + return Collection.UpdateAsync(headers, a => a.ClientKeys.RemoveAll(c => c.ClientKey == @event.ClientKey)); + } + public Task On(AppContributorAssigned @event, EnvelopeHeaders headers) { return Collection.UpdateAsync(headers, a => diff --git a/src/Squidex.Write/Apps/AppCommandHandler.cs b/src/Squidex.Write/Apps/AppCommandHandler.cs index 049f7e9f1..77aa16d49 100644 --- a/src/Squidex.Write/Apps/AppCommandHandler.cs +++ b/src/Squidex.Write/Apps/AppCommandHandler.cs @@ -70,6 +70,21 @@ namespace Squidex.Write.Apps return UpdateAsync(command, x => x.RemoveContributor(command)); } + public Task On(CreateClientKey command) + { + return UpdateAsync(command, x => x.CreateClientKey(command)); + } + + public Task On(RevokeClientKey command) + { + return UpdateAsync(command, x => x.RevokeClientKey(command)); + } + + public Task On(ConfigureLanguages command) + { + return UpdateAsync(command, x => x.ConfigureLanguages(command)); + } + public override Task HandleAsync(CommandContext context) { return context.IsHandled ? Task.FromResult(false) : this.DispatchActionAsync(context.Command); diff --git a/src/Squidex.Write/Apps/AppDomainObject.cs b/src/Squidex.Write/Apps/AppDomainObject.cs index fa34d9f15..518a9c9a0 100644 --- a/src/Squidex.Write/Apps/AppDomainObject.cs +++ b/src/Squidex.Write/Apps/AppDomainObject.cs @@ -16,6 +16,7 @@ using Squidex.Infrastructure.Dispatching; using Squidex.Write.Apps.Commands; using System.Collections.Generic; using System.Linq; +using Squidex.Infrastructure.CQRS.Commands; using Squidex.Infrastructure.Reflection; // ReSharper disable InvertIf @@ -23,6 +24,8 @@ namespace Squidex.Write.Apps { public sealed class AppDomainObject : DomainObject { + private static readonly List DefaultLanguages = new List { Language.GetLanguage("en") }; + private readonly HashSet clientKeys = new HashSet(StringComparer.OrdinalIgnoreCase); private readonly Dictionary contributors = new Dictionary(); private string name; @@ -36,7 +39,8 @@ namespace Squidex.Write.Apps get { return contributors; } } - public AppDomainObject(Guid id, int version) : base(id, version) + public AppDomainObject(Guid id, int version) + : base(id, version) { } @@ -55,6 +59,16 @@ namespace Squidex.Write.Apps contributors.Remove(@event.ContributorId); } + public void On(AppClientKeyCreated @event) + { + clientKeys.Add(@event.ClientKey); + } + + public void On(AppClientKeyRevoked @event) + { + clientKeys.Remove(@event.ClientKey); + } + protected override void DispatchEvent(Envelope @event) { this.DispatchAction(@event.Payload); @@ -67,7 +81,9 @@ namespace Squidex.Write.Apps VerifyNotCreated(); RaiseEvent(SimpleMapper.Map(command, new AppCreated())); - RaiseEvent(SimpleMapper.Map(command, new AppContributorAssigned { ContributorId = command.SubjectId, Permission = PermissionLevel.Owner })); + + RaiseEvent(CreateInitialOwner(command)); + RaiseEvent(CreateInitialLanguage()); return this; } @@ -77,7 +93,7 @@ namespace Squidex.Write.Apps Guard.Valid(command, nameof(command), () => "Cannot assign contributor"); VerifyCreated(); - VerifyHasStillOwner(c => c[command.ContributorId] = command.Permission); + VerifyOwnership(c => c[command.ContributorId] = command.Permission); RaiseEvent(SimpleMapper.Map(command, new AppContributorAssigned())); @@ -90,13 +106,57 @@ namespace Squidex.Write.Apps VerifyCreated(); VerifyContributorFound(command); - VerifyHasStillOwner(c => c.Remove(command.ContributorId)); + VerifyOwnership(c => c.Remove(command.ContributorId)); RaiseEvent(SimpleMapper.Map(command, new AppContributorRemoved())); return this; } + public AppDomainObject ConfigureLanguages(ConfigureLanguages command) + { + Guard.Valid(command, nameof(command), () => "Cannot remove contributor"); + + VerifyCreated(); + + RaiseEvent(SimpleMapper.Map(command, new AppLanguagesConfigured())); + + return this; + } + + public AppDomainObject RevokeClientKey(RevokeClientKey command) + { + Guard.Valid(command, nameof(command), () => "Cannot revoke client key"); + + VerifyCreated(); + VerifyClientKeyFound(command); + + RaiseEvent(SimpleMapper.Map(command, new AppClientKeyRevoked())); + + return this; + } + + public AppDomainObject CreateClientKey(CreateClientKey command) + { + Guard.Valid(command, nameof(command), () => "Cannot create client key"); + + VerifyCreated(); + + RaiseEvent(SimpleMapper.Map(command, new AppClientKeyCreated())); + + return this; + } + + private static AppLanguagesConfigured CreateInitialLanguage() + { + return new AppLanguagesConfigured { Languages = DefaultLanguages }; + } + + private static AppContributorAssigned CreateInitialOwner(ISubjectCommand command) + { + return new AppContributorAssigned { ContributorId = command.SubjectId, Permission = PermissionLevel.Owner }; + } + private void VerifyCreated() { if (string.IsNullOrWhiteSpace(name)) @@ -113,6 +173,16 @@ namespace Squidex.Write.Apps } } + private void VerifyClientKeyFound(RevokeClientKey command) + { + if (!clientKeys.Contains(command.ClientKey)) + { + var error = new ValidationError("Client key is not part of the app", "ClientKey"); + + throw new ValidationException("Cannot revoke client key", error); + } + } + private void VerifyContributorFound(RemoveContributor command) { if (!contributors.ContainsKey(command.ContributorId)) @@ -123,7 +193,7 @@ namespace Squidex.Write.Apps } } - private void VerifyHasStillOwner(Action> change) + private void VerifyOwnership(Action> change) { var contributorsCopy = new Dictionary(contributors); diff --git a/src/Squidex.Write/Apps/ClientKeyGenerator.cs b/src/Squidex.Write/Apps/ClientKeyGenerator.cs new file mode 100644 index 000000000..a3c41a8b3 --- /dev/null +++ b/src/Squidex.Write/Apps/ClientKeyGenerator.cs @@ -0,0 +1,33 @@ +// ========================================================================= +// ClientKeyGenerator.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Security.Cryptography; +using System.Text; + +namespace Squidex.Write.Apps +{ + public class ClientKeyGenerator + { + public virtual string GenerateKey() + { + return Sha256(Guid.NewGuid().ToString()); + } + + private static string Sha256(string input) + { + using (var sha = SHA256.Create()) + { + var bytes = Encoding.UTF8.GetBytes(input); + var hash = sha.ComputeHash(bytes); + + return Convert.ToBase64String(hash); + } + } + } +} diff --git a/src/Squidex.Write/Apps/Commands/ConfigureLanguages.cs b/src/Squidex.Write/Apps/Commands/ConfigureLanguages.cs new file mode 100644 index 000000000..7552db122 --- /dev/null +++ b/src/Squidex.Write/Apps/Commands/ConfigureLanguages.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// ConfigureLanguages.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using Squidex.Infrastructure; + +namespace Squidex.Write.Apps.Commands +{ + public sealed class ConfigureLanguages : AppAggregateCommand, IValidatable + { + public List Languages { get; set; } + + public void Validate(IList errors) + { + if (Languages == null || Languages.Count == 0) + { + errors.Add(new ValidationError("Languages need at least one element.", nameof(Languages))); + } + } + } +} diff --git a/src/Squidex.Write/Apps/Commands/CreateClientKey.cs b/src/Squidex.Write/Apps/Commands/CreateClientKey.cs new file mode 100644 index 000000000..18aba6022 --- /dev/null +++ b/src/Squidex.Write/Apps/Commands/CreateClientKey.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// CreateClientKey.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using Squidex.Infrastructure; +using Squidex.Infrastructure.CQRS.Commands; + +namespace Squidex.Write.Apps.Commands +{ + public sealed class CreateClientKey : AppAggregateCommand, ITimestampCommand, IValidatable + { + public string ClientKey { get; set; } + + public DateTime Timestamp { get; set; } + + public void Validate(IList errors) + { + if (string.IsNullOrWhiteSpace(ClientKey)) + { + errors.Add(new ValidationError("Client key is not assigned", nameof(ClientKey))); + } + } + } +} diff --git a/src/Squidex.Write/Apps/Commands/RevokeClientKey.cs b/src/Squidex.Write/Apps/Commands/RevokeClientKey.cs new file mode 100644 index 000000000..2beef0d9e --- /dev/null +++ b/src/Squidex.Write/Apps/Commands/RevokeClientKey.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// RevokeClientKey.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using Squidex.Infrastructure; + +namespace Squidex.Write.Apps.Commands +{ + public class RevokeClientKey : AppAggregateCommand, IValidatable + { + public string ClientKey { get; set; } + + public void Validate(IList errors) + { + if (string.IsNullOrWhiteSpace(ClientKey)) + { + errors.Add(new ValidationError("Client key is not assigned", nameof(ClientKey))); + } + } + } +} diff --git a/src/Squidex/Configurations/Domain/Serializers.cs b/src/Squidex/Configurations/Domain/Serializers.cs index 4518b4ea4..ee6bbc5ad 100644 --- a/src/Squidex/Configurations/Domain/Serializers.cs +++ b/src/Squidex/Configurations/Domain/Serializers.cs @@ -24,6 +24,7 @@ namespace Squidex.Configurations.Domain { settings.SerializationBinder = new TypeNameSerializationBinder(); settings.ContractResolver = new CamelCasePropertyNamesContractResolver(); + settings.Converters.Add(new LanguageConverter()); settings.Converters.Add(new PropertiesBagConverter()); settings.NullValueHandling = NullValueHandling.Ignore; settings.DateFormatHandling = DateFormatHandling.IsoDateFormat; diff --git a/src/Squidex/Configurations/Domain/WriteModule.cs b/src/Squidex/Configurations/Domain/WriteModule.cs index 659bfe5f3..4503b7995 100644 --- a/src/Squidex/Configurations/Domain/WriteModule.cs +++ b/src/Squidex/Configurations/Domain/WriteModule.cs @@ -30,10 +30,18 @@ namespace Squidex.Configurations.Domain .As() .SingleInstance(); + builder.RegisterType() + .As() + .SingleInstance(); + builder.RegisterType() .As() .SingleInstance(); + builder.RegisterType() + .AsSelf() + .InstancePerDependency(); + builder.RegisterType() .AsSelf() .InstancePerDependency(); diff --git a/src/Squidex/Configurations/Identity/LazyClientStore.cs b/src/Squidex/Configurations/Identity/LazyClientStore.cs index bf63b5843..03fe61768 100644 --- a/src/Squidex/Configurations/Identity/LazyClientStore.cs +++ b/src/Squidex/Configurations/Identity/LazyClientStore.cs @@ -8,36 +8,78 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using IdentityServer4.Models; using IdentityServer4.Stores; using Microsoft.Extensions.Options; using Squidex.Infrastructure; +using Squidex.Read.Apps; +using Squidex.Read.Apps.Services; namespace Squidex.Configurations.Identity { public class LazyClientStore : IClientStore { - private readonly Dictionary clients = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly IAppProvider appProvider; + private readonly Dictionary staticClients = new Dictionary(StringComparer.OrdinalIgnoreCase); - public LazyClientStore(IOptions identityOptions) + public LazyClientStore(IOptions identityOptions, IAppProvider appProvider) { Guard.NotNull(identityOptions, nameof(identityOptions)); + Guard.NotNull(appProvider, nameof(appProvider)); - foreach (var client in CreateClients(identityOptions.Value)) + this.appProvider = appProvider; + + CreateStaticClients(identityOptions); + } + + public async Task FindClientByIdAsync(string clientId) + { + var client = staticClients.GetOrDefault(clientId); + + if (client == null) { - clients[client.ClientId] = client; + return null; } + + var app = await appProvider.FindAppByNameAsync(clientId); + + if (app != null) + { + client = CreateClientFromApp(app); + } + + return client; } - public Task FindClientByIdAsync(string clientId) + private void CreateStaticClients(IOptions identityOptions) { - var client = clients.GetOrDefault(clientId); + foreach (var client in CreateStaticClients(identityOptions.Value)) + { + staticClients[client.ClientId] = client; + } + } - return Task.FromResult(client); + private static Client CreateClientFromApp(IAppEntity app) + { + var id = app.Name; + + return new Client + { + ClientId = id, + ClientName = id, + ClientSecrets = app.ClientKeys.Select(x => new Secret(x.ClientKey, x.ExpiresUtc)).ToList(), + AccessTokenLifetime = (int)TimeSpan.FromDays(30).TotalSeconds, + AllowedGrantTypes = GrantTypes.ClientCredentials, + AllowedScopes = new List + { + Constants.ApiScope + } + }; } - private static IEnumerable CreateClients(MyIdentityOptions options) + private static IEnumerable CreateStaticClients(MyIdentityOptions options) { const string id = Constants.FrontendClient; diff --git a/src/Squidex/Modules/Api/Apps/AppClientKeysController.cs b/src/Squidex/Modules/Api/Apps/AppClientKeysController.cs new file mode 100644 index 000000000..a0a22dc3a --- /dev/null +++ b/src/Squidex/Modules/Api/Apps/AppClientKeysController.cs @@ -0,0 +1,65 @@ +// ========================================================================== +// AppClientKeysController.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 Squidex.Infrastructure.CQRS.Commands; +using Squidex.Infrastructure.Reflection; +using Squidex.Modules.Api.Apps.Models; +using Squidex.Pipeline; +using Squidex.Read.Apps.Services; +using Squidex.Write.Apps; +using Squidex.Write.Apps.Commands; + +namespace Squidex.Modules.Api.Apps +{ + [Authorize(Roles = "app-owner")] + [ApiExceptionFilter] + [ServiceFilter(typeof(AppFilterAttribute))] + public class AppClientKeysController : ControllerBase + { + private readonly IAppProvider appProvider; + private readonly ClientKeyGenerator keyGenerator; + + public AppClientKeysController(ICommandBus commandBus, IAppProvider appProvider, ClientKeyGenerator keyGenerator) + : base(commandBus) + { + this.appProvider = appProvider; + this.keyGenerator = keyGenerator; + } + + [HttpGet] + [Route("apps/{app}/client-keys/")] + public async Task GetContributors(string app) + { + var entity = await appProvider.FindAppByNameAsync(app); + + if (entity == null) + { + return NotFound(); + } + + var model = entity.ClientKeys.Select(x => SimpleMapper.Map(x, new ClientKeyDto())).ToList(); + + return Ok(model); + } + + [HttpPost] + [Route("apps/{app}/client-keys/")] + public async Task PostClientKey() + { + var clientKey = keyGenerator.GenerateKey(); + + await CommandBus.PublishAsync(new CreateClientKey { ClientKey = clientKey }); + + return Ok(new ClientKeyCreatedDto { ClientKey = clientKey }); + } + } +} diff --git a/src/Squidex/Modules/Api/Apps/AppContributorsController.cs b/src/Squidex/Modules/Api/Apps/AppContributorsController.cs index 3112e1dfa..512f180ea 100644 --- a/src/Squidex/Modules/Api/Apps/AppContributorsController.cs +++ b/src/Squidex/Modules/Api/Apps/AppContributorsController.cs @@ -15,11 +15,12 @@ using Squidex.Infrastructure.Reflection; using Squidex.Modules.Api.Apps.Models; using Squidex.Pipeline; using Squidex.Read.Apps.Services; +using Squidex.Write.Apps; using Squidex.Write.Apps.Commands; namespace Squidex.Modules.Api.Apps { - [Authorize] + [Authorize(Roles = "app-owner")] [ApiExceptionFilter] [ServiceFilter(typeof(AppFilterAttribute))] public class AppContributorsController : ControllerBase @@ -43,7 +44,7 @@ namespace Squidex.Modules.Api.Apps return NotFound(); } - var model = entity.Contributors.Select(x => SimpleMapper.Map(x, new AppContributorDto())).ToList(); + var model = entity.Contributors.Select(x => SimpleMapper.Map(x, new ContributorDto())).ToList(); return Ok(model); } diff --git a/src/Squidex/Modules/Api/Apps/AppLanguagesController.cs b/src/Squidex/Modules/Api/Apps/AppLanguagesController.cs new file mode 100644 index 000000000..53e67114d --- /dev/null +++ b/src/Squidex/Modules/Api/Apps/AppLanguagesController.cs @@ -0,0 +1,60 @@ +// ========================================================================== +// AppLanguagesController.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 Squidex.Infrastructure.CQRS.Commands; +using Squidex.Infrastructure.Reflection; +using Squidex.Modules.Api.Apps.Models; +using Squidex.Pipeline; +using Squidex.Read.Apps.Services; +using Squidex.Write.Apps.Commands; + +namespace Squidex.Modules.Api.Apps +{ + [Authorize(Roles = "app-owner")] + [ApiExceptionFilter] + [ServiceFilter(typeof(AppFilterAttribute))] + public class AppLanguagesController : ControllerBase + { + private readonly IAppProvider appProvider; + + public AppLanguagesController(ICommandBus commandBus, IAppProvider appProvider) + : base(commandBus) + { + this.appProvider = appProvider; + } + + [HttpGet] + [Route("apps/{app}/languages/")] + public async Task GetContributors(string app) + { + var entity = await appProvider.FindAppByNameAsync(app); + + if (entity == null) + { + return NotFound(); + } + + var model = entity.Languages.Select(x => SimpleMapper.Map(x, new LanguageDto())).ToList(); + + return Ok(model); + } + + [HttpPost] + [Route("apps/{app}/languages/")] + public async Task PostLanguages([FromBody] ConfigureLanguagesDto model) + { + await CommandBus.PublishAsync(SimpleMapper.Map(model, new ConfigureLanguages())); + + return Ok(); + } + } +} diff --git a/src/Squidex/Modules/Api/Apps/Models/AssignContributorDto.cs b/src/Squidex/Modules/Api/Apps/Models/AssignContributorDto.cs index 7f0778603..830d5cd59 100644 --- a/src/Squidex/Modules/Api/Apps/Models/AssignContributorDto.cs +++ b/src/Squidex/Modules/Api/Apps/Models/AssignContributorDto.cs @@ -1,5 +1,5 @@ // ========================================================================== -// PutContributorDto.cs +// AssignContributorDto.cs // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex Group diff --git a/src/Squidex/Modules/Api/Apps/Models/ClientKeyCreatedDto.cs b/src/Squidex/Modules/Api/Apps/Models/ClientKeyCreatedDto.cs new file mode 100644 index 000000000..0773a429d --- /dev/null +++ b/src/Squidex/Modules/Api/Apps/Models/ClientKeyCreatedDto.cs @@ -0,0 +1,15 @@ +// ========================================================================== +// ClientKeyCreatedDto.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Modules.Api.Apps.Models +{ + public sealed class ClientKeyCreatedDto + { + public string ClientKey { get; set; } + } +} diff --git a/src/Squidex/Modules/Api/Apps/Models/ClientKeyDto.cs b/src/Squidex/Modules/Api/Apps/Models/ClientKeyDto.cs new file mode 100644 index 000000000..2fcca7be5 --- /dev/null +++ b/src/Squidex/Modules/Api/Apps/Models/ClientKeyDto.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// ClientKeyDto.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; + +namespace Squidex.Modules.Api.Apps.Models +{ + public sealed class ClientKeyDto + { + public string ClientKey { get; set; } + + public DateTime ExpiresUtc { get; set; } + } +} diff --git a/src/Squidex/Modules/Api/Apps/Models/ConfigureLanguagesDto.cs b/src/Squidex/Modules/Api/Apps/Models/ConfigureLanguagesDto.cs new file mode 100644 index 000000000..bbc60e177 --- /dev/null +++ b/src/Squidex/Modules/Api/Apps/Models/ConfigureLanguagesDto.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// ConfigureLanguagesDto.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using Squidex.Infrastructure; + +namespace Squidex.Modules.Api.Apps.Models +{ + public class ConfigureLanguagesDto + { + public List Languages { get; set; } + } +} diff --git a/src/Squidex/Modules/Api/Apps/Models/AppContributorDto.cs b/src/Squidex/Modules/Api/Apps/Models/ContributorDto.cs similarity index 87% rename from src/Squidex/Modules/Api/Apps/Models/AppContributorDto.cs rename to src/Squidex/Modules/Api/Apps/Models/ContributorDto.cs index 6ccaa9044..5daaf14c0 100644 --- a/src/Squidex/Modules/Api/Apps/Models/AppContributorDto.cs +++ b/src/Squidex/Modules/Api/Apps/Models/ContributorDto.cs @@ -1,5 +1,5 @@ -// ========================================================================= -// AppContributorDto.cs +// ========================================================================== +// ContributorDto.cs // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex Group @@ -12,7 +12,7 @@ using Squidex.Core.Apps; namespace Squidex.Modules.Api.Apps.Models { - public sealed class AppContributorDto + public sealed class ContributorDto { public string ContributorId { get; set; } diff --git a/src/Squidex/Modules/Api/Apps/Models/CreateAppDto.cs b/src/Squidex/Modules/Api/Apps/Models/CreateAppDto.cs index 7e4641e9b..6d37ea313 100644 --- a/src/Squidex/Modules/Api/Apps/Models/CreateAppDto.cs +++ b/src/Squidex/Modules/Api/Apps/Models/CreateAppDto.cs @@ -5,6 +5,7 @@ // Copyright (c) Squidex Group // All rights reserved. // ========================================================================== + namespace Squidex.Modules.Api.Apps.Models { public sealed class CreateAppDto diff --git a/src/Squidex/Modules/Api/LanguageDto.cs b/src/Squidex/Modules/Api/LanguageDto.cs new file mode 100644 index 000000000..6a6304e3a --- /dev/null +++ b/src/Squidex/Modules/Api/LanguageDto.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// LanguageDto.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Modules.Api +{ + public class LanguageDto + { + public string Iso2Code { get; set; } + + public string EnglishName { get; set; } + } +} diff --git a/src/Squidex/Modules/Api/Languages/LanguagesController.cs b/src/Squidex/Modules/Api/Languages/LanguagesController.cs index 6ded38a65..76ab0fcce 100644 --- a/src/Squidex/Modules/Api/Languages/LanguagesController.cs +++ b/src/Squidex/Modules/Api/Languages/LanguagesController.cs @@ -10,6 +10,7 @@ using System.Linq; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Squidex.Infrastructure; +using Squidex.Infrastructure.Reflection; using Squidex.Pipeline; namespace Squidex.Modules.Api.Languages @@ -22,7 +23,7 @@ namespace Squidex.Modules.Api.Languages [Route("languages/")] public IActionResult GetLanguages() { - var model = Language.AllLanguages.ToList(); + var model = Language.AllLanguages.Select(x => SimpleMapper.Map(x, new LanguageDto())).ToList(); return Ok(model); } diff --git a/src/Squidex/Pipeline/AppFilterAttribute.cs b/src/Squidex/Pipeline/AppFilterAttribute.cs index 48eb43956..486146f53 100644 --- a/src/Squidex/Pipeline/AppFilterAttribute.cs +++ b/src/Squidex/Pipeline/AppFilterAttribute.cs @@ -6,7 +6,9 @@ // All rights reserved. // ========================================================================== +using System; using System.Linq; +using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; @@ -15,7 +17,7 @@ using Squidex.Read.Apps.Services; namespace Squidex.Pipeline { - public sealed class AppFilterAttribute : ActionFilterAttribute + public sealed class AppFilterAttribute : Attribute, IAsyncAuthorizationFilter { private readonly IAppProvider appProvider; @@ -24,7 +26,7 @@ namespace Squidex.Pipeline this.appProvider = appProvider; } - public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + public async Task OnAuthorizationAsync(AuthorizationFilterContext context) { var appName = context.RouteData.Values["app"]?.ToString(); @@ -40,16 +42,30 @@ namespace Squidex.Pipeline var subject = context.HttpContext.User.FindFirst(OpenIdClaims.Subject)?.Value; - if (subject == null || app.Contributors.All(x => x.ContributorId != subject)) + if (subject == null) { context.Result = new NotFoundResult(); return; } + var contributor = app.Contributors.FirstOrDefault(x => string.Equals(x.ContributorId, subject, StringComparison.OrdinalIgnoreCase)); + + if (contributor == null) + { + context.Result = new NotFoundResult(); + return; + } + + var roleName = $"app-{contributor.Permission.ToString().ToLowerInvariant()}"; + + var defaultIdentity = context.HttpContext.User.Identities.First(); + + defaultIdentity + .AddClaim( + new Claim(defaultIdentity.RoleClaimType, roleName)); + context.HttpContext.Features.Set(new AppFeature(app)); } - - await next(); } } } diff --git a/src/Squidex/app/app.module.ts b/src/Squidex/app/app.module.ts index 96b804393..9bf1f292b 100644 --- a/src/Squidex/app/app.module.ts +++ b/src/Squidex/app/app.module.ts @@ -10,9 +10,13 @@ import * as Ng2Browser from '@angular/platform-browser'; import { AppComponent } from './app.component'; +import { DndModule } from 'ng2-dnd'; + import { ApiUrlConfig, + AppClientKeysService, AppContributorsService, + AppLanguagesService, AppMustExistGuard, AppsStoreService, AppsService, @@ -45,6 +49,7 @@ const baseUrl = window.location.protocol + '//' + window.location.host + '/'; @Ng2.NgModule({ imports: [ + DndModule.forRoot(), Ng2Browser.BrowserModule, SqxAppModule, SqxAuthModule, @@ -57,7 +62,9 @@ const baseUrl = window.location.protocol + '//' + window.location.host + '/'; AppComponent ], providers: [ + AppClientKeysService, AppContributorsService, + AppLanguagesService, AppsStoreService, AppsService, AppMustExistGuard, diff --git a/src/Squidex/app/components/internal/app/settings/credentials-page.component.html b/src/Squidex/app/components/internal/app/settings/credentials-page.component.html index aecc7d4da..2e869ee8f 100644 --- a/src/Squidex/app/components/internal/app/settings/credentials-page.component.html +++ b/src/Squidex/app/components/internal/app/settings/credentials-page.component.html @@ -9,6 +9,32 @@ + + + + + + + + + + + + + + + Create Token + + + Revoke + + + + + + \ No newline at end of file diff --git a/src/Squidex/app/components/internal/app/settings/credentials-page.component.scss b/src/Squidex/app/components/internal/app/settings/credentials-page.component.scss index eeb2214eb..e4d075ecc 100644 --- a/src/Squidex/app/components/internal/app/settings/credentials-page.component.scss +++ b/src/Squidex/app/components/internal/app/settings/credentials-page.component.scss @@ -3,4 +3,15 @@ .layout-title-icon { color: $color-section-settings; +} + +.card { + & { + max-width: 700px; + } + + &-block { + padding-left: .5rem; + padding-right: .5rem; + } } \ No newline at end of file diff --git a/src/Squidex/app/components/internal/app/settings/credentials-page.component.ts b/src/Squidex/app/components/internal/app/settings/credentials-page.component.ts index 2c1fb478b..1c5087c17 100644 --- a/src/Squidex/app/components/internal/app/settings/credentials-page.component.ts +++ b/src/Squidex/app/components/internal/app/settings/credentials-page.component.ts @@ -7,7 +7,12 @@ import * as Ng2 from '@angular/core'; -import { AppsStoreService, TitleService } from 'shared'; +import { + AppsStoreService, + AppClientKeyDto, + AppClientKeysService, + TitleService +} from 'shared'; @Ng2.Component({ selector: 'sqx-credentials-page', @@ -16,10 +21,14 @@ import { AppsStoreService, TitleService } from 'shared'; }) export class CredentialsPageComponent implements Ng2.OnInit { private appSubscription: any | null = null; + private appName: string | null = null; + + public appClientKeys: AppClientKeyDto[] = []; constructor( private readonly titles: TitleService, - private readonly appsStore: AppsStoreService + private readonly appsStore: AppsStoreService, + private readonly appClientKeysService: AppClientKeysService ) { } @@ -27,7 +36,13 @@ export class CredentialsPageComponent implements Ng2.OnInit { this.appSubscription = this.appsStore.selectedApp.subscribe(app => { if (app) { + this.appName = app.name; + this.titles.setTitle('{appName} | Settings | Credentials', { appName: app.name }); + + this.appClientKeysService.getClientKeys(app.name).subscribe(clientKeys => { + this.appClientKeys = clientKeys; + }); } }); } @@ -35,5 +50,11 @@ export class CredentialsPageComponent implements Ng2.OnInit { public ngOnDestroy() { this.appSubscription.unsubscribe(); } + + public createClientKey() { + this.appClientKeysService.postClientKey(this.appName).subscribe(clientKey => { + this.appClientKeys.push(clientKey); + }) + } } diff --git a/src/Squidex/app/components/internal/app/settings/languages-page.component.html b/src/Squidex/app/components/internal/app/settings/languages-page.component.html index 5c4a27b87..edf126732 100644 --- a/src/Squidex/app/components/internal/app/settings/languages-page.component.html +++ b/src/Squidex/app/components/internal/app/settings/languages-page.component.html @@ -9,6 +9,46 @@ + + + + + + + + + + + + + {{language.iso2Code}} + + + + + {{language.englishName}} + + + + Remove + + + + + {{isSaving ? 'Saving...' : 'Save'}} + + + \ No newline at end of file diff --git a/src/Squidex/app/components/internal/app/settings/languages-page.component.scss b/src/Squidex/app/components/internal/app/settings/languages-page.component.scss index eeb2214eb..64f0bb2d3 100644 --- a/src/Squidex/app/components/internal/app/settings/languages-page.component.scss +++ b/src/Squidex/app/components/internal/app/settings/languages-page.component.scss @@ -3,4 +3,18 @@ .layout-title-icon { color: $color-section-settings; +} + +.card { + max-width: 700px; +} + +.language { + &-select { + max-width: 200px; + } + + &-name { + @include truncate; + } } \ No newline at end of file diff --git a/src/Squidex/app/components/internal/app/settings/languages-page.component.ts b/src/Squidex/app/components/internal/app/settings/languages-page.component.ts index 9166c8161..400560bb0 100644 --- a/src/Squidex/app/components/internal/app/settings/languages-page.component.ts +++ b/src/Squidex/app/components/internal/app/settings/languages-page.component.ts @@ -7,7 +7,13 @@ import * as Ng2 from '@angular/core'; -import { AppsStoreService, TitleService } from 'shared'; +import { + AppLanguagesService, + AppsStoreService, + LanguageDto, + LanguageService, + TitleService +} from 'shared'; @Ng2.Component({ selector: 'sqx-languages-page', @@ -16,18 +22,41 @@ import { AppsStoreService, TitleService } from 'shared'; }) export class LanguagesPageComponent implements Ng2.OnInit { private appSubscription: any | null = null; + private appName: string; + + public allLanguages: LanguageDto[] = null; + public appLanguages: LanguageDto[] = []; + public selectedLanguage: LanguageDto | null = null; + + public isSaving: boolean; + + public get newLanguages() { + return this.allLanguages.filter(x => !this.appLanguages.find(l => l.iso2Code === x.iso2Code)); + } constructor( private readonly titles: TitleService, - private readonly appsStore: AppsStoreService + private readonly appsStore: AppsStoreService, + private readonly appLanguagesService: AppLanguagesService, + private readonly languagesService: LanguageService ) { } public ngOnInit() { + this.languagesService.getLanguages().subscribe(languages => { + this.allLanguages = languages; + }); + this.appSubscription = this.appsStore.selectedApp.subscribe(app => { if (app) { + this.appName = app.name; + this.titles.setTitle('{appName} | Settings | Languages', { appName: app.name }); + + this.appLanguagesService.getLanguages(app.name).subscribe(appLanguages => { + this.appLanguages = appLanguages; + }); } }); } @@ -35,5 +64,26 @@ export class LanguagesPageComponent implements Ng2.OnInit { public ngOnDestroy() { this.appSubscription.unsubscribe(); } + + public removeLanguage(language: LanguageDto) { + this.appLanguages.splice(this.appLanguages.indexOf(language), 1); + } + + public addLanguage() { + this.appLanguages.push(this.selectedLanguage); + + this.selectedLanguage = null; + } + + public saveLanguages() { + this.isSaving = true; + + this.appLanguagesService.postLanguages(this.appName, this.appLanguages.map(l => l.iso2Code)) + .delay(500) + .finally(() => { + this.isSaving = false; + }) + .subscribe(); + } } diff --git a/src/Squidex/app/components/internal/module.ts b/src/Squidex/app/components/internal/module.ts index 59e1026e4..49e430063 100644 --- a/src/Squidex/app/components/internal/module.ts +++ b/src/Squidex/app/components/internal/module.ts @@ -9,6 +9,8 @@ import * as Ng2 from '@angular/core'; import { Ng2CompleterModule } from 'ng2-completer'; +import { DndModule } from 'ng2-dnd'; + import { SqxFrameworkModule } from 'shared'; import { SqxLayoutModule } from 'components/layout'; @@ -26,6 +28,7 @@ import { @Ng2.NgModule({ imports: [ + DndModule, Ng2CompleterModule, SqxFrameworkModule, SqxLayoutModule diff --git a/src/Squidex/app/shared/index.ts b/src/Squidex/app/shared/index.ts index c3012d9d0..3c469e6f7 100644 --- a/src/Squidex/app/shared/index.ts +++ b/src/Squidex/app/shared/index.ts @@ -9,6 +9,8 @@ export * from './guards/app-must-exist.guard'; export * from './guards/must-be-authenticated.guard'; export * from './guards/must-be-not-authenticated.guard'; export * from './services/app-contributors.service'; +export * from './services/app-client-keys.service'; +export * from './services/app-languages.service'; export * from './services/apps-store.service'; export * from './services/apps.service'; export * from './services/auth.service'; diff --git a/src/Squidex/app/shared/services/app-client-keys.service.ts b/src/Squidex/app/shared/services/app-client-keys.service.ts new file mode 100644 index 000000000..4e196d659 --- /dev/null +++ b/src/Squidex/app/shared/services/app-client-keys.service.ts @@ -0,0 +1,52 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved + */ + +import * as Ng2 from '@angular/core'; +import * as Ng2Http from '@angular/http'; + +import { Observable } from 'rxjs'; + +import { ApiUrlConfig, DateTime } from 'framework'; +import { AuthService } from './auth.service'; + +export class AppClientKeyDto { + constructor( + public readonly clientKey: string, + public readonly expiresUtc: DateTime + ) { + } +} + +@Ng2.Injectable() +export class AppClientKeysService { + constructor( + private readonly authService: AuthService, + private readonly apiUrl: ApiUrlConfig, + private readonly http: Ng2Http.Http + ) { + } + + public getClientKeys(appName: string): Observable { + return this.authService.authGet(this.apiUrl.buildUrl(`api/apps/${appName}/client-keys`)) + .map(response => { + const body: any[] = response.json(); + + return body.map(item => { + return new AppClientKeyDto(item.clientKey, DateTime.parseISO_UTC(item.expiresUtc)); + }); + }); + } + + public postClientKey(appName: string): Observable { + return this.authService.authPost(this.apiUrl.buildUrl(`api/apps/${appName}/client-keys`), {}) + .map(response => { + const body = response.json(); + + return new AppClientKeyDto(body.clientKey, DateTime.now().addYears(1)); + }); + } +} \ No newline at end of file diff --git a/src/Squidex/app/shared/services/app-contributors.service.ts b/src/Squidex/app/shared/services/app-contributors.service.ts index 07b4fc2c4..0c0725824 100644 --- a/src/Squidex/app/shared/services/app-contributors.service.ts +++ b/src/Squidex/app/shared/services/app-contributors.service.ts @@ -10,7 +10,6 @@ import * as Ng2 from '@angular/core'; import { Observable } from 'rxjs'; import { ApiUrlConfig } from 'framework'; - import { AuthService } from './auth.service'; export class AppContributorDto { diff --git a/src/Squidex/app/shared/services/app-languages.service.spec.ts b/src/Squidex/app/shared/services/app-languages.service.spec.ts new file mode 100644 index 000000000..eb06d181e --- /dev/null +++ b/src/Squidex/app/shared/services/app-languages.service.spec.ts @@ -0,0 +1,76 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved + */ + +import * as TypeMoq from 'typemoq'; +import * as Ng2Http from '@angular/http'; + +import { Observable } from 'rxjs'; + +import { + ApiUrlConfig, + AppLanguagesService, + AuthService, + LanguageDto, +} from './../'; + +describe('AppLanguagesService', () => { + let authService: TypeMoq.Mock; + let appLanguagesService: AppLanguagesService; + + beforeEach(() => { + authService = TypeMoq.Mock.ofType(AuthService); + appLanguagesService = new AppLanguagesService(authService.object, new ApiUrlConfig('http://service/p/')); + }); + + it('should make get request with auth service to get app languages', () => { + authService.setup(x => x.authGet('http://service/p/api/apps/my-app/languages')) + .returns(() => Observable.of( + new Ng2Http.Response( + new Ng2Http.ResponseOptions({ + body: [{ + iso2Code: 'de', + englishName: 'German' + }, { + iso2Code: 'en', + englishName: 'English' + }] + }) + ) + )) + .verifiable(TypeMoq.Times.once()); + + let languages: LanguageDto[] = null; + + appLanguagesService.getLanguages('my-app').subscribe(result => { + languages = result; + }).unsubscribe(); + + expect(languages).toEqual( + [ + new LanguageDto('de', 'German'), + new LanguageDto('en', 'English'), + ]); + + authService.verifyAll(); + }); + + it('should make post request to configure languages', () => { + const languages = ['de', 'en']; + + authService.setup(x => x.authPost('http://service/p/api/apps/my-app/languages', TypeMoq.It.is(y => y['languages'] === languages))) + .returns(() => Observable.of( + new Ng2Http.Response( + new Ng2Http.ResponseOptions() + ) + )) + .verifiable(TypeMoq.Times.once()); + + appLanguagesService.postLanguages('my-app', languages); + + authService.verifyAll(); + }); +}); \ No newline at end of file diff --git a/src/Squidex/app/shared/services/app-languages.service.ts b/src/Squidex/app/shared/services/app-languages.service.ts new file mode 100644 index 000000000..31d0ad58f --- /dev/null +++ b/src/Squidex/app/shared/services/app-languages.service.ts @@ -0,0 +1,40 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved + */ + +import * as Ng2 from '@angular/core'; + +import { Observable } from 'rxjs'; + +import { ApiUrlConfig } from 'framework'; +import { AuthService } from './auth.service'; +import { LanguageDto } from './languages.service'; + +@Ng2.Injectable() +export class AppLanguagesService { + constructor( + private readonly authService: AuthService, + private readonly apiUrl: ApiUrlConfig + ) { + } + + public getLanguages(appName: string): Observable { + return this.authService.authGet(this.apiUrl.buildUrl(`api/apps/${appName}/languages`)) + .map(response => { + const body: any[] = response.json(); + + return body.map(item => { + return new LanguageDto( + item.iso2Code, + item.englishName); + }); + }); + } + + public postLanguages(appName: string, languageCodes: string[]): Observable { + return this.authService.authPost(this.apiUrl.buildUrl(`api/apps/${appName}/languages`), { languages: languageCodes }); + } +} \ No newline at end of file diff --git a/src/Squidex/app/shared/services/apps.service.ts b/src/Squidex/app/shared/services/apps.service.ts index 6d47104b3..0e46fc38a 100644 --- a/src/Squidex/app/shared/services/apps.service.ts +++ b/src/Squidex/app/shared/services/apps.service.ts @@ -9,7 +9,6 @@ import * as Ng2 from '@angular/core'; import { Observable } from 'rxjs'; import { ApiUrlConfig, DateTime } from 'framework'; - import { AuthService } from './auth.service'; export class AppDto { diff --git a/src/Squidex/app/shared/services/languages.service.ts b/src/Squidex/app/shared/services/languages.service.ts index db1f2bc39..09eca181d 100644 --- a/src/Squidex/app/shared/services/languages.service.ts +++ b/src/Squidex/app/shared/services/languages.service.ts @@ -9,7 +9,6 @@ import * as Ng2 from '@angular/core'; import { Observable } from 'rxjs'; import { ApiUrlConfig } from 'framework'; - import { AuthService } from './auth.service'; export class LanguageDto { diff --git a/src/Squidex/app/shared/services/users.service.ts b/src/Squidex/app/shared/services/users.service.ts index 8be4c20c4..dde656874 100644 --- a/src/Squidex/app/shared/services/users.service.ts +++ b/src/Squidex/app/shared/services/users.service.ts @@ -10,7 +10,6 @@ import * as Ng2 from '@angular/core'; import { Observable } from 'rxjs'; import { ApiUrlConfig } from 'framework'; - import { AuthService } from './auth.service'; export class UserDto { diff --git a/src/Squidex/app/theme/_layout.scss b/src/Squidex/app/theme/_layout.scss index 84db355eb..030605c43 100644 --- a/src/Squidex/app/theme/_layout.scss +++ b/src/Squidex/app/theme/_layout.scss @@ -70,6 +70,7 @@ h1 { &-content { @include flex-grow(1); padding: $padding-layout-v; + overflow-y: scroll; } } } \ No newline at end of file diff --git a/src/Squidex/app/theme/_completer.scss b/src/Squidex/app/theme/_lib-completer.scss similarity index 100% rename from src/Squidex/app/theme/_completer.scss rename to src/Squidex/app/theme/_lib-completer.scss diff --git a/src/Squidex/app/theme/_lib-dnd.scss b/src/Squidex/app/theme/_lib-dnd.scss new file mode 100644 index 000000000..caf3face3 --- /dev/null +++ b/src/Squidex/app/theme/_lib-dnd.scss @@ -0,0 +1,24 @@ +@import '_mixins'; +@import '_vars'; + +$color-drag-border: #000; + +.dnd { + &-drag { + &-start, + &-enter { + @include opacity(.6); + } + + &-start, + &-over, + &-enter { + border: 2px dashed $color-drag-border; + } + } + + &-sortable-drag { + @include opacity(.6); + border: 2px dashed $color-drag-border; + } +} \ No newline at end of file diff --git a/src/Squidex/app/theme/theme.scss b/src/Squidex/app/theme/theme.scss index 9beacf18b..4eb183a42 100644 --- a/src/Squidex/app/theme/theme.scss +++ b/src/Squidex/app/theme/theme.scss @@ -1,3 +1,4 @@ @import '_bootstrap.scss'; @import '_layout.scss'; -@import '_completer.scss'; \ No newline at end of file +@import '_lib-completer.scss'; +@import '_lib-dnd.scss'; \ No newline at end of file diff --git a/src/Squidex/package.json b/src/Squidex/package.json index 5aa12f959..7ea48ff99 100644 --- a/src/Squidex/package.json +++ b/src/Squidex/package.json @@ -30,6 +30,7 @@ "moment": "^2.14.0", "mousetrap": "^1.6.0", "ng2-completer": "^0.2.3", + "ng2-dnd": "^2.0.1", "oidc-client": "^1.2.1-beta.3", "reflect-metadata": "^0.1.3", "rxjs": "5.0.0-beta.12", diff --git a/tests/Squidex.Infrastructure.Tests/CQRS/Commands/EnrichWithTimestampHandlerTests.cs b/tests/Squidex.Infrastructure.Tests/CQRS/Commands/EnrichWithTimestampHandlerTests.cs new file mode 100644 index 000000000..ee0932ead --- /dev/null +++ b/tests/Squidex.Infrastructure.Tests/CQRS/Commands/EnrichWithTimestampHandlerTests.cs @@ -0,0 +1,51 @@ +// ========================================================================== +// EnrichWithTimestampHandlerTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Squidex.Infrastructure.CQRS.Commands +{ + public sealed class EnrichWithTimestampHandlerTests + { + private sealed class NormalCommand : AggregateCommand + { + } + + private sealed class TimestampCommand : AggregateCommand, ITimestampCommand + { + public DateTime Timestamp { get; set; } + } + + [Fact] + public async Task Should_set_timestamp_when_is_timestamp_command() + { + var utc = DateTime.Today; + var sut = new EnrichWithTimestampHandler(() => utc); + + var command = new TimestampCommand(); + + var result = await sut.HandleAsync(new CommandContext(command)); + + Assert.False(result); + Assert.Equal(utc, command.Timestamp); + } + + [Fact] + public async Task Should_do_nothing_for_normal_command() + { + var utc = DateTime.Today; + var sut = new EnrichWithTimestampHandler(() => utc); + + var result = await sut.HandleAsync(new CommandContext(new NormalCommand())); + + Assert.False(result); + } + } +} diff --git a/tests/Squidex.Infrastructure.Tests/LanguageTests.cs b/tests/Squidex.Infrastructure.Tests/LanguageTests.cs index e11a26009..b93a61cb5 100644 --- a/tests/Squidex.Infrastructure.Tests/LanguageTests.cs +++ b/tests/Squidex.Infrastructure.Tests/LanguageTests.cs @@ -8,12 +8,21 @@ using System; using System.Linq; +using Newtonsoft.Json; +using Squidex.Infrastructure.Json; using Xunit; namespace Squidex.Infrastructure { public class LanguageTests { + private static readonly JsonSerializerSettings serializerSettings = new JsonSerializerSettings(); + + static LanguageTests() + { + serializerSettings.Converters.Add(new LanguageConverter()); + } + [Theory] [InlineData("")] [InlineData(" ")] @@ -40,6 +49,26 @@ namespace Squidex.Infrastructure Assert.True(Language.AllLanguages.Count() > 100); } + [Fact] + public void Should_serialize_and_deserialize_null_language() + { + var input = Tuple.Create(null); + var json = JsonConvert.SerializeObject(input, serializerSettings); + var output = JsonConvert.DeserializeObject>(json, serializerSettings); + + Assert.Equal(output.Item1, input.Item1); + } + + [Fact] + public void Should_serialize_and_deserialize_valid_language() + { + var input = Tuple.Create(Language.GetLanguage("de")); + var json = JsonConvert.SerializeObject(input, serializerSettings); + var output = JsonConvert.DeserializeObject>(json, serializerSettings); + + Assert.Equal(output.Item1, input.Item1); + } + [Theory] [InlineData("de", "German")] [InlineData("en", "English")] diff --git a/tests/Squidex.Write.Tests/Apps/AppCommandHandlerTests.cs b/tests/Squidex.Write.Tests/Apps/AppCommandHandlerTests.cs index b17d8b440..32204dee4 100644 --- a/tests/Squidex.Write.Tests/Apps/AppCommandHandlerTests.cs +++ b/tests/Squidex.Write.Tests/Apps/AppCommandHandlerTests.cs @@ -6,6 +6,7 @@ // All rights reserved. // ========================================================================== +using System.Collections.Generic; using System.Threading.Tasks; using Moq; using Squidex.Infrastructure; @@ -26,9 +27,12 @@ namespace Squidex.Write.Tests.Apps private readonly Mock appRepository = new Mock(); private readonly Mock userRepository = new Mock(); private readonly AppCommandHandler sut; + private readonly AppDomainObject app; public AppCommandHandlerTests() { + app = new AppDomainObject(Id, 0); + sut = new AppCommandHandler( DomainObjectFactory.Object, DomainObjectRepository.Object, @@ -41,7 +45,7 @@ namespace Squidex.Write.Tests.Apps { appRepository.Setup(x => x.FindAppByNameAsync("my-app")).Returns(Task.FromResult(new Mock().Object)).Verifiable(); - await TestCreate(new AppDomainObject(Id, 0), async _ => + await TestCreate(app, async _ => { await Assert.ThrowsAsync(async () => await sut.On(new CreateApp { Name = "my-app" })); }, false); @@ -56,20 +60,31 @@ namespace Squidex.Write.Tests.Apps appRepository.Setup(x => x.FindAppByNameAsync("my-app")).Returns(Task.FromResult(null)).Verifiable(); - await TestCreate(new AppDomainObject(Id, 0), async _ => + await TestCreate(app, async _ => { await sut.On(command); }); appRepository.VerifyAll(); } - + + [Fact] + public async Task ConfigureLanguages_should_update_domain_object() + { + CreateApp(); + + var command = new ConfigureLanguages { AggregateId = Id, Languages = new List { Language.GetLanguage("de") } }; + + await TestUpdate(app, async _ => + { + await sut.On(command); + }); + } + [Fact] public async Task AssignContributor_should_throw_if_user_not_found() { - var app = - new AppDomainObject(Id, 0) - .Create(new CreateApp { Name = "my-app", SubjectId = "123" }); + CreateApp(); var command = new AssignContributor { AggregateId = Id, ContributorId = "456" }; @@ -84,9 +99,7 @@ namespace Squidex.Write.Tests.Apps [Fact] public async Task AssignContributor_should_assign_if_user_found() { - var app = - new AppDomainObject(Id, 0) - .Create(new CreateApp { Name = "my-app", SubjectId = "123" }); + CreateApp(); var command = new AssignContributor { AggregateId = Id, ContributorId = "456" }; @@ -101,10 +114,8 @@ namespace Squidex.Write.Tests.Apps [Fact] public async Task RemoveContributor_should_update_domain_object() { - var app = - new AppDomainObject(Id, 0) - .Create(new CreateApp { Name = "my-app", SubjectId = "123" }) - .AssignContributor(new AssignContributor { ContributorId = "456" }); + CreateApp() + .AssignContributor(new AssignContributor { ContributorId = "456" }); var command = new RemoveContributor { AggregateId = Id, ContributorId = "456" }; @@ -113,5 +124,39 @@ namespace Squidex.Write.Tests.Apps await sut.On(command); }); } + + [Fact] + public async Task CreateClientKey_should_update_domain_object() + { + CreateApp(); + + var command = new CreateClientKey { AggregateId = Id, ClientKey = "456" }; + + await TestUpdate(app, async _ => + { + await sut.On(command); + }); + } + + [Fact] + public async Task RevokeClientKey_should_update_domain_object() + { + CreateApp() + .CreateClientKey(new CreateClientKey { ClientKey = "456" }); + + var command = new RevokeClientKey { AggregateId = Id, ClientKey = "456" }; + + await TestUpdate(app, async _ => + { + await sut.On(command); + }); + } + + private AppDomainObject CreateApp() + { + app.Create(new CreateApp { Name = "my-app", SubjectId = "123" }); + + return app; + } } } diff --git a/tests/Squidex.Write.Tests/Apps/AppDomainObjectTests.cs b/tests/Squidex.Write.Tests/Apps/AppDomainObjectTests.cs index 707a24ef8..2a160bb93 100644 --- a/tests/Squidex.Write.Tests/Apps/AppDomainObjectTests.cs +++ b/tests/Squidex.Write.Tests/Apps/AppDomainObjectTests.cs @@ -7,11 +7,13 @@ // ========================================================================== using System; +using System.Collections.Generic; using System.Linq; using FluentAssertions; using Squidex.Core.Apps; using Squidex.Events.Apps; using Squidex.Infrastructure; +using Squidex.Infrastructure.CQRS; using Squidex.Infrastructure.CQRS.Events; using Squidex.Write.Apps; using Squidex.Write.Apps.Commands; @@ -22,14 +24,21 @@ namespace Squidex.Write.Tests.Apps public class AppDomainObjectTests { private const string TestName = "app"; - private readonly AppDomainObject sut = new AppDomainObject(Guid.NewGuid(), 0); + private readonly AppDomainObject sut; private readonly string subjectId = Guid.NewGuid().ToString(); private readonly string contributorId = Guid.NewGuid().ToString(); + private readonly string clientKey = Guid.NewGuid().ToString(); + private readonly List languages = new List { Language.GetLanguage("de") }; + + public AppDomainObjectTests() + { + sut = new AppDomainObject(Guid.NewGuid(), 0); + } [Fact] public void Create_should_throw_if_created() { - sut.Create(new CreateApp { Name = TestName, SubjectId = subjectId }); + CreateApp(); Assert.Throws(() => sut.Create(new CreateApp { Name = TestName })); } @@ -41,7 +50,7 @@ namespace Squidex.Write.Tests.Apps } [Fact] - public void Create_should_specify_and_owner() + public void Create_should_specify_name_and_owner() { sut.Create(new CreateApp { Name = TestName, SubjectId = subjectId }); @@ -53,7 +62,8 @@ namespace Squidex.Write.Tests.Apps new IEvent[] { new AppCreated { Name = TestName }, - new AppContributorAssigned { ContributorId = subjectId, Permission = PermissionLevel.Owner } + new AppContributorAssigned { ContributorId = subjectId, Permission = PermissionLevel.Owner }, + new AppLanguagesConfigured { Languages= new List { Language.GetLanguage("de") } } }); } @@ -66,7 +76,7 @@ namespace Squidex.Write.Tests.Apps [Fact] public void AssignContributor_should_throw_if_single_owner_becomes_non_owner() { - sut.Create(new CreateApp { Name = TestName, SubjectId = subjectId }); + CreateApp(); Assert.Throws(() => sut.AssignContributor(new AssignContributor { ContributorId = subjectId, Permission = PermissionLevel.Editor })); } @@ -74,12 +84,13 @@ namespace Squidex.Write.Tests.Apps [Fact] public void AssignContributor_should_create_events() { - sut.Create(new CreateApp { Name = TestName, SubjectId = subjectId }); + CreateApp(); + sut.AssignContributor(new AssignContributor { ContributorId = contributorId, Permission = PermissionLevel.Editor }); Assert.Equal(PermissionLevel.Editor, sut.Contributors[contributorId]); - sut.GetUncomittedEvents().Select(x => x.Payload).Skip(2).ToArray() + sut.GetUncomittedEvents().Select(x => x.Payload).ToArray() .ShouldBeEquivalentTo( new IEvent[] { @@ -96,7 +107,7 @@ namespace Squidex.Write.Tests.Apps [Fact] public void RemoveContributor_should_throw_if_all_owners_removed() { - sut.Create(new CreateApp { Name = TestName, SubjectId = subjectId }); + CreateApp(); Assert.Throws(() => sut.RemoveContributor(new RemoveContributor { ContributorId = subjectId })); } @@ -104,27 +115,141 @@ namespace Squidex.Write.Tests.Apps [Fact] public void RemoveContributor_should_throw_if_contributor_not_found() { - sut.Create(new CreateApp { Name = TestName, SubjectId = subjectId }); + CreateApp(); + sut.AssignContributor(new AssignContributor { ContributorId = contributorId, Permission = PermissionLevel.Editor }); - Assert.Throws(() => sut.RemoveContributor(new RemoveContributor { ContributorId = "123" })); + Assert.Throws(() => sut.RemoveContributor(new RemoveContributor { ContributorId = "not-found" })); } [Fact] public void RemoveContributor_should_create_events_and_remove_contributor() { - sut.Create(new CreateApp { Name = TestName, SubjectId = subjectId }); + CreateApp(); + sut.AssignContributor(new AssignContributor { ContributorId = contributorId, Permission = PermissionLevel.Editor }); sut.RemoveContributor(new RemoveContributor { ContributorId = contributorId }); Assert.False(sut.Contributors.ContainsKey(contributorId)); - sut.GetUncomittedEvents().Select(x => x.Payload).Skip(3).ToArray() + sut.GetUncomittedEvents().Select(x => x.Payload).Skip(1).ToArray() .ShouldBeEquivalentTo( new IEvent[] { new AppContributorRemoved { ContributorId = contributorId } }); } + + [Fact] + public void ConfigureLanguages_should_throw_if_not_created() + { + Assert.Throws(() => sut.ConfigureLanguages(new ConfigureLanguages { Languages = languages })); + } + + [Fact] + public void ConfigureLanguages_should_throw_if_languages_are_null_or_empty() + { + CreateApp(); + + Assert.Throws(() => sut.ConfigureLanguages(new ConfigureLanguages())); + Assert.Throws(() => sut.ConfigureLanguages(new ConfigureLanguages { Languages = new List() })); + } + + [Fact] + public void ConfigureLanguages_should_create_events() + { + CreateApp(); + + sut.ConfigureLanguages(new ConfigureLanguages { Languages = languages }); + + Assert.False(sut.Contributors.ContainsKey(contributorId)); + + sut.GetUncomittedEvents().Select(x => x.Payload).ToArray() + .ShouldBeEquivalentTo( + new IEvent[] + { + new AppLanguagesConfigured { Languages = languages } + }); + } + + [Fact] + public void CreateClientKey_should_throw_if_not_created() + { + Assert.Throws(() => sut.CreateClientKey(new CreateClientKey { ClientKey = clientKey })); + } + + [Fact] + public void CreateClientKey_should_throw_if_client_key_is_null_or_empty() + { + CreateApp(); + + Assert.Throws(() => sut.CreateClientKey(new CreateClientKey())); + Assert.Throws(() => sut.CreateClientKey(new CreateClientKey { ClientKey = string.Empty })); + } + + [Fact] + public void CreateClientKey_should_create_events() + { + var now = DateTime.Today; + + CreateApp(); + + sut.CreateClientKey(new CreateClientKey { ClientKey = clientKey, Timestamp = now }); + + Assert.False(sut.Contributors.ContainsKey(contributorId)); + + sut.GetUncomittedEvents().Select(x => x.Payload).ToArray() + .ShouldBeEquivalentTo( + new IEvent[] + { + new AppClientKeyCreated { ClientKey = clientKey, ExpiresUtc = now.AddYears(1) } + }); + } + + [Fact] + public void RevokeClientKey_should_throw_if_not_created() + { + Assert.Throws(() => sut.RevokeClientKey(new RevokeClientKey { ClientKey = "not-found" })); + } + + [Fact] + public void RevokeClientKey_should_throw_if_client_key_is_null_or_empty() + { + CreateApp(); + + Assert.Throws(() => sut.RevokeClientKey(new RevokeClientKey())); + Assert.Throws(() => sut.RevokeClientKey(new RevokeClientKey { ClientKey = string.Empty })); + } + + [Fact] + public void RevokeClientKey_should_throw_if_key_not_found() + { + CreateApp(); + + Assert.Throws(() => sut.RevokeClientKey(new RevokeClientKey { ClientKey = "not-found" })); + } + + [Fact] + public void RevokeClientKey_should_create_events() + { + CreateApp(); + + sut.CreateClientKey(new CreateClientKey { ClientKey = clientKey }); + sut.RevokeClientKey(new RevokeClientKey { ClientKey = clientKey }); + + sut.GetUncomittedEvents().Select(x => x.Payload).Skip(1).ToArray() + .ShouldBeEquivalentTo( + new IEvent[] + { + new AppClientKeyRevoked { ClientKey = clientKey } + }); + } + + private void CreateApp() + { + sut.Create(new CreateApp { Name = TestName, SubjectId = subjectId }); + + ((IAggregate)sut).ClearUncommittedEvents(); + } } } diff --git a/tests/Squidex.Write.Tests/Apps/ClientKeyGeneratorTests.cs b/tests/Squidex.Write.Tests/Apps/ClientKeyGeneratorTests.cs new file mode 100644 index 000000000..d9d4e655c --- /dev/null +++ b/tests/Squidex.Write.Tests/Apps/ClientKeyGeneratorTests.cs @@ -0,0 +1,26 @@ +// ========================================================================= +// ClientKeyGeneratorTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Write.Apps; +using Xunit; + +namespace Squidex.Write.Tests.Apps +{ + public class ClientKeyGeneratorTests + { + private readonly ClientKeyGenerator sut = new ClientKeyGenerator(); + + [Fact] + public void Should_create_very_long_client_key() + { + var key = sut.GenerateKey(); + + Assert.Equal(44, key.Length); + } + } +}