Browse Source

Client handling improved

pull/1/head
Sebastian 9 years ago
parent
commit
55ee4dd491
  1. 8
      src/Squidex.Events/Apps/AppClientAttached.cs
  2. 6
      src/Squidex.Events/Apps/AppClientRevoked.cs
  3. 2
      src/Squidex.Events/Apps/AppContributorAssigned.cs
  4. 2
      src/Squidex.Events/Apps/AppContributorRemoved.cs
  5. 2
      src/Squidex.Events/Apps/AppCreated.cs
  6. 2
      src/Squidex.Events/Apps/AppLanguagesConfigured.cs
  7. 2
      src/Squidex.Events/Schemas/FieldDeleted.cs
  8. 27
      src/Squidex.Infrastructure/CQRS/Commands/CommandContext.cs
  9. 4
      src/Squidex.Infrastructure/CQRS/Commands/InMemoryCommandBus.cs
  10. 2
      src/Squidex.Infrastructure/Dispatching/FuncContextDispatcher.cs
  11. 6
      src/Squidex.Read/Apps/IAppClientEntity.cs
  12. 2
      src/Squidex.Read/Apps/IAppEntity.cs
  13. 4
      src/Squidex.Read/Apps/Services/Implementations/CachingAppProvider.cs
  14. 8
      src/Squidex.Store.MongoDb/Apps/MongoAppClientEntity.cs
  15. 8
      src/Squidex.Store.MongoDb/Apps/MongoAppEntity.cs
  16. 8
      src/Squidex.Store.MongoDb/Apps/MongoAppRepository.cs
  17. 6
      src/Squidex.Store.MongoDb/Schemas/Models/FieldModel.cs
  18. 10
      src/Squidex.Store.MongoDb/Schemas/Models/SchemaModel.cs
  19. 2
      src/Squidex.Store.MongoDb/Schemas/MongoSchemaEntity.cs
  20. 4
      src/Squidex.Store.MongoDb/Schemas/MongoSchemaRepository.cs
  21. 33
      src/Squidex.Write/Apps/AppClient.cs
  22. 39
      src/Squidex.Write/Apps/AppCommandHandler.cs
  23. 109
      src/Squidex.Write/Apps/AppDomainObject.cs
  24. 10
      src/Squidex.Write/Apps/Commands/AttachClient.cs
  25. 8
      src/Squidex.Write/Apps/Commands/CreateApp.cs
  26. 10
      src/Squidex.Write/Apps/Commands/RevokeClient.cs
  27. 2
      src/Squidex/Configurations/Identity/LazyClientStore.cs
  28. 17
      src/Squidex/Configurations/Swagger/SwaggerUsage.cs
  29. 53
      src/Squidex/Configurations/Swagger/XmlResponseTypesProcessor.cs
  30. 54
      src/Squidex/Modules/Api/Apps/AppClientsController.cs
  31. 28
      src/Squidex/Modules/Api/Apps/AppContributorsController.cs
  32. 15
      src/Squidex/Modules/Api/Apps/AppController.cs
  33. 8
      src/Squidex/Modules/Api/Apps/AppLanguagesController.cs
  34. 9
      src/Squidex/Modules/Api/Apps/Models/AttachClientDto.cs
  35. 12
      src/Squidex/Modules/Api/Apps/Models/ClientDto.cs
  36. 2
      src/Squidex/Modules/Api/Apps/Models/CreateAppDto.cs
  37. 4
      src/Squidex/Modules/Api/Languages/LanguagesController.cs
  38. 23
      src/Squidex/Modules/Api/Schemas/Models/CreateFieldDto.cs
  39. 9
      src/Squidex/Modules/Api/Schemas/Models/CreateSchemaDto.cs
  40. 52
      src/Squidex/Modules/Api/Schemas/Models/FieldDto.cs
  41. 33
      src/Squidex/Modules/Api/Schemas/Models/Fields/NumberField.cs
  42. 38
      src/Squidex/Modules/Api/Schemas/Models/Fields/StringField.cs
  43. 57
      src/Squidex/Modules/Api/Schemas/Models/SchemaDetailsDto.cs
  44. 17
      src/Squidex/Modules/Api/Schemas/Models/SchemaDto.cs
  45. 17
      src/Squidex/Modules/Api/Schemas/Models/UpdateFieldDto.cs
  46. 14
      src/Squidex/Modules/Api/Schemas/Models/UpdateSchemaDto.cs
  47. 183
      src/Squidex/Modules/Api/Schemas/SchemaFieldsController.cs
  48. 75
      src/Squidex/Modules/Api/Schemas/SchemasController.cs
  49. 2
      src/Squidex/Modules/Api/Users/Models/UserDto.cs
  50. 8
      src/Squidex/Modules/Api/Users/UsersController.cs
  51. 4
      src/Squidex/app/app.module.ts
  52. 6
      src/Squidex/app/app.routes.ts
  53. 2
      src/Squidex/app/components/internal/app/left-menu.component.html
  54. 91
      src/Squidex/app/components/internal/app/settings/clients-page.component.html
  55. 44
      src/Squidex/app/components/internal/app/settings/clients-page.component.scss
  56. 107
      src/Squidex/app/components/internal/app/settings/clients-page.component.ts
  57. 6
      src/Squidex/app/components/internal/app/settings/contributors-page.component.html
  58. 6
      src/Squidex/app/components/internal/app/settings/contributors-page.component.scss
  59. 2
      src/Squidex/app/components/internal/app/settings/contributors-page.component.ts
  60. 40
      src/Squidex/app/components/internal/app/settings/credentials-page.component.html
  61. 17
      src/Squidex/app/components/internal/app/settings/credentials-page.component.scss
  62. 60
      src/Squidex/app/components/internal/app/settings/credentials-page.component.ts
  63. 10
      src/Squidex/app/components/internal/app/settings/languages-page.component.html
  64. 6
      src/Squidex/app/components/internal/app/settings/languages-page.component.scss
  65. 2
      src/Squidex/app/components/internal/declarations.ts
  66. 4
      src/Squidex/app/components/internal/module.ts
  67. 5
      src/Squidex/app/components/layout/app-form.component.html
  68. 8
      src/Squidex/app/components/layout/app-form.component.ts
  69. 2
      src/Squidex/app/shared/index.ts
  70. 52
      src/Squidex/app/shared/services/app-client-keys.service.ts
  71. 107
      src/Squidex/app/shared/services/app-clients.service.spec.ts
  72. 67
      src/Squidex/app/shared/services/app-clients.service.ts
  73. 5
      src/Squidex/app/shared/services/app-contributors.service.ts
  74. 6
      src/Squidex/app/shared/services/app-languages.service.ts
  75. 5
      src/Squidex/app/shared/services/apps.service.ts
  76. 5
      src/Squidex/app/shared/services/languages.service.ts
  77. 16
      src/Squidex/app/shared/services/users.service.ts
  78. 14
      src/Squidex/app/theme/_bootstrap.scss
  79. 1
      src/Squidex/app/theme/_vars.scss
  80. 2
      src/Squidex/web.config
  81. 97
      tests/Squidex.Infrastructure.Tests/CQRS/Commands/CommandContextTests.cs
  82. 80
      tests/Squidex.Write.Tests/Apps/AppCommandHandlerTests.cs
  83. 54
      tests/Squidex.Write.Tests/Apps/AppDomainObjectTests.cs

8
src/Squidex.Events/Apps/AppClientKeyCreated.cs → src/Squidex.Events/Apps/AppClientAttached.cs

@ -12,10 +12,12 @@ using Squidex.Infrastructure.CQRS.Events;
namespace Squidex.Events.Apps
{
[TypeName("AppClientKeyCreated")]
public sealed class AppClientKeyCreated : IEvent
[TypeName("AppClientAttachedEvent")]
public sealed class AppClientAttached : IEvent
{
public string ClientKey { get; set; }
public string ClientName { get; set; }
public string ClientSecret { get; set; }
public DateTime ExpiresUtc { get; set; }
}

6
src/Squidex.Events/Apps/AppClientKeyRevoked.cs → src/Squidex.Events/Apps/AppClientRevoked.cs

@ -12,10 +12,10 @@ using Squidex.Infrastructure.CQRS.Events;
namespace Squidex.Events.Apps
{
[TypeName("AppClientKeyRevoked")]
public sealed class AppClientKeyRevoked : IEvent
[TypeName("AppClientRevokedEvent")]
public sealed class AppClientRevoked : IEvent
{
public string ClientKey { get; set; }
public string ClientName { get; set; }
public DateTime ExpiresUtc { get; set; }
}

2
src/Squidex.Events/Apps/AppContributorAssigned.cs

@ -12,7 +12,7 @@ using Squidex.Infrastructure.CQRS.Events;
namespace Squidex.Events.Apps
{
[TypeName("AppContributorAssigned")]
[TypeName("AppContributorAssignedEvent")]
public class AppContributorAssigned : IEvent
{
public string ContributorId { get; set; }

2
src/Squidex.Events/Apps/AppContributorRemoved.cs

@ -11,7 +11,7 @@ using Squidex.Infrastructure.CQRS.Events;
namespace Squidex.Events.Apps
{
[TypeName("AppContributorRemoved")]
[TypeName("AppContributorRemovedEvent")]
public class AppContributorRemoved : IEvent
{
public string ContributorId { get; set; }

2
src/Squidex.Events/Apps/AppCreated.cs

@ -11,7 +11,7 @@ using Squidex.Infrastructure.CQRS.Events;
namespace Squidex.Events.Apps
{
[TypeName("AppCreated")]
[TypeName("AppCreatedEvent")]
public class AppCreated : IEvent
{
public string Name { get; set; }

2
src/Squidex.Events/Apps/AppLanguagesConfigured.cs

@ -12,7 +12,7 @@ using Squidex.Infrastructure.CQRS.Events;
namespace Squidex.Events.Apps
{
[TypeName("AppLanguagesConfigured")]
[TypeName("AppLanguagesConfiguredEvent")]
public sealed class AppLanguagesConfigured : IEvent
{
public List<Language> Languages { get; set; }

2
src/Squidex.Events/Schemas/FieldDeleted.cs

@ -11,7 +11,7 @@ using Squidex.Infrastructure.CQRS.Events;
namespace Squidex.Events.Schemas
{
[TypeName("FieldDeletedEvent")]
[TypeName("FieldDeleted")]
public class FieldDeleted : IEvent
{
public long FieldId { get; set; }

27
src/Squidex.Infrastructure/CQRS/Commands/CommandContext.cs

@ -14,7 +14,7 @@ namespace Squidex.Infrastructure.CQRS.Commands
{
private readonly ICommand command;
private Exception exception;
private bool isSucceeded;
private Tuple<object> result;
public ICommand Command
{
@ -23,12 +23,12 @@ namespace Squidex.Infrastructure.CQRS.Commands
public bool IsHandled
{
get { return isSucceeded || exception != null; }
get { return result != null || exception != null; }
}
public bool IsSucceeded
{
get { return isSucceeded; }
get { return result != null; }
}
public Exception Exception
@ -43,14 +43,29 @@ namespace Squidex.Infrastructure.CQRS.Commands
this.command = command;
}
public void MarkSucceeded()
public void Succeed(object resultValue = null)
{
isSucceeded = true;
if (IsHandled)
{
return;
}
result = Tuple.Create(resultValue);
}
public void MarkFailed(Exception handlerException)
public void Fail(Exception handlerException)
{
if (IsHandled)
{
return;
}
exception = handlerException;
}
public T Result<T>()
{
return result != null ? (T)result.Item1 : default(T);
}
}
}

4
src/Squidex.Infrastructure/CQRS/Commands/InMemoryCommandBus.cs

@ -37,12 +37,12 @@ namespace Squidex.Infrastructure.CQRS.Commands
if (isHandled)
{
context.MarkSucceeded();
context.Succeed();
}
}
catch (Exception e)
{
context.MarkFailed(e);
context.Fail(e);
}
}

2
src/Squidex.Infrastructure/Dispatching/FuncContextDispatcher.cs

@ -21,7 +21,7 @@ namespace Squidex.Infrastructure.Dispatching
{
Handlers =
typeof(TTarget)
.GetMethods()
.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
.Where(Helper.HasRightName)
.Where(Helper.HasRightParameters<TIn, TContext>)
.Where(Helper.HasRightReturnType<TOut>)

6
src/Squidex.Read/Apps/IAppClientKeyEntity.cs → src/Squidex.Read/Apps/IAppClientEntity.cs

@ -10,9 +10,11 @@ using System;
namespace Squidex.Read.Apps
{
public interface IAppClientKeyEntity
public interface IAppClientEntity
{
string ClientKey { get; }
string ClientName { get; }
string ClientSecret { get; }
DateTime ExpiresUtc { get; }
}

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

@ -17,7 +17,7 @@ namespace Squidex.Read.Apps
IEnumerable<Language> Languages { get; }
IEnumerable<IAppClientKeyEntity> ClientKeys { get; }
IEnumerable<IAppClientEntity> Clients { get; }
IEnumerable<IAppContributorEntity> Contributors { get; }
}

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

@ -66,8 +66,8 @@ namespace Squidex.Read.Apps.Services.Implementations
{
if (@event.Payload is AppContributorAssigned ||
@event.Payload is AppContributorRemoved ||
@event.Payload is AppClientKeyCreated ||
@event.Payload is AppClientKeyRevoked ||
@event.Payload is AppClientAttached ||
@event.Payload is AppClientRevoked ||
@event.Payload is AppLanguagesConfigured)
{
var appName = Cache.Get<string>(BuildNamesCacheKey(@event.Headers.AggregateId()));

8
src/Squidex.Store.MongoDb/Apps/MongoAppClientKeyEntity.cs → src/Squidex.Store.MongoDb/Apps/MongoAppClientEntity.cs

@ -12,11 +12,15 @@ using Squidex.Read.Apps;
namespace Squidex.Store.MongoDb.Apps
{
public class MongoAppClientKeyEntity : IAppClientKeyEntity
public sealed class MongoAppClientEntity : IAppClientEntity
{
[BsonRequired]
[BsonElement]
public string ClientKey { get; set; }
public string ClientName { get; set; }
[BsonRequired]
[BsonElement]
public string ClientSecret { get; set; }
[BsonRequired]
[BsonElement]

8
src/Squidex.Store.MongoDb/Apps/MongoAppEntity.cs

@ -27,7 +27,7 @@ namespace Squidex.Store.MongoDb.Apps
[BsonRequired]
[BsonElement]
public List<MongoAppClientKeyEntity> ClientKeys { get; set; }
public List<MongoAppClientEntity> Clients { get; set; }
[BsonRequired]
[BsonElement]
@ -38,9 +38,9 @@ namespace Squidex.Store.MongoDb.Apps
get { return Languages.Select(Language.GetLanguage); }
}
IEnumerable<IAppClientKeyEntity> IAppEntity.ClientKeys
IEnumerable<IAppClientEntity> IAppEntity.Clients
{
get { return ClientKeys; }
get { return Clients; }
}
IEnumerable<IAppContributorEntity> IAppEntity.Contributors
@ -52,7 +52,7 @@ namespace Squidex.Store.MongoDb.Apps
{
Contributors = new List<MongoAppContributorEntity>();
ClientKeys = new List<MongoAppClientKeyEntity>();
Clients = new List<MongoAppClientEntity>();
}
}
}

8
src/Squidex.Store.MongoDb/Apps/MongoAppRepository.cs

@ -69,14 +69,14 @@ namespace Squidex.Store.MongoDb.Apps
return Collection.UpdateAsync(headers, a => a.Languages = @event.Languages.Select(x => x.Iso2Code).ToList());
}
public Task On(AppClientKeyCreated @event, EnvelopeHeaders headers)
public Task On(AppClientAttached @event, EnvelopeHeaders headers)
{
return Collection.UpdateAsync(headers, a => a.ClientKeys.Add(SimpleMapper.Map(@event, new MongoAppClientKeyEntity())));
return Collection.UpdateAsync(headers, a => a.Clients.Add(SimpleMapper.Map(@event, new MongoAppClientEntity())));
}
public Task On(AppClientKeyRevoked @event, EnvelopeHeaders headers)
public Task On(AppClientRevoked @event, EnvelopeHeaders headers)
{
return Collection.UpdateAsync(headers, a => a.ClientKeys.RemoveAll(c => c.ClientKey == @event.ClientKey));
return Collection.UpdateAsync(headers, a => a.Clients.RemoveAll(c => c.ClientName == @event.ClientName));
}
public Task On(AppContributorAssigned @event, EnvelopeHeaders headers)

6
src/Squidex.Store.MongoDb/Schemas/Models/FieldDto.cs → src/Squidex.Store.MongoDb/Schemas/Models/FieldModel.cs

@ -10,7 +10,7 @@ using Squidex.Core.Schemas;
namespace Squidex.Store.MongoDb.Schemas.Models
{
public class FieldDto
public class FieldModel
{
public string Name { get; set; }
@ -20,9 +20,9 @@ namespace Squidex.Store.MongoDb.Schemas.Models
public FieldProperties Properties { get; set; }
public static FieldDto Create(Field field)
public static FieldModel Create(Field field)
{
return new FieldDto
return new FieldModel
{
Name = field.Name,
IsHidden = field.IsHidden,

10
src/Squidex.Store.MongoDb/Schemas/Models/SchemaDto.cs → src/Squidex.Store.MongoDb/Schemas/Models/SchemaModel.cs

@ -16,24 +16,24 @@ using Squidex.Infrastructure;
namespace Squidex.Store.MongoDb.Schemas.Models
{
public sealed class SchemaDto
public sealed class SchemaModel
{
public string Name { get; set; }
public Dictionary<long, FieldDto> Fields { get; set; }
public Dictionary<long, FieldModel> Fields { get; set; }
public SchemaProperties Properties { get; set; }
public static SchemaDto Create(Schema schema)
public static SchemaModel Create(Schema schema)
{
Guard.NotNull(schema, nameof(schema));
var dto = new SchemaDto { Properties = schema.Properties, Name = schema.Name };
var dto = new SchemaModel { Properties = schema.Properties, Name = schema.Name };
dto.Fields =
schema.Fields.ToDictionary(
kvp => kvp.Key,
kvp => FieldDto.Create(kvp.Value));
kvp => FieldModel.Create(kvp.Value));
return dto;
}

2
src/Squidex.Store.MongoDb/Schemas/MongoSchemaEntity.cs

@ -49,7 +49,7 @@ namespace Squidex.Store.MongoDb.Schemas
return;
}
var dto = Schema.ToJsonObject<SchemaDto>(serializerSettings);
var dto = Schema.ToJsonObject<SchemaModel>(serializerSettings);
schema = dto?.ToSchema(fieldRegistry);
}

4
src/Squidex.Store.MongoDb/Schemas/MongoSchemaRepository.cs

@ -161,14 +161,14 @@ namespace Squidex.Store.MongoDb.Schemas
private void Serialize(MongoSchemaEntity entity, Schema schema)
{
var dto = SchemaDto.Create(schema);
var dto = SchemaModel.Create(schema);
entity.Schema = dto.ToJsonBsonDocument(serializerSettings);
}
private Schema Deserialize(MongoSchemaEntity entity)
{
var dto = entity?.Schema.ToJsonObject<SchemaDto>(serializerSettings);
var dto = entity?.Schema.ToJsonObject<SchemaModel>(serializerSettings);
return dto?.ToSchema(fieldRegistry);
}

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

@ -0,0 +1,33 @@
// ==========================================================================
// AppClient.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System;
using Squidex.Infrastructure;
namespace Squidex.Write.Apps
{
public sealed class AppClient
{
public string ClientName { get; }
public string ClientSecret { get; }
public DateTime ExpiresUtc { get; }
public AppClient(string name, string secret, DateTime expiresUtc)
{
Guard.NotNullOrEmpty(name, nameof(name));
Guard.NotNullOrEmpty(secret, nameof(secret));
ClientName = name;
ClientSecret = secret;
ExpiresUtc = expiresUtc;
}
}
}

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

@ -20,22 +20,26 @@ namespace Squidex.Write.Apps
{
private readonly IAppRepository appRepository;
private readonly IUserRepository userRepository;
private readonly ClientKeyGenerator keyGenerator;
public AppCommandHandler(
IDomainObjectFactory domainObjectFactory,
IDomainObjectRepository domainObjectRepository,
IAppRepository appRepository,
IUserRepository userRepository)
IDomainObjectRepository domainObjectRepository,
IUserRepository userRepository,
IAppRepository appRepository,
ClientKeyGenerator keyGenerator)
: base(domainObjectFactory, domainObjectRepository)
{
Guard.NotNull(keyGenerator, nameof(keyGenerator));
Guard.NotNull(appRepository, nameof(appRepository));
Guard.NotNull(userRepository, nameof(userRepository));
this.keyGenerator = keyGenerator;
this.appRepository = appRepository;
this.userRepository = userRepository;
}
public Task On(CreateApp command)
protected Task On(CreateApp command, CommandContext context)
{
return CreateAsync(command, async x =>
{
@ -47,10 +51,12 @@ namespace Squidex.Write.Apps
}
x.Create(command);
context.Succeed(command.AggregateId);
});
}
public Task On(AssignContributor command)
protected Task On(AssignContributor command, CommandContext context)
{
return UpdateAsync(command, async x =>
{
@ -65,29 +71,36 @@ namespace Squidex.Write.Apps
});
}
public Task On(RemoveContributor command)
protected Task On(AttachClient command, CommandContext context)
{
return UpdateAsync(command, x => x.RemoveContributor(command));
return UpdateAsync(command, x =>
{
var clientKey = keyGenerator.GenerateKey();
x.AttachClient(command, clientKey);
context.Succeed(x.Clients[command.ClientName]);
});
}
public Task On(CreateClientKey command)
protected Task On(RemoveContributor command, CommandContext context)
{
return UpdateAsync(command, x => x.CreateClientKey(command));
return UpdateAsync(command, x => x.RemoveContributor(command));
}
public Task On(RevokeClientKey command)
protected Task On(RevokeClient command, CommandContext context)
{
return UpdateAsync(command, x => x.RevokeClientKey(command));
return UpdateAsync(command, x => x.RevokeClient(command));
}
public Task On(ConfigureLanguages command)
protected Task On(ConfigureLanguages command, CommandContext context)
{
return UpdateAsync(command, x => x.ConfigureLanguages(command));
}
public override Task<bool> HandleAsync(CommandContext context)
{
return context.IsHandled ? Task.FromResult(false) : this.DispatchActionAsync(context.Command);
return context.IsHandled ? Task.FromResult(false) : this.DispatchActionAsync(context.Command, context);
}
}
}

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

@ -25,7 +25,7 @@ namespace Squidex.Write.Apps
public sealed class AppDomainObject : DomainObject
{
private static readonly List<Language> DefaultLanguages = new List<Language> { Language.GetLanguage("en") };
private readonly HashSet<string> clientKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, AppClient> clients = new Dictionary<string, AppClient>(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, PermissionLevel> contributors = new Dictionary<string, PermissionLevel>();
private string name;
@ -39,6 +39,11 @@ namespace Squidex.Write.Apps
get { return contributors; }
}
public IReadOnlyDictionary<string, AppClient> Clients
{
get { return clients; }
}
public AppDomainObject(Guid id, int version)
: base(id, version)
{
@ -59,14 +64,14 @@ namespace Squidex.Write.Apps
contributors.Remove(@event.ContributorId);
}
public void On(AppClientKeyCreated @event)
public void On(AppClientAttached @event)
{
clientKeys.Add(@event.ClientKey);
clients.Add(@event.ClientName, new AppClient(@event.ClientName, @event.ClientSecret, @event.ExpiresUtc));
}
public void On(AppClientKeyRevoked @event)
public void On(AppClientRevoked @event)
{
clientKeys.Remove(@event.ClientKey);
clients.Remove(@event.ClientName);
}
protected override void DispatchEvent(Envelope<IEvent> @event)
@ -76,9 +81,11 @@ namespace Squidex.Write.Apps
public AppDomainObject Create(CreateApp command)
{
Guard.Valid(command, nameof(command), () => "Cannot create app");
Func<string> message = () => "Cannot create app";
Guard.Valid(command, nameof(command), message);
VerifyNotCreated();
ThrowIfCreated();
RaiseEvent(SimpleMapper.Map(command, new AppCreated()));
@ -90,59 +97,73 @@ namespace Squidex.Write.Apps
public AppDomainObject AssignContributor(AssignContributor command)
{
Guard.Valid(command, nameof(command), () => "Cannot assign contributor");
Func<string> message = () => "Cannot assign contributor";
VerifyCreated();
VerifyOwnership(c => c[command.ContributorId] = command.Permission);
Guard.Valid(command, nameof(command), message);
ThrowIfNotCreated();
ThrowIfNoOwner(c => c[command.ContributorId] = command.Permission, message);
RaiseEvent(SimpleMapper.Map(command, new AppContributorAssigned()));
return this;
}
public AppDomainObject RemoveContributor(RemoveContributor command)
public AppDomainObject RevokeClient(RevokeClient command)
{
Guard.Valid(command, nameof(command), () => "Cannot remove contributor");
Func<string> message = () => "Cannot revoke client";
VerifyCreated();
VerifyContributorFound(command);
VerifyOwnership(c => c.Remove(command.ContributorId));
Guard.Valid(command, nameof(command), () => "Cannot revoke client");
RaiseEvent(SimpleMapper.Map(command, new AppContributorRemoved()));
ThrowIfNotCreated();
ThrowIfClientNotFound(command, message);
RaiseEvent(SimpleMapper.Map(command, new AppClientRevoked()));
return this;
}
public AppDomainObject ConfigureLanguages(ConfigureLanguages command)
public AppDomainObject AttachClient(AttachClient command, string secret)
{
Guard.Valid(command, nameof(command), () => "Cannot remove contributor");
Func<string> message = () => "Cannot attach client";
VerifyCreated();
Guard.Valid(command, nameof(command), () => "Cannot attach client");
RaiseEvent(SimpleMapper.Map(command, new AppLanguagesConfigured()));
ThrowIfNotCreated();
ThrowIfClientFound(command, message);
var expire = command.Timestamp.AddYears(1);
RaiseEvent(SimpleMapper.Map(command, new AppClientAttached { ClientSecret = secret, ExpiresUtc = expire }));
return this;
}
public AppDomainObject RevokeClientKey(RevokeClientKey command)
public AppDomainObject RemoveContributor(RemoveContributor command)
{
Guard.Valid(command, nameof(command), () => "Cannot revoke client key");
Func<string> message = () => "Cannot remove contributor";
Guard.Valid(command, nameof(command), () => "Cannot remove contributor");
VerifyCreated();
VerifyClientKeyFound(command);
ThrowIfNotCreated();
ThrowIfContributorNotFound(command, message);
RaiseEvent(SimpleMapper.Map(command, new AppClientKeyRevoked()));
ThrowIfNoOwner(c => c.Remove(command.ContributorId), message);
RaiseEvent(SimpleMapper.Map(command, new AppContributorRemoved()));
return this;
}
public AppDomainObject CreateClientKey(CreateClientKey command)
public AppDomainObject ConfigureLanguages(ConfigureLanguages command)
{
Guard.Valid(command, nameof(command), () => "Cannot create client key");
Func<string> message = () => "Cannot configure languages";
Guard.Valid(command, nameof(command), message);
VerifyCreated();
ThrowIfNotCreated();
RaiseEvent(SimpleMapper.Map(command, new AppClientKeyCreated()));
RaiseEvent(SimpleMapper.Map(command, new AppLanguagesConfigured()));
return this;
}
@ -157,7 +178,7 @@ namespace Squidex.Write.Apps
return new AppContributorAssigned { ContributorId = command.SubjectId, Permission = PermissionLevel.Owner };
}
private void VerifyCreated()
private void ThrowIfNotCreated()
{
if (string.IsNullOrWhiteSpace(name))
{
@ -165,7 +186,7 @@ namespace Squidex.Write.Apps
}
}
private void VerifyNotCreated()
private void ThrowIfCreated()
{
if (!string.IsNullOrWhiteSpace(name))
{
@ -173,27 +194,37 @@ namespace Squidex.Write.Apps
}
}
private void VerifyClientKeyFound(RevokeClientKey command)
private void ThrowIfClientFound(AttachClient command, Func<string> message)
{
if (clients.ContainsKey(command.ClientName))
{
var error = new ValidationError("Client name is alreay part of the app", "ClientName");
throw new ValidationException(message(), error);
}
}
private void ThrowIfClientNotFound(RevokeClient command, Func<string> message)
{
if (!clientKeys.Contains(command.ClientKey))
if (!clients.ContainsKey(command.ClientName))
{
var error = new ValidationError("Client key is not part of the app", "ClientKey");
var error = new ValidationError("Client is not part of the app", "ClientName");
throw new ValidationException("Cannot revoke client key", error);
throw new ValidationException(message(), error);
}
}
private void VerifyContributorFound(RemoveContributor command)
private void ThrowIfContributorNotFound(RemoveContributor command, Func<string> message)
{
if (!contributors.ContainsKey(command.ContributorId))
{
var error = new ValidationError("Contributor is not part of the app", "ContributorId");
throw new ValidationException("Cannot remove contributor", error);
throw new ValidationException(message(), error);
}
}
private void VerifyOwnership(Action<Dictionary<string, PermissionLevel>> change)
private void ThrowIfNoOwner(Action<Dictionary<string, PermissionLevel>> change, Func<string> message)
{
var contributorsCopy = new Dictionary<string, PermissionLevel>(contributors);
@ -203,7 +234,7 @@ namespace Squidex.Write.Apps
{
var error = new ValidationError("Contributor is the last owner", "ContributorId");
throw new ValidationException("Cannot assign contributor", error);
throw new ValidationException(message(), error);
}
}
}

10
src/Squidex.Write/Apps/Commands/CreateClientKey.cs → src/Squidex.Write/Apps/Commands/AttachClient.cs

@ -1,5 +1,5 @@
// ==========================================================================
// CreateClientKey.cs
// AttachClient.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
@ -13,17 +13,17 @@ using Squidex.Infrastructure.CQRS.Commands;
namespace Squidex.Write.Apps.Commands
{
public sealed class CreateClientKey : AppAggregateCommand, ITimestampCommand, IValidatable
public sealed class AttachClient : AppAggregateCommand, ITimestampCommand, IValidatable
{
public string ClientKey { get; set; }
public string ClientName { get; set; }
public DateTime Timestamp { get; set; }
public void Validate(IList<ValidationError> errors)
{
if (string.IsNullOrWhiteSpace(ClientKey))
if (!ClientName.IsSlug())
{
errors.Add(new ValidationError("Client key is not assigned", nameof(ClientKey)));
errors.Add(new ValidationError("Name must be a valid slug", nameof(ClientName)));
}
}
}

8
src/Squidex.Write/Apps/Commands/CreateApp.cs

@ -6,6 +6,7 @@
// All rights reserved.
// ==========================================================================
using System;
using System.Collections.Generic;
using Squidex.Infrastructure;
using Squidex.Infrastructure.CQRS.Commands;
@ -18,11 +19,16 @@ namespace Squidex.Write.Apps.Commands
public string SubjectId { get; set; }
public CreateApp()
{
AggregateId = Guid.NewGuid();
}
public void Validate(IList<ValidationError> errors)
{
if (!Name.IsSlug())
{
errors.Add(new ValidationError("DisplayName must be a valid slug", nameof(Name)));
errors.Add(new ValidationError("Name must be a valid slug", nameof(Name)));
}
}
}

10
src/Squidex.Write/Apps/Commands/RevokeClientKey.cs → src/Squidex.Write/Apps/Commands/RevokeClient.cs

@ -1,5 +1,5 @@
// ==========================================================================
// RevokeClientKey.cs
// RevokeClient.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
@ -11,15 +11,15 @@ using Squidex.Infrastructure;
namespace Squidex.Write.Apps.Commands
{
public class RevokeClientKey : AppAggregateCommand, IValidatable
public class RevokeClient : AppAggregateCommand, IValidatable
{
public string ClientKey { get; set; }
public string ClientName { get; set; }
public void Validate(IList<ValidationError> errors)
{
if (string.IsNullOrWhiteSpace(ClientKey))
if (!ClientName.IsSlug())
{
errors.Add(new ValidationError("Client key is not assigned", nameof(ClientKey)));
errors.Add(new ValidationError("Name must be a valid slug", nameof(ClientName)));
}
}
}

2
src/Squidex/Configurations/Identity/LazyClientStore.cs

@ -69,7 +69,7 @@ namespace Squidex.Configurations.Identity
{
ClientId = id,
ClientName = id,
ClientSecrets = app.ClientKeys.Select(x => new Secret(x.ClientKey, x.ExpiresUtc)).ToList(),
ClientSecrets = app.Clients.Select(x => new Secret(x.ClientName, x.ExpiresUtc)).ToList(),
AccessTokenLifetime = (int)TimeSpan.FromDays(30).TotalSeconds,
AllowedGrantTypes = GrantTypes.ClientCredentials,
AllowedScopes = new List<string>

17
src/Squidex/Configurations/Swagger/SwaggerUsage.cs

@ -21,12 +21,27 @@ namespace Squidex.Configurations.Swagger
{
public static class SwaggerUsage
{
/*
class TypeNameGenerator : DefaultTypeNameGenerator
{
public override string Generate(JsonSchema4 schema, string typeNameHint)
{
try
{
return TypeNameRegistry.GetName(schema.Ty);
}
catch
{
return base.Generate(schema, typeNameHint);
}
}
}*/
public static void UseMySwagger(this IApplicationBuilder app)
{
var options = app.ApplicationServices.GetService<IOptions<MyUrlsOptions>>().Value;
var settings =
new SwaggerOwinSettings { Title = "Squidex API Specification" }
new SwaggerOwinSettings { Title = "Squidex API Specification", IsAspNetCore = false}
.ConfigurePaths()
.ConfigureSchemaSettings()
.ConfigureIdentity(options);

53
src/Squidex/Configurations/Swagger/XmlResponseTypesProcessor.cs

@ -6,11 +6,15 @@
// All rights reserved.
// ==========================================================================
using System;
using System.Text.RegularExpressions;
using NJsonSchema;
using NJsonSchema.Infrastructure;
using NSwag;
using NSwag.CodeGeneration.SwaggerGenerators.WebApi.Processors;
using NSwag.CodeGeneration.SwaggerGenerators.WebApi.Processors.Contexts;
using Squidex.Modules.Api;
// ReSharper disable UseObjectOrCollectionInitializer
namespace Squidex.Configurations.Swagger
{
@ -20,6 +24,10 @@ namespace Squidex.Configurations.Swagger
public bool Process(OperationProcessorContext context)
{
var hasOkResponse = false;
var operation = context.OperationDescription.Operation;
var returnsDescription = context.MethodInfo.GetXmlDocumentation("returns") ?? string.Empty;
foreach (Match match in ResponseRegex.Matches(returnsDescription))
@ -28,17 +36,58 @@ namespace Squidex.Configurations.Swagger
SwaggerResponse response;
if (!context.OperationDescription.Operation.Responses.TryGetValue(statusCode, out response))
if (!operation.Responses.TryGetValue(statusCode, out response))
{
response = new SwaggerResponse();
context.OperationDescription.Operation.Responses[statusCode] = response;
operation.Responses[statusCode] = response;
}
response.Description = match.Groups["Description"].Value;
if (statusCode == "200")
{
hasOkResponse = true;
}
}
AddInternalErrorResponse(context, operation);
if (!hasOkResponse)
{
RemoveOkResponse(operation);
}
return true;
}
private static void AddInternalErrorResponse(OperationProcessorContext context, SwaggerOperation operation)
{
if (operation.Responses.ContainsKey("500"))
{
return;
}
var errorType = typeof(ErrorDto);
var errorSchema = JsonObjectTypeDescription.FromType(errorType, new Attribute[0], EnumHandling.String);
var response = new SwaggerResponse { Description = "Operation failed." };
response.Schema = context.SwaggerGenerator.GenerateAndAppendSchemaFromType(errorType, errorSchema.IsNullable, null);
operation.Responses.Add("500", response);
}
private static void RemoveOkResponse(SwaggerOperation operation)
{
SwaggerResponse response;
if (operation.Responses.TryGetValue("200", out response) &&
response.Description != null &&
response.Description.Contains("=>"))
{
operation.Responses.Remove("200");
}
}
}
}

54
src/Squidex/Modules/Api/Apps/AppClientKeysController.cs → src/Squidex/Modules/Api/Apps/AppClientsController.cs

@ -28,32 +28,31 @@ namespace Squidex.Modules.Api.Apps
[ApiExceptionFilter]
[ServiceFilter(typeof(AppFilterAttribute))]
[SwaggerTag("Apps")]
public class AppClientKeysController : ControllerBase
public class AppClientsController : ControllerBase
{
private readonly IAppProvider appProvider;
private readonly ClientKeyGenerator keyGenerator;
public AppClientKeysController(ICommandBus commandBus, IAppProvider appProvider, ClientKeyGenerator keyGenerator)
public AppClientsController(ICommandBus commandBus, IAppProvider appProvider)
: base(commandBus)
{
this.appProvider = appProvider;
this.keyGenerator = keyGenerator;
}
/// <summary>
/// Get app client keys.
/// Get app clients.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <returns>
/// 200 => Client keys returned.
/// 404 => App not found.
/// </returns>
/// <remarks>
/// Gets all configured client keys for the app with the specified name.
/// </remarks>
[HttpGet]
[Route("apps/{app}/client-keys/")]
[ProducesResponseType(typeof(ClientKeyDto[]), 200)]
public async Task<IActionResult> GetContributors(string app)
[Route("apps/{app}/clients/")]
[ProducesResponseType(typeof(ClientDto[]), 200)]
public async Task<IActionResult> GetClients(string app)
{
var entity = await appProvider.FindAppByNameAsync(app);
@ -62,33 +61,52 @@ namespace Squidex.Modules.Api.Apps
return NotFound();
}
var model = entity.ClientKeys.Select(x => SimpleMapper.Map(x, new ClientKeyDto())).ToList();
var response = entity.Clients.Select(x => SimpleMapper.Map(x, new ClientDto())).ToList();
return Ok(model);
return Ok(response);
}
/// <summary>
/// Create new client key.
/// Create a new app client.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">Client object that needs to be added to the app.</param>
/// <returns>
/// 201 => Client key generated.
/// 404 => App not found.
/// </returns>
/// <remarks>
/// Create a new client key for the app with the specified name.
/// The client key is auto generated on the server and returned.
/// </remarks>
[HttpPost]
[Route("apps/{app}/client-keys/")]
[SwaggerTags("Apps")]
[ProducesResponseType(typeof(ClientKeyCreatedDto[]), 201)]
public async Task<IActionResult> PostClientKey(string app)
[Route("apps/{app}/clients/")]
[ProducesResponseType(typeof(ClientDto[]), 201)]
public async Task<IActionResult> PostClient(string app, [FromBody] AttachClientDto request)
{
var clientKey = keyGenerator.GenerateKey();
var context = await CommandBus.PublishAsync(SimpleMapper.Map(request, new AttachClient()));
var result = context.Result<AppClient>();
await CommandBus.PublishAsync(new CreateClientKey { ClientKey = clientKey });
var response = SimpleMapper.Map(result, new ClientDto());
return StatusCode(201, new ClientKeyCreatedDto { ClientKey = clientKey });
return StatusCode(201, response);
}
/// <summary>
/// Revoke an app client
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="client">Client object that needs to be added to the app.</param>
/// <returns>
/// 204 => Client revoked.
/// </returns>
[HttpDelete]
[Route("apps/{app}/clients/{client}/")]
public async Task<IActionResult> DeleteClient(string app, string client)
{
await CommandBus.PublishAsync(new RevokeClient { ClientName = client });
return NoContent();
}
}
}

28
src/Squidex/Modules/Api/Apps/AppContributorsController.cs

@ -38,11 +38,12 @@ namespace Squidex.Modules.Api.Apps
}
/// <summary>
/// Get contributors for the app.
/// Get app contributors.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <returns>
/// 200 => App contributors returned.
/// 404 => App not found.
/// </returns>
[HttpGet]
[Route("apps/{app}/contributors/")]
@ -56,19 +57,20 @@ namespace Squidex.Modules.Api.Apps
return NotFound();
}
var model = entity.Contributors.Select(x => SimpleMapper.Map(x, new ContributorDto())).ToList();
var response = entity.Contributors.Select(x => SimpleMapper.Map(x, new ContributorDto())).ToList();
return Ok(model);
return Ok(response);
}
/// <summary>
/// Assign contributor to the app.
/// Assign contributor to app.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="model">Contributor object that needs to be added to the app.</param>
/// <returns>
/// 200 => User assigned to app.
/// 204 => User assigned to app.
/// 400 => User is already assigned to the app or not found.
/// 404 => App not found.
/// </returns>
[HttpPost]
[Route("apps/{app}/contributors/")]
@ -77,26 +79,26 @@ namespace Squidex.Modules.Api.Apps
{
await CommandBus.PublishAsync(SimpleMapper.Map(model, new AssignContributor()));
return Ok();
return NoContent();
}
/// <summary>
/// Removes contributor from app.
/// Remove contributor from app.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="contributorId"></param>
/// <param name="id">The id of the contributor.</param>
/// <returns>
/// 200 => User removed from app.
/// 204 => User removed from app.
/// 400 => User is not assigned to the app.
/// </returns>
[HttpDelete]
[Route("apps/{app}/contributors/{contributorId}/")]
[Route("apps/{app}/contributors/{id}/")]
[ProducesResponseType(typeof(ErrorDto[]), 400)]
public async Task<IActionResult> DeleteContributor(string app, string contributorId)
public async Task<IActionResult> DeleteContributor(string app, string id)
{
await CommandBus.PublishAsync(new RemoveContributor { ContributorId = contributorId });
await CommandBus.PublishAsync(new RemoveContributor { ContributorId = id });
return Ok();
return NoContent();
}
}
}

15
src/Squidex/Modules/Api/Apps/AppController.cs

@ -39,7 +39,7 @@ namespace Squidex.Modules.Api.Apps
}
/// <summary>
/// Gets your apps.
/// Get your apps.
/// </summary>
/// <returns>
/// 200 => Apps returned.
@ -56,7 +56,7 @@ namespace Squidex.Modules.Api.Apps
var subject = HttpContext.User.OpenIdSubject();
var schemas = await appRepository.QueryAllAsync(subject);
var models = schemas.Select(s =>
var response = schemas.Select(s =>
{
var dto = SimpleMapper.Map(s, new AppDto());
@ -65,13 +65,13 @@ namespace Squidex.Modules.Api.Apps
return dto;
}).ToList();
return Ok(models);
return Ok(response);
}
/// <summary>
/// Create a new app.
/// </summary>
/// <param name="model">The app object that needs to be added to squided.</param>
/// <param name="model">The app object that needs to be added to squidex.</param>
/// <returns>
/// 201 => App created.
/// 400 => App object is not valid.
@ -88,11 +88,12 @@ namespace Squidex.Modules.Api.Apps
[ProducesResponseType(typeof(ErrorDto), 409)]
public async Task<IActionResult> PostApp([FromBody] CreateAppDto model)
{
var command = SimpleMapper.Map(model, new CreateApp { AggregateId = Guid.NewGuid() });
var command = SimpleMapper.Map(model, new CreateApp());
await CommandBus.PublishAsync(command);
var context = await CommandBus.PublishAsync(command);
var result = context.Result<Guid>();
return CreatedAtAction(nameof(GetApps), new EntityCreatedDto { Id = command.AggregateId.ToString() });
return CreatedAtAction(nameof(GetApps), new EntityCreatedDto { Id = result.ToString() });
}
}
}

8
src/Squidex/Modules/Api/Apps/AppLanguagesController.cs

@ -43,6 +43,7 @@ namespace Squidex.Modules.Api.Apps
/// <param name="app">The name of the app.</param>
/// <returns>
/// 200 => Language configuration returned.
/// 404 => App not found.
/// </returns>
[HttpGet]
[Route("apps/{app}/languages/")]
@ -62,13 +63,14 @@ namespace Squidex.Modules.Api.Apps
}
/// <summary>
/// Configures the app languages.
/// Configure the app languages.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="model">The language configuration for the app.</param>
/// <returns>
/// 201 => App languages configured.
/// 204 => App languages configured.
/// 400 => Language configuration is empty or contains an invalid language.
/// 404 => App not found.
/// </returns>
/// <remarks>
/// The ordering of the languages matterns: When you retrieve a content with a localized content squidex tries
@ -82,7 +84,7 @@ namespace Squidex.Modules.Api.Apps
{
await CommandBus.PublishAsync(SimpleMapper.Map(model, new ConfigureLanguages()));
return Ok();
return NoContent();
}
}
}

9
src/Squidex/Modules/Api/Apps/Models/ClientKeyCreatedDto.cs → src/Squidex/Modules/Api/Apps/Models/AttachClientDto.cs

@ -1,5 +1,5 @@
// ==========================================================================
// ClientKeyCreatedDto.cs
// AttachClientDto.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
@ -10,12 +10,13 @@ using System.ComponentModel.DataAnnotations;
namespace Squidex.Modules.Api.Apps.Models
{
public sealed class ClientKeyCreatedDto
public class AttachClientDto
{
/// <summary>
/// The created client key.
/// The name of the client.
/// </summary>
[Required]
public string ClientKey { get; set; }
[RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")]
public string ClientName { get; set; }
}
}

12
src/Squidex/Modules/Api/Apps/Models/ClientKeyDto.cs → src/Squidex/Modules/Api/Apps/Models/ClientDto.cs

@ -11,13 +11,19 @@ using System.ComponentModel.DataAnnotations;
namespace Squidex.Modules.Api.Apps.Models
{
public sealed class ClientKeyDto
public sealed class ClientDto
{
/// <summary>
/// The client key.
/// The client name.
/// </summary>
[Required]
public string ClientKey { get; set; }
public string ClientName { get; set; }
/// <summary>
/// The client secret.
/// </summary>
[Required]
public string ClientSecret { get; set; }
/// <summary>
/// The date and time when the client key expires.

2
src/Squidex/Modules/Api/Apps/Models/CreateAppDto.cs

@ -13,7 +13,7 @@ namespace Squidex.Modules.Api.Apps.Models
public sealed class CreateAppDto
{
/// <summary>
/// The new name of the app.
/// The name of the app.
/// </summary>
[Required]
[RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")]

4
src/Squidex/Modules/Api/Languages/LanguagesController.cs

@ -38,9 +38,9 @@ namespace Squidex.Modules.Api.Languages
[ProducesResponseType(typeof(string[]), 200)]
public IActionResult GetLanguages()
{
var model = Language.AllLanguages.Select(x => SimpleMapper.Map(x, new LanguageDto())).ToList();
var response = Language.AllLanguages.Select(x => SimpleMapper.Map(x, new LanguageDto())).ToList();
return Ok(model);
return Ok(response);
}
}
}

23
src/Squidex/Modules/Api/Schemas/Models/CreateFieldDto.cs

@ -1,23 +0,0 @@
// ==========================================================================
// CreateFieldDto.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Squidex.Modules.Api.Schemas.Models
{
public class CreateFieldDto
{
[JsonProperty("$type")]
public string Type { get; set; }
public string Name { get; set; }
public JObject Properties { get; set; }
}
}

9
src/Squidex/Modules/Api/Schemas/Models/CreateSchemaDto.cs

@ -6,14 +6,17 @@
// All rights reserved.
// ==========================================================================
using Squidex.Core.Schemas;
using System.ComponentModel.DataAnnotations;
namespace Squidex.Modules.Api.Schemas.Models
{
public class CreateSchemaDto
{
/// <summary>
/// The name of the schema.
/// </summary>
[Required]
[RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")]
public string Name { get; set; }
public FieldProperties Properties { get; set; }
}
}

52
src/Squidex/Modules/Api/Schemas/Models/FieldDto.cs

@ -0,0 +1,52 @@
// ==========================================================================
// FieldDto.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization;
using Newtonsoft.Json;
using NJsonSchema.Converters;
using Squidex.Modules.Api.Schemas.Models.Fields;
namespace Squidex.Modules.Api.Schemas.Models
{
[JsonConverter(typeof(JsonInheritanceConverter), "fieldType")]
[KnownType(typeof(NumberField))]
[KnownType(typeof(StringField))]
public class FieldDto
{
/// <summary>
/// The name of the field. Must be unique within the schema.
/// </summary>
[Required]
[RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")]
public string Name { get; set; }
/// <summary>
/// Optional label for the editor.
/// </summary>
[StringLength(100)]
public string Label { get; set; }
/// <summary>
/// Hints to describe the schema.
/// </summary>
[StringLength(1000)]
public string Hints { get; set; }
/// <summary>
/// Placeholder to show when no value has been entered.
/// </summary>
[StringLength(100)]
public string Placeholder { get; set; }
/// <summary>
/// Indicates if the field is required.
/// </summary>
public bool IsRequired { get; set; }
}
}

33
src/Squidex/Modules/Api/Schemas/Models/Fields/NumberField.cs

@ -0,0 +1,33 @@
// ==========================================================================
// NumberField.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
namespace Squidex.Modules.Api.Schemas.Models.Fields
{
public class NumberField : FieldDto
{
/// <summary>
/// The default value for the field value.
/// </summary>
public double? DefaultValue { get; set; }
/// <summary>
/// The maximum allowed value for the field value.
/// </summary>
public double? MaxValue { get; set; }
/// <summary>
/// The minimum allowed value for the field value.
/// </summary>
public double? MinValue { get; set; }
/// <summary>
/// The allowed values for the field value.
/// </summary>
public double[] AllowedValues { get; set; }
}
}

38
src/Squidex/Modules/Api/Schemas/Models/Fields/StringField.cs

@ -0,0 +1,38 @@
// ==========================================================================
// StringField.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
namespace Squidex.Modules.Api.Schemas.Models.Fields
{
public sealed class StringField : FieldDto
{
/// <summary>
/// The default value for the field value.
/// </summary>
public string DefaultValue { get; set; }
/// <summary>
/// The pattern to enforce a specific format for the field value.
/// </summary>
public string Pattern { get; set; }
/// <summary>
/// The minimum allowed length for the field value.
/// </summary>
public int? MinLength { get; set; }
/// <summary>
/// The maximum allowed length for the field value.
/// </summary>
public int? MaxLength { get; set; }
/// <summary>
/// The allowed values for the field value.
/// </summary>
public double[] AllowedValues { get; set; }
}
}

57
src/Squidex/Modules/Api/Schemas/Models/SchemaDetailsDto.cs

@ -0,0 +1,57 @@
// ==========================================================================
// SchemaDetailsDto.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Squidex.Modules.Api.Schemas.Models
{
public sealed class SchemaDetailsDto
{
/// <summary>
/// The id of the schema.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// The name of the schema. Unique within the app.
/// </summary>
[Required]
[RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")]
public string Name { get; set; }
/// <summary>
/// The list of fields.
/// </summary>
[Required]
public List<FieldDto> Fields { get; set; }
/// <summary>
/// Optional label for the editor.
/// </summary>
[StringLength(100)]
public string Label { get; set; }
/// <summary>
/// Hints to describe the schema.
/// </summary>
[StringLength(1000)]
public string Hints { get; set; }
/// <summary>
/// The date and time when the schema has been creaed.
/// </summary>
public DateTime Created { get; set; }
/// <summary>
/// The date and time when the schema has been modified last.
/// </summary>
public DateTime LastModified { get; set; }
}
}

17
src/Squidex/Modules/Api/Schemas/Models/ListSchemaDto.cs → src/Squidex/Modules/Api/Schemas/Models/SchemaDto.cs

@ -7,17 +7,32 @@
// ==========================================================================
using System;
using System.ComponentModel.DataAnnotations;
namespace Squidex.Modules.Api.Schemas.Models
{
public class ListSchemaDto
public class SchemaDto
{
/// <summary>
/// The id of the schema.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// The name of the schema. Unique within the app.
/// </summary>
[Required]
[RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")]
public string Name { get; set; }
/// <summary>
/// The date and time when the schema has been creaed.
/// </summary>
public DateTime Created { get; set; }
/// <summary>
/// The date and time when the schema has been modified last.
/// </summary>
public DateTime LastModified { get; set; }
}
}

17
src/Squidex/Modules/Api/Schemas/Models/UpdateFieldDto.cs

@ -1,17 +0,0 @@
// ==========================================================================
// UpdateFieldDto.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using Newtonsoft.Json.Linq;
namespace Squidex.Modules.Api.Schemas.Models
{
public class UpdateFieldDto
{
public JObject Properties { get; set; }
}
}

14
src/Squidex/Modules/Api/Schemas/Models/UpdateSchemaDto.cs

@ -6,12 +6,22 @@
// All rights reserved.
// ==========================================================================
using Squidex.Core.Schemas;
using System.ComponentModel.DataAnnotations;
namespace Squidex.Modules.Api.Schemas.Models
{
public class UpdateSchemaDto
{
public SchemaProperties Properties { get; set; }
/// <summary>
/// Optional label for the editor.
/// </summary>
[StringLength(100)]
public string Label { get; set; }
/// <summary>
/// Hints to describe the schema.
/// </summary>
[StringLength(1000)]
public string Hints { get; set; }
}
}

183
src/Squidex/Modules/Api/Schemas/SchemaFieldsController.cs

@ -18,78 +18,197 @@ using Squidex.Write.Schemas.Commands;
namespace Squidex.Modules.Api.Schemas
{
[Authorize]
/// <summary>
/// Manages and retrieves information about schemas.
/// </summary>
[Authorize("app-owner,app-developer")]
[ApiExceptionFilter]
[ServiceFilter(typeof(AppFilterAttribute))]
[SwaggerIgnore]
public class SchemasFieldsController : ControllerBase
[SwaggerTag("Schemas")]
public class SchemaFieldsController : ControllerBase
{
public SchemasFieldsController(ICommandBus commandBus)
public SchemaFieldsController(ICommandBus commandBus)
: base(commandBus)
{
}
/// <summary>
/// Create a new schema field.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="model">The field object that needs to be added to the schema.</param>
/// <returns>
/// 201 => Field created.
/// 409 => Field name already in use.
/// 404 => App or schema not found.
/// 404 => Field properties not valid.
/// </returns>
[HttpPost]
[Route("apps/{app}/schemas/{name}/fields/")]
public Task Add(string name, [FromBody] CreateFieldDto model)
[ProducesResponseType(typeof(EntityCreatedDto), 201)]
[ProducesResponseType(typeof(ErrorDto), 409)]
[ProducesResponseType(typeof(ErrorDto), 400)]
public async Task<IActionResult> PostField(string app, string name, [FromBody] FieldDto model)
{
var command = SimpleMapper.Map(model, new AddField());
return CommandBus.PublishAsync(command);
var context = await CommandBus.PublishAsync(command);
var result = context.Result<long>();
return StatusCode(201, new EntityCreatedDto { Id = result.ToString() });
}
/// <summary>
/// Update a schema field.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="model">The field object that needs to be added to the schema.</param>
/// <returns>
/// 204 => Field created.
/// 409 => Field name already in use.
/// 404 => App, schema or field not found.
/// 404 => Field properties not valid.
/// </returns>
[HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{fieldId:long}/")]
public Task Update(string name, long fieldId, [FromBody] UpdateFieldDto model)
[Route("apps/{app}/schemas/{name}/fields/{id:long}/")]
[ProducesResponseType(typeof(ErrorDto), 409)]
[ProducesResponseType(typeof(ErrorDto), 400)]
public async Task<IActionResult> PutField(string app, string name, long id, [FromBody] FieldDto model)
{
var command = SimpleMapper.Map(model, new UpdateField());
return CommandBus.PublishAsync(command);
await CommandBus.PublishAsync(command);
return NoContent();
}
/// <summary>
/// Hide a schema field.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the field to hide.</param>
/// <returns>
/// 400 => Field already hidden.
/// 204 => Schema field hidden.
/// 404 => App, schema or field not found.
/// </returns>
/// <remarks>
/// A hidden field is not part of the API response, but can still be edited in the portal.
/// </remarks>
[HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{fieldId:long}/hide/")]
public Task Hide(string name, long fieldId)
[Route("apps/{app}/schemas/{name}/fields/{id:long}/hide/")]
[ProducesResponseType(typeof(ErrorDto), 400)]
public async Task<IActionResult> HideField(string app, string name, long id)
{
var command = new HideField { FieldId = fieldId };
var command = new HideField { FieldId = id };
return CommandBus.PublishAsync(command);
await CommandBus.PublishAsync(command);
return NoContent();
}
/// <summary>
/// Show a schema field.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the field to shows.</param>
/// <returns>
/// 400 => Field already visible.
/// 204 => Schema field shown.
/// 404 => App, schema or field not found.
/// </returns>
/// <remarks>
/// A hidden field is not part of the API response, but can still be edited in the portal.
/// </remarks>
[HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{fieldId:long}/show/")]
public Task Show(string name, long fieldId)
[Route("apps/{app}/schemas/{name}/fields/{id:long}/show/")]
[ProducesResponseType(typeof(ErrorDto), 400)]
public async Task<IActionResult> ShowField(string app, string name, long id)
{
var command = new ShowField { FieldId = fieldId };
var command = new ShowField { FieldId = id };
await CommandBus.PublishAsync(command);
return CommandBus.PublishAsync(command);
return NoContent();
}
/// <summary>
/// Enable a schema field.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the field to enable.</param>
/// <returns>
/// 400 => Field already enabled.
/// 204 => Schema field enabled.
/// 404 => App, schema or field not found.
/// </returns>
/// <remarks>
/// A disabled field cannot not be edited in the squidex portal anymore,
/// but will be part of the API response.
/// </remarks>
[HttpPut]
[Route("schemas/{name}/fields/{fieldId:long}/enable/")]
public Task Enable(string name, long fieldId)
[Route("apps/{app}/schemas/{name}/fields/{id:long}/enable/")]
[ProducesResponseType(typeof(ErrorDto), 400)]
public async Task<IActionResult> EnableField(string app, string name, long id)
{
var command = new EnableField { FieldId = fieldId };
var command = new EnableField { FieldId = id };
await CommandBus.PublishAsync(command);
return CommandBus.PublishAsync(command);
return NoContent();
}
/// <summary>
/// Disable a schema field.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the field to disable.</param>
/// <returns>
/// 400 => Field already disabled.
/// 204 => Schema field disabled.
/// 404 => App, schema or field not found.
/// </returns>
/// <remarks>
/// A disabled field cannot not be edited in the squidex portal anymore,
/// but will be part of the API response.
/// </remarks>
[HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{fieldId:long}/disable/")]
public Task Disable(string name, long fieldId)
[Route("apps/{app}/schemas/{name}/fields/{id:long}/disable/")]
[ProducesResponseType(typeof(ErrorDto), 400)]
public async Task<IActionResult> DisableField(string app, string name, long id)
{
var command = new DisableField { FieldId = fieldId };
var command = new DisableField { FieldId = id };
return CommandBus.PublishAsync(command);
await CommandBus.PublishAsync(command);
return NoContent();
}
/// <summary>
/// Delete a schema field.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the field to disable.</param>
/// <returns>
/// 204 => Schema field deleted.
/// 404 => App, schema or field not found.
/// </returns>
[HttpDelete]
[Route("apps/{app}/schemas/{name}/fields/{fieldId:long}/")]
public Task Delete(string name, long fieldId)
[Route("apps/{app}/schemas/{name}/fields/{id:long}/")]
public async Task<IActionResult> DeleteField(string app, string name, long id)
{
var command = new DeleteField { FieldId = fieldId };
var command = new DeleteField { FieldId = id };
await CommandBus.PublishAsync(command);
return CommandBus.PublishAsync(command);
return NoContent();
}
}
}

75
src/Squidex/Modules/Api/Schemas/SchemasController.cs

@ -7,7 +7,6 @@
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
@ -18,15 +17,17 @@ using Squidex.Infrastructure.Reflection;
using Squidex.Modules.Api.Schemas.Models;
using Squidex.Pipeline;
using Squidex.Read.Schemas.Repositories;
using Squidex.Store.MongoDb.Schemas.Models;
using Squidex.Write.Schemas.Commands;
namespace Squidex.Modules.Api.Schemas
{
[Authorize]
/// <summary>
/// Manages and retrieves information about schemas.
/// </summary>
[Authorize("app-owner,app-developer")]
[ApiExceptionFilter]
[ServiceFilter(typeof(AppFilterAttribute))]
[SwaggerIgnore]
[SwaggerTag("Schemas")]
public class SchemasController : ControllerBase
{
private readonly ISchemaRepository schemaRepository;
@ -37,18 +38,36 @@ namespace Squidex.Modules.Api.Schemas
this.schemaRepository = schemaRepository;
}
/// <summary>
/// Get app schemas.
/// </summary>
/// <returns>
/// 200 => Schemas returned.
/// </returns>
[HttpGet]
[Route("apps/{app}/schemas/")]
public async Task<List<ListSchemaDto>> Query()
[ProducesResponseType(typeof(SchemaDto[]), 200)]
public async Task<IActionResult> GetSchemas()
{
var schemas = await schemaRepository.QueryAllAsync(AppId);
return schemas.Select(s => SimpleMapper.Map(s, new ListSchemaDto())).ToList();
var model = schemas.Select(s => SimpleMapper.Map(s, new SchemaDto())).ToList();
return Ok(model);
}
/// <summary>
/// Get a schema by name.
/// </summary>
/// <param name="name">The name of the schema to retrieve.</param>
/// <returns>
/// 200 => Schema found.
/// 404 => Schema not found.
/// </returns>
[HttpGet]
[Route("apps/{app}/schemas/{name}/")]
public async Task<ActionResult> Get(string name)
[ProducesResponseType(typeof(SchemaDetailsDto[]), 200)]
public async Task<IActionResult> GetSchema(string name)
{
var entity = await schemaRepository.FindSchemaAsync(AppId, name);
@ -57,25 +76,45 @@ namespace Squidex.Modules.Api.Schemas
return NotFound();
}
var model = SchemaDto.Create(entity.Schema);
return Ok(model);
return Ok(null);
}
/// <summary>
/// Create a new schema.
/// </summary>
/// <param name="model">The schema object that needs to be added to the app.</param>
/// <param name="app">The name of the app to create the schema to.</param>
/// <returns>
/// 201 => Schema created.
/// 400 => Schema name is not valid.
/// 409 => Schema name already in use.
/// </returns>
[HttpPost]
[Route("apps/{app}/schemas/")]
public async Task<ActionResult> Create([FromBody] CreateSchemaDto model)
[ProducesResponseType(typeof(EntityCreatedDto), 201)]
[ProducesResponseType(typeof(ErrorDto), 400)]
[ProducesResponseType(typeof(ErrorDto), 409)]
public async Task<IActionResult> PostSchema(string app, [FromBody] CreateSchemaDto model)
{
var command = SimpleMapper.Map(model, new CreateSchema { AggregateId = Guid.NewGuid() });
await CommandBus.PublishAsync(command);
return CreatedAtAction("Get", new { name = model.Name }, new EntityCreatedDto { Id = command.Name });
return CreatedAtAction(nameof(GetSchema), new { name = model.Name }, new EntityCreatedDto { Id = command.Name });
}
/// <summary>
/// Update a schema.
/// </summary>
/// <param name="app">The app where the schema is a part of.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="model">The schema object that needs to updated.</param>
/// <returns>
/// 204 => Schema updated.
/// </returns>
[HttpPut]
[Route("apps/{app}/schemas/{name}/")]
public async Task<ActionResult> Update(string name, [FromBody] UpdateSchemaDto model)
public async Task<IActionResult> PutSchema(string app, string name, [FromBody] UpdateSchemaDto model)
{
var command = SimpleMapper.Map(model, new UpdateSchema());
@ -84,9 +123,17 @@ namespace Squidex.Modules.Api.Schemas
return NoContent();
}
/// <summary>
/// Delete a schema.
/// </summary>
/// <param name="app">The app where the schema is a part of.</param>
/// <param name="name">The name of the schema to delete.</param>
/// <returns>
/// 204 => Schema has been deleted.
/// </returns>
[HttpDelete]
[Route("apps/{app}/schemas/{name}/")]
public async Task<ActionResult> Delete(string name)
public async Task<IActionResult> DeleteSchema(string app, string name)
{
await CommandBus.PublishAsync(new DeleteSchema());

2
src/Squidex/Modules/Api/Users/Models/UserDto.cs

@ -13,7 +13,7 @@ namespace Squidex.Modules.Api.Users.Models
public sealed class UserDto
{
/// <summary>
/// The id of the user. Unique value.
/// The id of the user.
/// </summary>
[Required]
public string Id { get; set; }

8
src/Squidex/Modules/Api/Users/UsersController.cs

@ -50,9 +50,9 @@ namespace Squidex.Modules.Api.Users
{
var entities = await userRepository.FindUsersByQuery(query ?? string.Empty);
var model = entities.Select(x => SimpleMapper.Map(x, new UserDto())).ToList();
var response = entities.Select(x => SimpleMapper.Map(x, new UserDto())).ToList();
return Ok(model);
return Ok(response);
}
/// <summary>
@ -75,9 +75,9 @@ namespace Squidex.Modules.Api.Users
return NotFound();
}
var model = SimpleMapper.Map(entity, new UserDto());
var response = SimpleMapper.Map(entity, new UserDto());
return Ok(model);
return Ok(response);
}
}
}

4
src/Squidex/app/app.module.ts

@ -14,7 +14,7 @@ import { DndModule } from 'ng2-dnd';
import {
ApiUrlConfig,
AppClientKeysService,
AppClientsService,
AppContributorsService,
AppLanguagesService,
AppMustExistGuard,
@ -62,7 +62,7 @@ const baseUrl = window.location.protocol + '//' + window.location.host + '/';
AppComponent
],
providers: [
AppClientKeysService,
AppClientsService,
AppContributorsService,
AppLanguagesService,
AppsStoreService,

6
src/Squidex/app/app.routes.ts

@ -11,8 +11,8 @@ import * as Ng2Router from '@angular/router';
import {
AppsPageComponent,
AppAreaComponent,
ClientsPageComponent,
ContributorsPageComponent,
CredentialsPageComponent,
DashboardPageComponent,
InternalAreaComponent,
HomePageComponent,
@ -65,8 +65,8 @@ export const routes: Ng2Router.Routes = [
component: ContributorsPageComponent
},
{
path: 'credentials',
component: CredentialsPageComponent
path: 'clients',
component: ClientsPageComponent
},
{
path: 'languages',

2
src/Squidex/app/components/internal/app/left-menu.component.html

@ -28,7 +28,7 @@
<a class="nav-link" routerLink="../contributors" routerLinkActive="active">Contributors</a>
</li>
<li class="subnav-item">
<a class="nav-link" routerLink="../credentials" routerLinkActive="active">Credentials</a>
<a class="nav-link" routerLink="../clients" routerLinkActive="active">Clients</a>
</li>
<li class="subnav-item">
<a class="nav-link" routerLink="../languages" routerLinkActive="active">Languages</a>

91
src/Squidex/app/components/internal/app/settings/clients-page.component.html

@ -0,0 +1,91 @@
<div class="layout">
<div class="layout-left">
<sqx-left-menu></sqx-left-menu>
</div>
<div class="layout-middle">
<div class="layout-middle-header">
<h1>
<i class="layout-title-icon icon-settings"></i> Clients
</h1>
</div>
<div class="layout-middle-content">
<div class="card">
<div class="card-block">
<div class="clients-empty" *ngIf="appClients && appClients.length === 0">
No client created yet.
</div>
<table class="table table-borderless-top table-fixed clients-table">
<colgroup>
<col style="width: 100%" />
<col style="width: 160px" />
</colgroup>
<tr *ngFor="let client of appClients">
<td>
<table class="table table-middle table-sm table-borderless table-fixed client-info">
<colgroup>
<col style="width: 160px; text-align: right;" />
<col style="width: 100%" />
</colgroup>
<tr>
<td>Client Name:</td>
<td>
<input readonly class="form-control" [attr.value]="fullAppName(client)" />
</td>
</tr>
<tr>
<td>Client Secret:</td>
<td>
<input readonly class="form-control" [attr.value]="client.clientSecret" />
</td>
</tr>
<tr>
<td>Expires:</td>
<td class="client-expires">
{{client.expiresUtc}}
</td>
</tr>
</table>
</td>
<td>
<button class="btn btn-block btn-danger client-delete" (click)="revokeClient(client)">Revoke</button>
<button class="btn btn-block btn-default">Create Token</button>
</td>
</tr>
</table>
</div>
<div class="card-footer">
<div *ngIf="creationError" [@fade]>
<div class="form-error">
{{creationError}}
</div>
</div>
<form class="form-inline" [formGroup]="createForm" (submit)="attachClient()">
<div class="errors-box" *ngIf="createForm.get('name').invalid && createForm.get('name').dirty">
<div class="errors">
<span *ngIf="createForm.get('name').hasError('required')">
Name is required.
</span>
<span *ngIf="createForm.get('name').hasError('maxlength')">
Name can not have more than 40 characters.
</span>
<span *ngIf="createForm.get('name').hasError('pattern')">
Name can contain lower case letters (a-z), numbers and dashes (not at the end).
</span>
</div>
</div>
<div class="form-group">
<input type="text" class="form-control" id="app-name" formControlName="name" placeholder="Enter client name" />
</div>
<button type="submit" class="btn btn-success" [disabled]="createForm.invalid">Create Client</button>
</form>
</div>
</div>
</div>
</div>
</div>

44
src/Squidex/app/components/internal/app/settings/clients-page.component.scss

@ -0,0 +1,44 @@
@import '_vars';
@import '_mixins';
.layout-title-icon {
color: $color-section-settings;
}
.card {
& {
max-width: 700px;
}
&-block {
padding-left: .5rem;
padding-right: .5rem;
}
}
.clients {
&-empty {
color: $color-empty;
font-size: 1.2rem;
font-weight: lighter;
padding: .2rem 1rem;
}
&-table {
margin: 0;
}
}
.client {
&-delete {
margin-top: .3rem;
}
&-info {
margin: 0;
}
&-expires {
padding: .3rem .8rem;
}
}

107
src/Squidex/app/components/internal/app/settings/clients-page.component.ts

@ -0,0 +1,107 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Sebastian Stehle. All rights reserved
*/
import * as Ng2 from '@angular/core';
import * as Ng2Forms from '@angular/forms';
import {
AppsStoreService,
AppClientDto,
AppClientCreateDto,
AppClientsService,
fadeAnimation,
TitleService
} from 'shared';
@Ng2.Component({
selector: 'sqx-clients-page',
styles,
template,
animations: [
fadeAnimation()
]
})
export class ClientsPageComponent implements Ng2.OnInit {
private appSubscription: any | null = null;
private appName: string | null = null;
public appClients: AppClientDto[];
public creationError = '';
public createForm =
this.formBuilder.group({
name: ['',
[
Ng2Forms.Validators.required,
Ng2Forms.Validators.maxLength(40),
Ng2Forms.Validators.pattern('[a-z0-9]+(\-[a-z0-9]+)*')
]]
});
constructor(
private readonly titles: TitleService,
private readonly appsStore: AppsStoreService,
private readonly appClientsService: AppClientsService,
private readonly formBuilder: Ng2Forms.FormBuilder
) {
}
public ngOnInit() {
this.appSubscription =
this.appsStore.selectedApp.subscribe(app => {
if (app) {
this.appName = app.name;
this.titles.setTitle('{appName} | Settings | Clients', { appName: app.name });
this.appClientsService.getClients(app.name).subscribe(clients => {
this.appClients = clients;
});
}
});
}
public ngOnDestroy() {
this.appSubscription.unsubscribe();
}
public fullAppName(client: AppClientDto): string {
return this.appName + ':' + client.clientName;
}
public revokeClient(client: AppClientDto) {
this.appClientsService.deleteClient(this.appName, client.clientName).subscribe();
this.appClients.splice(this.appClients.indexOf(client), 1);
}
public attachClient() {
this.createForm.markAsDirty();
if (this.createForm.valid) {
this.createForm.disable();
const dto = new AppClientCreateDto(this.createForm.controls['name'].value);
this.appClientsService.postClient(this.appName, dto)
.subscribe(client => {
this.reset();
this.appClients.push(client);
}, error => {
this.reset();
this.creationError = error;
});
}
}
private reset() {
this.createForm.reset();
this.createForm.enable();
this.creationError = '';
}
}

6
src/Squidex/app/components/internal/app/settings/contributors-page.component.html

@ -11,7 +11,7 @@
<div class="layout-middle-content">
<div class="card">
<div class="card-block">
<table class="table table-borderless table-fixed">
<table class="table table-borderless-top table-fixed contributors-table">
<colgroup>
<col style="width: 100%" />
<col style="width: 150px" />
@ -41,7 +41,7 @@
</table>
</div>
<div class="card-footer">
<form class="form-inline" (submit)="addContributor()" >
<form class="form-inline" (submit)="assignContributor()" >
<div class="form-group">
<ng2-completer
[autoMatch]="true"
@ -58,7 +58,7 @@
</ng2-completer>
</div>
<button type="submit" class="btn btn-primary" [disabled]="!selectedUser">Add</button>
<button type="submit" class="btn btn-success" [disabled]="!selectedUser">Add Contributor</button>
</form>
</div>
</div>

6
src/Squidex/app/components/internal/app/settings/contributors-page.component.scss

@ -9,6 +9,12 @@
max-width: 700px;
}
.contributors {
&-table {
margin: 0;
}
}
.user {
&-picture {
@include circle(2.2rem);

2
src/Squidex/app/components/internal/app/settings/contributors-page.component.ts

@ -119,7 +119,7 @@ export class ContributorsPageComponent implements Ng2.OnInit {
this.appSubscription.unsubscribe();
}
public addContributor() {
public assignContributor() {
if (!this.selectedUser) {
return;
}

40
src/Squidex/app/components/internal/app/settings/credentials-page.component.html

@ -1,40 +0,0 @@
<div class="layout">
<div class="layout-left">
<sqx-left-menu></sqx-left-menu>
</div>
<div class="layout-middle">
<div class="layout-middle-header">
<h1>
<i class="layout-title-icon icon-settings"></i> Credentials
</h1>
</div>
<div class="layout-middle-content">
<div class="card">
<div class="card-block">
<table class="table table-borderless table-fixed">
<colgroup>
<col style="width: 100%" />
<col style="width: 160px" />
<col style="width: 120px" />
</colgroup>
<tr *ngFor="let clientKey of appClientKeys">
<td>
<input readonly class="form-control" [ngModel]="clientKey.clientKey" />
</td>
<td>
<button class="btn btn-block btn-default">Create Token</button>
</td>
<td>
<button class="btn btn-block btn-danger">Revoke</button>
</td>
</tr>
</table>
</div>
<div class="card-footer">
<button type="submit" class="btn btn-primary" (click)="createClientKey()">Create Client Key</button>
</div>
</div>
</div>
</div>
</div>

17
src/Squidex/app/components/internal/app/settings/credentials-page.component.scss

@ -1,17 +0,0 @@
@import '_vars';
@import '_mixins';
.layout-title-icon {
color: $color-section-settings;
}
.card {
& {
max-width: 700px;
}
&-block {
padding-left: .5rem;
padding-right: .5rem;
}
}

60
src/Squidex/app/components/internal/app/settings/credentials-page.component.ts

@ -1,60 +0,0 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Sebastian Stehle. All rights reserved
*/
import * as Ng2 from '@angular/core';
import {
AppsStoreService,
AppClientKeyDto,
AppClientKeysService,
TitleService
} from 'shared';
@Ng2.Component({
selector: 'sqx-credentials-page',
styles,
template
})
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 appClientKeysService: AppClientKeysService
) {
}
public ngOnInit() {
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;
});
}
});
}
public ngOnDestroy() {
this.appSubscription.unsubscribe();
}
public createClientKey() {
this.appClientKeysService.postClientKey(this.appName).subscribe(clientKey => {
this.appClientKeys.push(clientKey);
})
}
}

10
src/Squidex/app/components/internal/app/settings/languages-page.component.html

@ -4,6 +4,10 @@
</div>
<div class="layout-middle">
<div class="layout-middle-header">
<div class="pull-right">
<button class="btn btn-success" (click)="saveLanguages()" [disabled]="isSaving">{{isSaving ? 'Saving...' : 'Save Changes'}}</button>
</div>
<h1>
<i class="layout-title-icon icon-settings"></i> Languages
</h1>
@ -11,7 +15,7 @@
<div class="layout-middle-content">
<div class="card">
<div class="card-block">
<table class="table table-borderless table-fixed" dnd-sortable-container [sortableData]="appLanguages">
<table class="table table-borderless-top table-fixed languages-table" dnd-sortable-container [sortableData]="appLanguages">
<colgroup>
<col style="width: 60px" />
<col style="width: 100%" />
@ -34,8 +38,6 @@
</td>
</tr>
</table>
<button class="btn btn-primary" (click)="saveLanguages()" [disabled]="isSaving">{{isSaving ? 'Saving...' : 'Save'}}</button>
</div>
<div class="card-footer">
<form class="form-inline" (submit)="addLanguage()" name="addLanguageForm">
@ -45,7 +47,7 @@
</select>
</div>
<button type="submit" class="btn btn-primary" [disabled]="!selectedLanguage">Add</button>
<button type="submit" class="btn btn-success" [disabled]="!selectedLanguage">Add</button>
</form>
</div>
</div>

6
src/Squidex/app/components/internal/app/settings/languages-page.component.scss

@ -9,6 +9,12 @@
max-width: 700px;
}
.languages {
&-table {
margin: 0;
}
}
.language {
&-select {
max-width: 200px;

2
src/Squidex/app/components/internal/declarations.ts

@ -10,8 +10,8 @@ export * from './app/app-area.component';
export * from './app/left-menu.component';
export * from './app/dashboard/dashboard-page.component';
export * from './app/schemas/schemas-page.component';
export * from './app/settings/clients-page.component';
export * from './app/settings/contributors-page.component';
export * from './app/settings/credentials-page.component';
export * from './app/settings/languages-page.component';
export * from './internal-area.component';

4
src/Squidex/app/components/internal/module.ts

@ -17,8 +17,8 @@ import { SqxLayoutModule } from 'components/layout';
import {
AppAreaComponent,
AppsPageComponent,
ClientsPageComponent,
ContributorsPageComponent,
CredentialsPageComponent,
DashboardPageComponent,
InternalAreaComponent,
LeftMenuComponent,
@ -39,8 +39,8 @@ import {
declarations: [
AppAreaComponent,
AppsPageComponent,
ClientsPageComponent,
ContributorsPageComponent,
CredentialsPageComponent,
DashboardPageComponent,
InternalAreaComponent,
LanguagesPageComponent,

5
src/Squidex/app/components/layout/app-form.component.html

@ -1,4 +1,4 @@
<form [formGroup]="createForm" (ngSubmit)="submit()">
<form [formGroup]="createForm" (ngSubmit)="createApp()">
<div *ngIf="creationError" [@fade]>
<div class="form-error">
{{creationError}}
@ -17,7 +17,7 @@
Name can not have more than 40 characters.
</span>
<span *ngIf="createForm.get('name').hasError('pattern')">
Name can contain lower case letters (a-z), numbers and dashes only.
Name can contain lower case letters (a-z), numbers and dashes only (not at the end).
</span>
</div>
</div>
@ -26,6 +26,7 @@
<span class="form-hint">
The app name becomes part of the api url, e.g, https://<b>{{appName}}</b>.squidex.io/.<br />
It must contain lower case letters (a-z), numbers and dashes only, and cannot be longer than 40 characters. The name cannot be changed later.
</span>
</div>

8
src/Squidex/app/components/layout/app-form.component.ts

@ -34,6 +34,7 @@ export class AppFormComponent implements Ng2.OnInit {
@Ng2.Output()
public cancelled = new Ng2.EventEmitter();
public creationError = '';
public createForm =
this.formBuilder.group({
name: ['',
@ -46,9 +47,6 @@ export class AppFormComponent implements Ng2.OnInit {
public appName = FALLBACK_NAME;
public creating = false;
public creationError = '';
constructor(
private readonly appsStore: AppsStoreService,
private readonly formBuilder: Ng2Forms.FormBuilder
@ -61,12 +59,11 @@ export class AppFormComponent implements Ng2.OnInit {
});
}
public submit() {
public createApp() {
this.createForm.markAsDirty();
if (this.createForm.valid) {
this.createForm.disable();
this.creating = true;
const dto = new AppCreateDto(this.createForm.controls['name'].value);
@ -83,7 +80,6 @@ export class AppFormComponent implements Ng2.OnInit {
private reset() {
this.createForm.enable();
this.creating = false;
this.creationError = '';
}

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

@ -9,7 +9,7 @@ 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-clients.service';
export * from './services/app-languages.service';
export * from './services/apps-store.service';
export * from './services/apps.service';

52
src/Squidex/app/shared/services/app-client-keys.service.ts

@ -1,52 +0,0 @@
/*
* 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<AppClientKeyDto[]> {
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<AppClientKeyDto> {
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));
});
}
}

107
src/Squidex/app/shared/services/app-clients.service.spec.ts

@ -0,0 +1,107 @@
/*
* 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,
AppClientDto,
AppClientsService,
AuthService,
AppClientCreateDto,
DateTime
} from './../';
describe('AppClientsService', () => {
let authService: TypeMoq.Mock<AuthService>;
let appClientsService: AppClientsService;
beforeEach(() => {
authService = TypeMoq.Mock.ofType(AuthService);
appClientsService = new AppClientsService(authService.object, new ApiUrlConfig('http://service/p/'));
});
it('should make get request with auth service to get app clients', () => {
authService.setup(x => x.authGet('http://service/p/api/apps/my-app/clients'))
.returns(() => Observable.of(
new Ng2Http.Response(
new Ng2Http.ResponseOptions({
body: [{
clientName: 'client1',
clientSecret: 'secret1',
expiresUtc: '2016-12-12T10:10'
}, {
clientName: 'client2',
clientSecret: 'secret2',
expiresUtc: '2016-11-11T10:10'
}]
})
)
))
.verifiable(TypeMoq.Times.once());
let clients: AppClientDto[] = null;
appClientsService.getClients('my-app').subscribe(result => {
clients = result;
}).unsubscribe();
expect(clients).toEqual(
[
new AppClientDto('client1', 'secret1', DateTime.parseISO_UTC('2016-12-12T10:10')),
new AppClientDto('client2', 'secret2', DateTime.parseISO_UTC('2016-11-11T10:10')),
]);
authService.verifyAll();
});
it('should make post request to create client', () => {
const createClient = new AppClientCreateDto('client1');
authService.setup(x => x.authPost('http://service/p/api/apps/my-app/clients', TypeMoq.It.is(c => c === createClient)))
.returns(() => Observable.of(
new Ng2Http.Response(
new Ng2Http.ResponseOptions({
body: {
clientName: 'client1',
clientSecret: 'secret1',
expiresUtc: '2016-12-12T10:10'
}
})
)
))
.verifiable(TypeMoq.Times.once());
let client: AppClientDto = null;
appClientsService.postClient('my-app', createClient).subscribe(result => {
client = result;
});
expect(client).toEqual(
new AppClientDto('client1', 'secret1', DateTime.parseISO_UTC('2016-12-12T10:10')));
authService.verifyAll();
});
it('should make delete request to remove client', () => {
authService.setup(x => x.authDelete('http://service/p/api/apps/my-app/clients/client1'))
.returns(() => Observable.of(
new Ng2Http.Response(
new Ng2Http.ResponseOptions()
)
))
.verifiable(TypeMoq.Times.once());
appClientsService.deleteClient('my-app', 'client1');
authService.verifyAll();
});
});

67
src/Squidex/app/shared/services/app-clients.service.ts

@ -0,0 +1,67 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Sebastian Stehle. All rights reserved
*/
import * as Ng2 from '@angular/core';
import { Observable } from 'rxjs';
import { ApiUrlConfig, DateTime } from 'framework';
import { AuthService } from './auth.service';
export class AppClientDto {
constructor(
public readonly clientName: string,
public readonly clientSecret: string,
public readonly expiresUtc: DateTime
) {
}
}
export class AppClientCreateDto {
constructor(
public readonly clientName: string
) {
}
}
@Ng2.Injectable()
export class AppClientsService {
constructor(
private readonly authService: AuthService,
private readonly apiUrl: ApiUrlConfig
) {
}
public getClients(appName: string): Observable<AppClientDto[]> {
return this.authService.authGet(this.apiUrl.buildUrl(`api/apps/${appName}/clients`))
.map(response => response.json())
.map(response => {
const items: any[] = response;
return items.map(item => {
return new AppClientDto(item.clientName, item.clientSecret, DateTime.parseISO_UTC(item.expiresUtc));
});
});
}
public postClient(appName: string, client: AppClientCreateDto): Observable<AppClientDto> {
return this.authService.authPost(this.apiUrl.buildUrl(`api/apps/${appName}/clients`), client)
.map(response => response.json())
.map(response => new AppClientDto(response.clientName, response.clientSecret, DateTime.parseISO_UTC(response.expiresUtc)))
.catch(response => {
if (response.status === 400) {
return Observable.throw('An client with the same name already exists.');
} else {
return Observable.throw('A new client could not be created.');
}
});
}
public deleteClient(appName: string, name: string): Observable<any> {
return this.authService.authDelete(this.apiUrl.buildUrl(`api/apps/${appName}/clients/${name}`));
}
}

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

@ -30,10 +30,11 @@ export class AppContributorsService {
public getContributors(appName: string): Observable<AppContributorDto[]> {
return this.authService.authGet(this.apiUrl.buildUrl(`api/apps/${appName}/contributors`))
.map(response => response.json())
.map(response => {
const body: any[] = response.json();
const items: any[] = response;
return body.map(item => {
return items.map(item => {
return new AppContributorDto(
item.contributorId,
item.permission);

6
src/Squidex/app/shared/services/app-languages.service.ts

@ -23,10 +23,11 @@ export class AppLanguagesService {
public getLanguages(appName: string): Observable<LanguageDto[]> {
return this.authService.authGet(this.apiUrl.buildUrl(`api/apps/${appName}/languages`))
.map(response => response.json())
.map(response => {
const body: any[] = response.json();
const items: any[] = response;
return body.map(item => {
return items.map(item => {
return new LanguageDto(
item.iso2Code,
item.englishName);
@ -35,7 +36,6 @@ export class AppLanguagesService {
}
public postLanguages(appName: string, languageCodes: string[]): Observable<any> {
languageCodes = [ null, null ];
return this.authService.authPost(this.apiUrl.buildUrl(`api/apps/${appName}/languages`), { languages: languageCodes });
}
}

5
src/Squidex/app/shared/services/apps.service.ts

@ -39,10 +39,11 @@ export class AppsService {
public getApps(): Observable<AppDto[]> {
return this.authService.authGet(this.apiUrl.buildUrl('/api/apps'))
.map(response => response.json())
.map(response => {
const body: any[] = response.json() || [];
const items: any[] = response;
return body.map(item => {
return items.map(item => {
return new AppDto(
item.id,
item.name,

5
src/Squidex/app/shared/services/languages.service.ts

@ -29,10 +29,11 @@ export class LanguageService {
public getLanguages(): Observable<LanguageDto[]> {
return this.authService.authGet(this.apiUrl.buildUrl('api/languages'))
.map(response => response.json())
.map(response => {
const body: any[] = response.json();
const items: any[] = response;
return body.map(item => {
return items.map(item => {
return new LanguageDto(
item.iso2Code,
item.englishName);

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

@ -32,10 +32,11 @@ export class UsersService {
public getUsers(query?: string): Observable<UserDto[]> {
return this.authService.authGet(this.apiUrl.buildUrl(`api/users/?query=${query || ''}`))
.map(response => response.json())
.map(response => {
const body: any[] = response.json() || [];
const items: any[] = response;
return body.map(item => {
return items.map(item => {
return new UserDto(
item.id,
item.email,
@ -47,14 +48,13 @@ export class UsersService {
public getUser(id: string): Observable<UserDto> {
return this.authService.authGet(this.apiUrl.buildUrl(`api/users/${id}`))
.map(response => response.json())
.map(response => {
const body: any = response.json();
return new UserDto(
body.id,
body.email,
body.displayName,
body.pictureUrl);
response.id,
response.email,
response.displayName,
response.pictureUrl);
});
}
}

14
src/Squidex/app/theme/_bootstrap.scss

@ -8,7 +8,19 @@
&-borderless {
td {
border: none;
border: 0;
}
}
&-borderless-top {
tr:first-child td {
border: 0;
}
}
&-middle {
td {
vertical-align: middle;
}
}
}

1
src/Squidex/app/theme/_vars.scss

@ -4,6 +4,7 @@ $color-background: #f4f8f9;
$color-border: #eaeeef;
$color-text: #373a3c;
$color-empty: #777;
$color-modal-header-background: #1d262f;
$color-modal-header-foreground: #a8aaac;

2
src/Squidex/web.config

@ -2,7 +2,7 @@
<configuration>
<system.webServer>
<handlers>
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModule" resourceType="Unspecified"/>
<add client="aspNetCore" path="*" verb="*" modules="AspNetCoreModule" resourceType="Unspecified"/>
</handlers>
<aspNetCore processPath="%LAUNCHER_PATH%" arguments="%LAUNCHER_ARGS%" stdoutLogEnabled="true" stdoutLogFile="C:\\Logs\squidex\stdout" forwardWindowsAuthToken="false"/>
</system.webServer>

97
tests/Squidex.Infrastructure.Tests/CQRS/Commands/CommandContextTests.cs

@ -0,0 +1,97 @@
// ==========================================================================
// CommandContextTests.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System;
using Xunit;
namespace Squidex.Infrastructure.CQRS.Commands
{
public class CommandContextTests
{
private readonly MockupCommand command = new MockupCommand();
private sealed class MockupCommand : ICommand
{
}
[Fact]
public void Should_instantiate_and_provide_command()
{
var sut = new CommandContext(command);
Assert.Equal(command, sut.Command);
Assert.Null(sut.Exception);
Assert.False(sut.IsSucceeded);
Assert.False(sut.IsHandled);
}
[Fact]
public void Should_provide_exception_when_failed()
{
var exc = new InvalidOperationException();
var sut = new CommandContext(command);
sut.Fail(exc);
Assert.Equal(exc, sut.Exception);
Assert.False(sut.IsSucceeded);
Assert.True(sut.IsHandled);
}
[Fact]
public void Should_be_handled_when_succeeded()
{
var sut = new CommandContext(command);
sut.Succeed();
Assert.Null(sut.Exception);
Assert.True(sut.IsSucceeded);
Assert.True(sut.IsHandled);
}
[Fact]
public void Shoud_not_change_status_when_already_succeeded()
{
var sut = new CommandContext(command);
sut.Succeed(Guid.NewGuid());
sut.Fail(new Exception());
Assert.Null(sut.Exception);
Assert.True(sut.IsHandled);
Assert.True(sut.IsSucceeded);
}
[Fact]
public void Should_provide_result_valid_when_succeeded_with_value()
{
var guid = Guid.NewGuid();
var sut = new CommandContext(command);
sut.Succeed(guid);
Assert.Equal(guid, sut.Result<Guid>());
Assert.True(sut.IsSucceeded);
Assert.True(sut.IsHandled);
}
[Fact]
public void Shoud_not_change_status_when_already_failed()
{
var sut = new CommandContext(command);
sut.Fail(new Exception());
sut.Succeed(Guid.NewGuid());
Assert.NotNull(sut.Exception);
Assert.True(sut.IsHandled);
Assert.False(sut.IsSucceeded);
}
}
}

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

@ -6,10 +6,12 @@
// All rights reserved.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Moq;
using Squidex.Infrastructure;
using Squidex.Infrastructure.CQRS.Commands;
using Squidex.Read.Apps;
using Squidex.Read.Apps.Repositories;
using Squidex.Read.Users;
@ -18,16 +20,24 @@ using Squidex.Write.Apps;
using Squidex.Write.Apps.Commands;
using Squidex.Write.Tests.Utils;
using Xunit;
using FluentAssertions;
// ReSharper disable ImplicitlyCapturedClosure
// ReSharper disable ConvertToConstant.Local
namespace Squidex.Write.Tests.Apps
{
public class AppCommandHandlerTests : HandlerTestBase<AppDomainObject>
{
private readonly Mock<ClientKeyGenerator> keyGenerator = new Mock<ClientKeyGenerator>();
private readonly Mock<IAppRepository> appRepository = new Mock<IAppRepository>();
private readonly Mock<IUserRepository> userRepository = new Mock<IUserRepository>();
private readonly AppCommandHandler sut;
private readonly AppDomainObject app;
private readonly string subjectId = Guid.NewGuid().ToString();
private readonly string contributorId = Guid.NewGuid().ToString();
private readonly string clientSecret = Guid.NewGuid().ToString();
private readonly string clientName = "client";
private readonly string appName = "my-app";
public AppCommandHandlerTests()
{
@ -35,19 +45,23 @@ namespace Squidex.Write.Tests.Apps
sut = new AppCommandHandler(
DomainObjectFactory.Object,
DomainObjectRepository.Object,
DomainObjectRepository.Object,
userRepository.Object,
appRepository.Object,
userRepository.Object);
keyGenerator.Object);
}
[Fact]
public async Task Create_should_throw_if_a_name_with_same_name_already_exists()
{
appRepository.Setup(x => x.FindAppByNameAsync("my-app")).Returns(Task.FromResult(new Mock<IAppEntity>().Object)).Verifiable();
var command = new CreateApp { Name = appName, AggregateId = Id, SubjectId = subjectId };
var context = new CommandContext(command);
appRepository.Setup(x => x.FindAppByNameAsync(appName)).Returns(Task.FromResult(new Mock<IAppEntity>().Object)).Verifiable();
await TestCreate(app, async _ =>
{
await Assert.ThrowsAsync<ValidationException>(async () => await sut.On(new CreateApp { Name = "my-app" }));
await Assert.ThrowsAsync<ValidationException>(async () => await sut.HandleAsync(context));
}, false);
appRepository.VerifyAll();
@ -56,28 +70,30 @@ namespace Squidex.Write.Tests.Apps
[Fact]
public async Task Create_should_create_app_if_name_is_free()
{
var command = new CreateApp { Name = "my-app", AggregateId = Id, SubjectId = "456" };
var command = new CreateApp { Name = appName, AggregateId = Id, SubjectId = subjectId };
var context = new CommandContext(command);
appRepository.Setup(x => x.FindAppByNameAsync("my-app")).Returns(Task.FromResult<IAppEntity>(null)).Verifiable();
appRepository.Setup(x => x.FindAppByNameAsync(appName)).Returns(Task.FromResult<IAppEntity>(null)).Verifiable();
await TestCreate(app, async _ =>
{
await sut.On(command);
await sut.HandleAsync(context);
});
appRepository.VerifyAll();
Assert.Equal(command.AggregateId, context.Result<Guid>());
}
[Fact]
public async Task ConfigureLanguages_should_update_domain_object()
{
CreateApp();
var command = new ConfigureLanguages { AggregateId = Id, Languages = new List<Language> { Language.GetLanguage("de") } };
var context = new CommandContext(command);
await TestUpdate(app, async _ =>
{
await sut.On(command);
await sut.HandleAsync(context);
});
}
@ -86,13 +102,14 @@ namespace Squidex.Write.Tests.Apps
{
CreateApp();
var command = new AssignContributor { AggregateId = Id, ContributorId = "456" };
var command = new AssignContributor { AggregateId = Id, ContributorId = contributorId };
var context = new CommandContext(command);
userRepository.Setup(x => x.FindUserByIdAsync(command.ContributorId)).Returns(Task.FromResult<IUserEntity>(null));
await TestUpdate(app, async _ =>
{
await Assert.ThrowsAsync<ValidationException>(() => sut.On(command));
await Assert.ThrowsAsync<ValidationException>(() => sut.HandleAsync(context));
}, false);
}
@ -101,13 +118,14 @@ namespace Squidex.Write.Tests.Apps
{
CreateApp();
var command = new AssignContributor { AggregateId = Id, ContributorId = "456" };
var command = new AssignContributor { AggregateId = Id, ContributorId = contributorId };
var context = new CommandContext(command);
userRepository.Setup(x => x.FindUserByIdAsync(command.ContributorId)).Returns(Task.FromResult(new Mock<IUserEntity>().Object));
await TestUpdate(app, async _ =>
{
await sut.On(command);
await sut.HandleAsync(context);
});
}
@ -115,46 +133,58 @@ namespace Squidex.Write.Tests.Apps
public async Task RemoveContributor_should_update_domain_object()
{
CreateApp()
.AssignContributor(new AssignContributor { ContributorId = "456" });
.AssignContributor(new AssignContributor { ContributorId = contributorId });
var command = new RemoveContributor { AggregateId = Id, ContributorId = "456" };
var command = new RemoveContributor { AggregateId = Id, ContributorId = contributorId };
var context = new CommandContext(command);
await TestUpdate(app, async _ =>
{
await sut.On(command);
await sut.HandleAsync(context);
});
}
[Fact]
public async Task CreateClientKey_should_update_domain_object()
public async Task AttachClient_should_update_domain_object()
{
keyGenerator.Setup(x => x.GenerateKey()).Returns(clientSecret).Verifiable();
CreateApp();
var command = new CreateClientKey { AggregateId = Id, ClientKey = "456" };
var timestamp = DateTime.Today;
var command = new AttachClient { ClientName = clientName, AggregateId = Id, Timestamp = timestamp };
var context = new CommandContext(command);
await TestUpdate(app, async _ =>
{
await sut.On(command);
await sut.HandleAsync(context);
});
keyGenerator.VerifyAll();
context.Result<AppClient>().ShouldBeEquivalentTo(
new AppClient(clientName, clientSecret, timestamp.AddYears(1)));
}
[Fact]
public async Task RevokeClientKey_should_update_domain_object()
public async Task RevokeClient_should_update_domain_object()
{
CreateApp()
.CreateClientKey(new CreateClientKey { ClientKey = "456" });
.AttachClient(new AttachClient { ClientName = clientName }, clientSecret);
var command = new RevokeClientKey { AggregateId = Id, ClientKey = "456" };
var command = new RevokeClient { AggregateId = Id, ClientName = clientName };
var context = new CommandContext(command);
await TestUpdate(app, async _ =>
{
await sut.On(command);
await sut.HandleAsync(context);
});
}
private AppDomainObject CreateApp()
{
app.Create(new CreateApp { Name = "my-app", SubjectId = "123" });
app.Create(new CreateApp { Name = appName, SubjectId = subjectId });
return app;
}

54
tests/Squidex.Write.Tests/Apps/AppDomainObjectTests.cs

@ -18,6 +18,7 @@ using Squidex.Infrastructure.CQRS.Events;
using Squidex.Write.Apps;
using Squidex.Write.Apps.Commands;
using Xunit;
// ReSharper disable ConvertToConstant.Local
namespace Squidex.Write.Tests.Apps
{
@ -27,7 +28,8 @@ namespace Squidex.Write.Tests.Apps
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 string clientSecret = Guid.NewGuid().ToString();
private readonly string clientName = "client";
private readonly List<Language> languages = new List<Language> { Language.GetLanguage("de") };
public AppDomainObjectTests()
@ -63,7 +65,7 @@ namespace Squidex.Write.Tests.Apps
{
new AppCreated { Name = TestName },
new AppContributorAssigned { ContributorId = subjectId, Permission = PermissionLevel.Owner },
new AppLanguagesConfigured { Languages= new List<Language> { Language.GetLanguage("de") } }
new AppLanguagesConfigured { Languages= new List<Language> { Language.GetLanguage("en") } }
});
}
@ -173,28 +175,38 @@ namespace Squidex.Write.Tests.Apps
}
[Fact]
public void CreateClientKey_should_throw_if_not_created()
public void AttachClient_should_throw_if_not_created()
{
Assert.Throws<DomainException>(() => sut.CreateClientKey(new CreateClientKey { ClientKey = clientKey }));
Assert.Throws<DomainException>(() => sut.AttachClient(new AttachClient { ClientName = clientName }, clientSecret));
}
[Fact]
public void CreateClientKey_should_throw_if_client_key_is_null_or_empty()
public void AttachClient_should_throw_if_name_not_valid()
{
CreateApp();
Assert.Throws<ValidationException>(() => sut.CreateClientKey(new CreateClientKey()));
Assert.Throws<ValidationException>(() => sut.CreateClientKey(new CreateClientKey { ClientKey = string.Empty }));
Assert.Throws<ValidationException>(() => sut.AttachClient(new AttachClient(), clientSecret));
Assert.Throws<ValidationException>(() => sut.AttachClient(new AttachClient { ClientName = string.Empty }, clientSecret));
}
[Fact]
public void CreateClientKey_should_create_events()
public void AttachClient_should_throw_if_name_already_exists()
{
CreateApp();
sut.AttachClient(new AttachClient { ClientName = clientName }, clientSecret);
Assert.Throws<ValidationException>(() => sut.AttachClient(new AttachClient { ClientName = clientName }, clientSecret));
}
[Fact]
public void AttachClient_should_create_events()
{
var now = DateTime.Today;
CreateApp();
sut.CreateClientKey(new CreateClientKey { ClientKey = clientKey, Timestamp = now });
sut.AttachClient(new AttachClient { ClientName = clientName, Timestamp = now }, clientSecret);
Assert.False(sut.Contributors.ContainsKey(contributorId));
@ -202,46 +214,46 @@ namespace Squidex.Write.Tests.Apps
.ShouldBeEquivalentTo(
new IEvent[]
{
new AppClientKeyCreated { ClientKey = clientKey, ExpiresUtc = now.AddYears(1) }
new AppClientAttached { ClientName = clientName, ClientSecret = clientSecret, ExpiresUtc = now.AddYears(1) }
});
}
[Fact]
public void RevokeClientKey_should_throw_if_not_created()
public void RevokeKey_should_throw_if_not_created()
{
Assert.Throws<DomainException>(() => sut.RevokeClientKey(new RevokeClientKey { ClientKey = "not-found" }));
Assert.Throws<DomainException>(() => sut.RevokeClient(new RevokeClient { ClientName = "not-found" }));
}
[Fact]
public void RevokeClientKey_should_throw_if_client_key_is_null_or_empty()
public void RevokeClient_should_throw_if_client_key_is_null_or_empty()
{
CreateApp();
Assert.Throws<ValidationException>(() => sut.RevokeClientKey(new RevokeClientKey()));
Assert.Throws<ValidationException>(() => sut.RevokeClientKey(new RevokeClientKey { ClientKey = string.Empty }));
Assert.Throws<ValidationException>(() => sut.RevokeClient(new RevokeClient()));
Assert.Throws<ValidationException>(() => sut.RevokeClient(new RevokeClient { ClientName = string.Empty }));
}
[Fact]
public void RevokeClientKey_should_throw_if_key_not_found()
public void RevokeClient_should_throw_if_client_not_found()
{
CreateApp();
Assert.Throws<ValidationException>(() => sut.RevokeClientKey(new RevokeClientKey { ClientKey = "not-found" }));
Assert.Throws<ValidationException>(() => sut.RevokeClient(new RevokeClient { ClientName = "not-found" }));
}
[Fact]
public void RevokeClientKey_should_create_events()
public void RevokeClient_should_create_events()
{
CreateApp();
sut.CreateClientKey(new CreateClientKey { ClientKey = clientKey });
sut.RevokeClientKey(new RevokeClientKey { ClientKey = clientKey });
sut.AttachClient(new AttachClient { ClientName = clientName }, clientSecret);
sut.RevokeClient(new RevokeClient { ClientName = clientName });
sut.GetUncomittedEvents().Select(x => x.Payload).Skip(1).ToArray()
.ShouldBeEquivalentTo(
new IEvent[]
{
new AppClientKeyRevoked { ClientKey = clientKey }
new AppClientRevoked { ClientName = clientSecret }
});
}

Loading…
Cancel
Save