diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/AppImage.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/AppImage.cs new file mode 100644 index 000000000..603b73af4 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Apps/AppImage.cs @@ -0,0 +1,35 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Apps +{ + public sealed class AppImage + { + public string MimeType { get; } + + public string Etag { get; } + + public AppImage(string mimeType, string etag = null) + { + Guard.NotNullOrEmpty(mimeType, nameof(mimeType)); + + MimeType = mimeType; + + if (string.IsNullOrWhiteSpace(etag)) + { + Etag = RandomHash.Simple(); + } + else + { + Etag = etag; + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs index 63b8f2833..0c8ee32e7 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs @@ -10,24 +10,47 @@ using System.Threading.Tasks; using Orleans; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure; +using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities.Apps { public sealed class AppCommandMiddleware : GrainCommandMiddleware { + private readonly IAssetStore assetStore; + private readonly IAssetThumbnailGenerator assetThumbnailGenerator; private readonly IContextProvider contextProvider; - public AppCommandMiddleware(IGrainFactory grainFactory, IContextProvider contextProvider) + public AppCommandMiddleware( + IGrainFactory grainFactory, + IAssetStore assetStore, + IAssetThumbnailGenerator assetThumbnailGenerator, + IContextProvider contextProvider) : base(grainFactory) { Guard.NotNull(contextProvider, nameof(contextProvider)); + Guard.NotNull(assetStore, nameof(assetStore)); + Guard.NotNull(assetThumbnailGenerator, nameof(assetThumbnailGenerator)); + this.assetStore = assetStore; + this.assetThumbnailGenerator = assetThumbnailGenerator; this.contextProvider = contextProvider; } public override async Task HandleAsync(CommandContext context, Func next) { + if (context.Command is UploadAppImage uploadImage) + { + var image = await assetThumbnailGenerator.GetImageInfoAsync(uploadImage.File()); + + if (image == null) + { + throw new ValidationException("File is not an image."); + } + + await assetStore.UploadAsync(uploadImage.AppId.ToString(), uploadImage.File(), true); + } + await ExecuteCommandAsync(context); if (context.PlainResult is IAppEntity app) diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs index 268c80e96..09b65e1e7 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs @@ -69,6 +69,36 @@ namespace Squidex.Domain.Apps.Entities.Apps return Snapshot; }); + case UpdateApp updateApp: + return UpdateReturn(updateApp, c => + { + GuardApp.CanUpdate(c); + + Update(c); + + return Snapshot; + }); + + case UploadAppImage uploadImage: + return UpdateReturn(uploadImage, c => + { + GuardApp.CanUploadImage(c); + + UploadImage(c); + + return Snapshot; + }); + + case RemoveAppImage removeImage: + return UpdateReturn(removeImage, c => + { + GuardApp.CanRemoveImage(c); + + RemoveImage(c); + + return Snapshot; + }); + case AssignContributor assignContributor: return UpdateReturnAsync(assignContributor, async c => { @@ -324,6 +354,21 @@ namespace Squidex.Domain.Apps.Entities.Apps } } + public void Update(UpdateApp command) + { + RaiseEvent(SimpleMapper.Map(command, new AppUpdated())); + } + + public void UploadImage(UploadAppImage command) + { + RaiseEvent(SimpleMapper.Map(command, new AppImageUploaded())); + } + + public void RemoveImage(RemoveAppImage command) + { + RaiseEvent(SimpleMapper.Map(command, new AppImageRemoved())); + } + public void UpdateLanguage(UpdateLanguage command) { RaiseEvent(SimpleMapper.Map(command, new AppLanguageUpdated())); diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs index e31a6a55d..dd612658c 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs @@ -13,12 +13,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Commands { public string ContributorId { get; set; } - public string Role { get; set; } = Roles.Developer; + public string Role { get; set; } = Roles.Editor; public bool IsRestore { get; set; } - public bool IsInviting { get; set; } - - public bool IsCreated { get; set; } + public bool Invite { get; set; } } } \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveAppImage.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveAppImage.cs new file mode 100644 index 000000000..c88fa5c56 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveAppImage.cs @@ -0,0 +1,13 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class RemoveAppImage : AppCommand + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateApp.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateApp.cs new file mode 100644 index 000000000..8b5f0ef32 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateApp.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class UpdateApp : AppCommand + { + public string Label { get; set; } + + public string Description { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs new file mode 100644 index 000000000..465ed4f83 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using Squidex.Domain.Apps.Core.Apps; + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class UploadAppImage : AppCommand + { + public AppImage Image { get; set; } + + public Func File { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs index ea2cad6a9..7da070db5 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs @@ -28,6 +28,21 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards }); } + public static void CanUpdate(UpdateApp command) + { + Guard.NotNull(command, nameof(command)); + } + + public static void CanUploadImage(UploadAppImage command) + { + Guard.NotNull(command, nameof(command)); + } + + public static void CanRemoveImage(RemoveAppImage command) + { + Guard.NotNull(command, nameof(command)); + } + public static void CanChangePlan(ChangePlan command, AppPlan plan, IAppPlansProvider appPlans) { Guard.NotNull(command, nameof(command)); diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs index b43d4f502..962198cf4 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs @@ -32,35 +32,39 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards if (string.IsNullOrWhiteSpace(command.ContributorId)) { e(Not.Defined("Contributor id"), nameof(command.ContributorId)); - return; } - - var user = await users.FindByIdOrEmailAsync(command.ContributorId); - - if (user == null) + else { - throw new DomainObjectNotFoundException(command.ContributorId, "Contributors", typeof(IAppEntity)); - } - - command.ContributorId = user.Id; + var user = await users.FindByIdOrEmailAsync(command.ContributorId); - if (!command.IsRestore) - { - if (string.Equals(command.ContributorId, command.Actor?.Identifier, StringComparison.OrdinalIgnoreCase)) + if (user == null) { - throw new DomainForbiddenException("You cannot change your own role."); + throw new DomainObjectNotFoundException(command.ContributorId, "Contributors", typeof(IAppEntity)); } - if (contributors.TryGetValue(command.ContributorId, out var existing)) + command.ContributorId = user.Id; + + if (!command.IsRestore) { - if (existing == command.Role) + if (string.Equals(command.ContributorId, command.Actor?.Identifier, StringComparison.OrdinalIgnoreCase)) { - e(Not.New("Contributor", "role"), nameof(command.Role)); + throw new DomainForbiddenException("You cannot change your own role."); + } + + if (contributors.TryGetValue(command.ContributorId, out var role)) + { + if (role == command.Role) + { + e(Not.New("Contributor", "role"), nameof(command.Role)); + } + } + else + { + if (plan.MaxContributors > 0 && contributors.Count >= plan.MaxContributors) + { + e("You have reached the maximum number of contributors for your plan."); + } } - } - else if (plan.MaxContributors > 0 && contributors.Count >= plan.MaxContributors) - { - e("You have reached the maximum number of contributors for your plan."); } } }); diff --git a/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs b/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs index a41e3368f..4b959e6e4 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs @@ -18,10 +18,16 @@ namespace Squidex.Domain.Apps.Entities.Apps { string Name { get; } + string Label { get; } + + string Description { get; } + Roles Roles { get; } AppPlan Plan { get; } + AppImage Image { get; } + AppClients Clients { get; } AppPatterns Patterns { get; } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs index 7bde0a4cd..084d1270c 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs @@ -27,24 +27,26 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation public async Task HandleAsync(CommandContext context, Func next) { - if (context.Command is AssignContributor assignContributor) + if (context.Command is AssignContributor assignContributor && ShouldInvite(assignContributor)) { - if (assignContributor.IsInviting && assignContributor.ContributorId.IsEmail()) - { - assignContributor.IsCreated = await userResolver.CreateUserIfNotExists(assignContributor.ContributorId, true); - - await next(); + var created = await userResolver.CreateUserIfNotExists(assignContributor.ContributorId, true); - if (assignContributor.IsCreated && context.PlainResult is IAppEntity app) - { - context.Complete(new InvitedResult { App = app }); - } + await next(); - return; + if (created && context.PlainResult is IAppEntity app) + { + context.Complete(new InvitedResult { App = app }); } } + else + { + await next(); + } + } - await next(); + private bool ShouldInvite(AssignContributor assignContributor) + { + return assignContributor.Invite && assignContributor.ContributorId.IsEmail(); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs index 7e870d56c..cf9f08a55 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs @@ -25,12 +25,21 @@ namespace Squidex.Domain.Apps.Entities.Apps.State [DataMember] public string Name { get; set; } + [DataMember] + public string Label { get; set; } + + [DataMember] + public string Description { get; set; } + [DataMember] public Roles Roles { get; set; } = Roles.Empty; [DataMember] public AppPlan Plan { get; set; } + [DataMember] + public AppImage Image { get; set; } + [DataMember] public AppClients Clients { get; set; } = AppClients.Empty; @@ -56,6 +65,21 @@ namespace Squidex.Domain.Apps.Entities.Apps.State SimpleMapper.Map(@event, this); } + protected void On(AppUpdated @event) + { + SimpleMapper.Map(@event, this); + } + + protected void On(AppImageUploaded @event) + { + Image = @event.Image; + } + + protected void On(AppImageRemoved @event) + { + Image = null; + } + protected void On(AppPlanChanged @event) { Plan = AppPlan.Build(@event.Actor, @event.PlanId); diff --git a/src/Squidex.Domain.Apps.Events/Apps/AppImageRemoved.cs b/src/Squidex.Domain.Apps.Events/Apps/AppImageRemoved.cs new file mode 100644 index 000000000..1835d2941 --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Apps/AppImageRemoved.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Apps +{ + [EventType(nameof(AppImageRemoved))] + public sealed class AppImageRemoved : AppEvent + { + } +} diff --git a/src/Squidex.Domain.Apps.Events/Apps/AppImageUploaded.cs b/src/Squidex.Domain.Apps.Events/Apps/AppImageUploaded.cs new file mode 100644 index 000000000..9a237e6d8 --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Apps/AppImageUploaded.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Apps +{ + [EventType(nameof(AppImageUploaded))] + public sealed class AppImageUploaded : AppEvent + { + public AppImage Image { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Events/Apps/AppUpdated.cs b/src/Squidex.Domain.Apps.Events/Apps/AppUpdated.cs new file mode 100644 index 000000000..bcc4f8896 --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Apps/AppUpdated.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Apps +{ + [EventType(nameof(AppUpdated))] + public sealed class AppUpdated : AppEvent + { + public string Label { get; set; } + + public string Description { get; set; } + } +} diff --git a/src/Squidex.Domain.Users/DefaultUserResolver.cs b/src/Squidex.Domain.Users/DefaultUserResolver.cs index 49723af01..b03abae64 100644 --- a/src/Squidex.Domain.Users/DefaultUserResolver.cs +++ b/src/Squidex.Domain.Users/DefaultUserResolver.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -69,5 +70,12 @@ namespace Squidex.Domain.Users return result.OfType().ToList(); } + + public async Task> QueryManyAsync(string[] ids) + { + var result = await userManager.QueryByIdsAync(ids); + + return result.OfType().ToDictionary(x => x.Id); + } } } diff --git a/src/Squidex.Domain.Users/UserManagerExtensions.cs b/src/Squidex.Domain.Users/UserManagerExtensions.cs index 94176bfc0..4217194c0 100644 --- a/src/Squidex.Domain.Users/UserManagerExtensions.cs +++ b/src/Squidex.Domain.Users/UserManagerExtensions.cs @@ -85,6 +85,15 @@ namespace Squidex.Domain.Users return Task.FromResult(count); } + public static async Task> QueryByIdsAync(this UserManager userManager, string[] ids) + { + var users = userManager.Users.Where(x => ids.Contains(x.Id)).ToList(); + + var result = await userManager.ResolveUsersAsync(users); + + return result.ToList(); + } + public static async Task> QueryByEmailAsync(this UserManager userManager, string email = null, int take = 10, int skip = 0) { var users = QueryUsers(userManager, email).Skip(skip).Take(take).ToList(); diff --git a/src/Squidex.Infrastructure/RandomHash.cs b/src/Squidex.Infrastructure/RandomHash.cs index e64115342..020a3568c 100644 --- a/src/Squidex.Infrastructure/RandomHash.cs +++ b/src/Squidex.Infrastructure/RandomHash.cs @@ -15,7 +15,17 @@ namespace Squidex.Infrastructure { public static string New() { - return Guid.NewGuid().ToString().Sha256Base64().Replace("+", "x"); + return Guid.NewGuid() + .ToString().Sha256Base64() + .ToLowerInvariant() + .Replace("+", "x") + .Replace("=", "x") + .Replace("/", "x"); + } + + public static string Simple() + { + return Guid.NewGuid().ToString().Replace("-", string.Empty); } public static string Sha256Base64(this string value) diff --git a/src/Squidex.Shared/Permissions.cs b/src/Squidex.Shared/Permissions.cs index 10ceb8fef..66e631ea2 100644 --- a/src/Squidex.Shared/Permissions.cs +++ b/src/Squidex.Shared/Permissions.cs @@ -53,6 +53,9 @@ namespace Squidex.Shared public const string AppCommon = "squidex.apps.{app}.common"; public const string AppDelete = "squidex.apps.{app}.delete"; + public const string AppUpdate = "squidex.apps.{app}.update"; + public const string AppUpdateImage = "squidex.apps.{app}.update"; + public const string AppUpdateGeneral = "squidex.apps.{app}.general"; public const string AppClients = "squidex.apps.{app}.clients"; public const string AppClientsRead = "squidex.apps.{app}.clients.read"; diff --git a/src/Squidex.Shared/Users/IUserResolver.cs b/src/Squidex.Shared/Users/IUserResolver.cs index 29f3b541a..4406107a8 100644 --- a/src/Squidex.Shared/Users/IUserResolver.cs +++ b/src/Squidex.Shared/Users/IUserResolver.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; using System.Threading.Tasks; @@ -17,5 +18,7 @@ namespace Squidex.Shared.Users Task FindByIdOrEmailAsync(string idOrEmail); Task> QueryByEmailAsync(string email); + + Task> QueryManyAsync(string[] ids); } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs index 7ce6c79b8..fb444d2a3 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs @@ -48,7 +48,7 @@ namespace Squidex.Areas.Api.Controllers.Apps { var response = Deferred.Response(() => { - return ClientsDto.FromApp(App, this); + return GetResponse(App); }); Response.Headers[HeaderNames.ETag] = App.ToEtag(); @@ -143,9 +143,14 @@ namespace Squidex.Areas.Api.Controllers.Apps var context = await CommandBus.PublishAsync(command); var result = context.Result(); - var response = ClientsDto.FromApp(result, this); + var response = GetResponse(result); return response; } + + private ClientsDto GetResponse(IAppEntity app) + { + return ClientsDto.FromApp(App, this); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs index fda51ba82..7d5e1ed37 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs @@ -15,6 +15,7 @@ using Squidex.Domain.Apps.Entities.Apps.Invitation; using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Infrastructure.Commands; using Squidex.Shared; +using Squidex.Shared.Users; using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Apps @@ -26,11 +27,14 @@ namespace Squidex.Areas.Api.Controllers.Apps public sealed class AppContributorsController : ApiController { private readonly IAppPlansProvider appPlansProvider; + private readonly IUserResolver userResolver; - public AppContributorsController(ICommandBus commandBus, IAppPlansProvider appPlansProvider) + public AppContributorsController(ICommandBus commandBus, IAppPlansProvider appPlansProvider, IUserResolver userResolver) : base(commandBus) { this.appPlansProvider = appPlansProvider; + + this.userResolver = userResolver; } /// @@ -48,9 +52,9 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(0)] public IActionResult GetContributors(string app) { - var response = Deferred.Response(() => + var response = Deferred.AsyncResponse(() => { - return ContributorsDto.FromApp(App, appPlansProvider, this, false); + return GetResponseAsync(App, false); }); Response.Headers[HeaderNames.ETag] = App.ToEtag(); @@ -112,12 +116,17 @@ namespace Squidex.Areas.Api.Controllers.Apps if (context.PlainResult is InvitedResult invited) { - return ContributorsDto.FromApp(invited.App, appPlansProvider, this, true); + return await GetResponseAsync(invited.App, true); } else { - return ContributorsDto.FromApp(context.Result(), appPlansProvider, this, false); + return await GetResponseAsync(context.Result(), false); } } + + private Task GetResponseAsync(IAppEntity app, bool invited) + { + return ContributorsDto.FromAppAsync(app, this, userResolver, appPlansProvider, invited); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs index 43498aa82..f76da205d 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs @@ -47,7 +47,7 @@ namespace Squidex.Areas.Api.Controllers.Apps { var response = Deferred.Response(() => { - return AppLanguagesDto.FromApp(App, this); + return GetResponse(App); }); Response.Headers[HeaderNames.ETag] = App.ToEtag(); @@ -132,11 +132,16 @@ namespace Squidex.Areas.Api.Controllers.Apps var context = await CommandBus.PublishAsync(command); var result = context.Result(); - var response = AppLanguagesDto.FromApp(result, this); + var response = GetResponse(result); return response; } + private AppLanguagesDto GetResponse(IAppEntity result) + { + return AppLanguagesDto.FromApp(result, this); + } + private static Language ParseLanguage(string language) { try diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs index 31b788e83..da58aade8 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs @@ -49,7 +49,7 @@ namespace Squidex.Areas.Api.Controllers.Apps { var response = Deferred.Response(() => { - return PatternsDto.FromApp(App, this); + return GetResponse(App); }); Response.Headers[HeaderNames.ETag] = App.ToEtag(); @@ -137,9 +137,14 @@ namespace Squidex.Areas.Api.Controllers.Apps var context = await CommandBus.PublishAsync(command); var result = context.Result(); - var response = PatternsDto.FromApp(result, this); + var response = GetResponse(result); return response; } + + private PatternsDto GetResponse(IAppEntity result) + { + return PatternsDto.FromApp(result, this); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs index 2961a91b7..8a3aef0ba 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs @@ -49,7 +49,7 @@ namespace Squidex.Areas.Api.Controllers.Apps { var response = Deferred.Response(() => { - return RolesDto.FromApp(App, this); + return GetResponse(App); }); Response.Headers[HeaderNames.ETag] = App.ToEtag(); @@ -160,9 +160,14 @@ namespace Squidex.Areas.Api.Controllers.Apps var context = await CommandBus.PublishAsync(command); var result = context.Result(); - var response = RolesDto.FromApp(result, this); + var response = GetResponse(result); return response; } + + private RolesDto GetResponse(IAppEntity result) + { + return RolesDto.FromApp(result, this); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs index 9f798add0..09c8c93d1 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs @@ -50,7 +50,7 @@ namespace Squidex.Areas.Api.Controllers.Apps { var response = Deferred.AsyncResponse(() => { - return WorkflowsDto.FromAppAsync(workflowsValidator, App, this); + return GetResponse(App); }); Response.Headers[HeaderNames.ETag] = App.ToEtag(); @@ -135,9 +135,14 @@ namespace Squidex.Areas.Api.Controllers.Apps var context = await CommandBus.PublishAsync(command); var result = context.Result(); - var response = await WorkflowsDto.FromAppAsync(workflowsValidator, result, this); + var response = await GetResponse(result); return response; } + + private async Task GetResponse(IAppEntity result) + { + return await WorkflowsDto.FromAppAsync(workflowsValidator, result, this); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs index ea4d0f4b6..b2669bae1 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs @@ -5,16 +5,26 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; +using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Apps.Models; +using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Services; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Security; using Squidex.Shared; using Squidex.Web; @@ -27,14 +37,20 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiExplorerSettings(GroupName = nameof(Apps))] public sealed class AppsController : ApiController { + private readonly IAssetStore assetStore; + private readonly IAssetThumbnailGenerator assetThumbnailGenerator; private readonly IAppProvider appProvider; private readonly IAppPlansProvider appPlansProvider; public AppsController(ICommandBus commandBus, + IAssetStore assetStore, + IAssetThumbnailGenerator assetThumbnailGenerator, IAppProvider appProvider, IAppPlansProvider appPlansProvider) : base(commandBus) { + this.assetStore = assetStore; + this.assetThumbnailGenerator = assetThumbnailGenerator; this.appProvider = appProvider; this.appPlansProvider = appPlansProvider; } @@ -63,7 +79,7 @@ namespace Squidex.Areas.Api.Controllers.Apps var response = Deferred.Response(() => { - return apps.Select(a => AppDto.FromApp(a, userOrClientId, userPermissions, appPlansProvider, this)).ToArray(); + return apps.OrderBy(x => x.Name).Select(a => AppDto.FromApp(a, userOrClientId, userPermissions, appPlansProvider, this)).ToArray(); }); Response.Headers[HeaderNames.ETag] = apps.ToEtag(); @@ -88,18 +104,143 @@ namespace Squidex.Areas.Api.Controllers.Apps [Route("apps/")] [ProducesResponseType(typeof(AppDto), 201)] [ApiPermission] - [ApiCosts(1)] + [ApiCosts(0)] public async Task PostApp([FromBody] CreateAppDto request) { - var context = await CommandBus.PublishAsync(request.ToCommand()); + var response = await InvokeCommandAsync(request.ToCommand()); - var userOrClientId = HttpContext.User.UserOrClientId(); - var userPermissions = HttpContext.Permissions(); + return CreatedAtAction(nameof(GetApps), response); + } - var result = context.Result(); - var response = AppDto.FromApp(result, userOrClientId, userPermissions, appPlansProvider, this); + /// + /// Update the app. + /// + /// The name of the app to update. + /// The values to update. + /// + /// 200 => App updated. + /// 404 => App not found. + /// + [HttpPut] + [Route("apps/{app}/")] + [ProducesResponseType(typeof(AppDto), 200)] + [ApiPermission(Permissions.AppUpdateGeneral)] + [ApiCosts(0)] + public async Task UpdateApp(string app, [FromBody] UpdateAppDto request) + { + var response = await InvokeCommandAsync(request.ToCommand()); - return CreatedAtAction(nameof(GetApps), response); + return Ok(response); + } + + /// + /// Get the app image. + /// + /// The name of the app to update. + /// The file to upload. + /// + /// 200 => App image uploaded. + /// 404 => App not found. + /// + [HttpPost] + [Route("apps/{app}/image")] + [ProducesResponseType(typeof(AppDto), 201)] + [ApiPermission(Permissions.AppUpdateImage)] + [ApiCosts(0)] + public async Task UploadImage(string app, [OpenApiIgnore] List file) + { + var response = await InvokeCommandAsync(CreateCommand(file)); + + return Ok(response); + } + + /// + /// Get the app image. + /// + /// The name of the app. + /// + /// 200 => App image found and content or (resized) image returned. + /// 404 => App not found. + /// + [HttpGet] + [Route("apps/{app}/image")] + [ProducesResponseType(typeof(FileResult), 200)] + [AllowAnonymous] + [ApiCosts(0)] + public IActionResult GetImage(string app) + { + if (App.Image == null) + { + return NotFound(); + } + + var etag = App.Image.Etag; + + Response.Headers[HeaderNames.ETag] = etag; + + var handler = new Func(async bodyStream => + { + var assetId = App.Id.ToString(); + var assetResizedId = $"{assetId}_{etag}_Resized"; + + try + { + await assetStore.DownloadAsync(assetResizedId, bodyStream); + } + catch (AssetNotFoundException) + { + using (Profiler.Trace("Resize")) + { + using (var sourceStream = GetTempStream()) + { + using (var destinationStream = GetTempStream()) + { + using (Profiler.Trace("ResizeDownload")) + { + await assetStore.DownloadAsync(assetId, sourceStream); + sourceStream.Position = 0; + } + + using (Profiler.Trace("ResizeImage")) + { + await assetThumbnailGenerator.CreateThumbnailAsync(sourceStream, destinationStream, 150, 150, "Crop"); + destinationStream.Position = 0; + } + + using (Profiler.Trace("ResizeUpload")) + { + await assetStore.UploadAsync(assetResizedId, destinationStream); + destinationStream.Position = 0; + } + + await destinationStream.CopyToAsync(bodyStream); + } + } + } + } + }); + + return new FileCallbackResult(App.Image.MimeType, null, true, handler); + } + + /// + /// Remove the app image. + /// + /// The name of the app to update. + /// + /// 200 => App image removed. + /// 404 => App not found. + /// + [HttpDelete] + [Route("apps/{app}/image")] + [ProducesResponseType(typeof(AppDto), 201)] + [ApiPermission(Permissions.AppUpdate)] + [ApiCosts(0)] + public async Task DeleteImage(string app) + { + var response = await InvokeCommandAsync(new RemoveAppImage()); + + return Ok(response); } /// @@ -113,12 +254,50 @@ namespace Squidex.Areas.Api.Controllers.Apps [HttpDelete] [Route("apps/{app}/")] [ApiPermission(Permissions.AppDelete)] - [ApiCosts(1)] + [ApiCosts(0)] public async Task DeleteApp(string app) { await CommandBus.PublishAsync(new ArchiveApp()); return NoContent(); } + + private async Task InvokeCommandAsync(AppCommand command) + { + var context = await CommandBus.PublishAsync(command); + + var userOrClientId = HttpContext.User.UserOrClientId(); + var userPermissions = HttpContext.Permissions(); + + var result = context.Result(); + var response = AppDto.FromApp(result, userOrClientId, userPermissions, appPlansProvider, this); + + return response; + } + + private UploadAppImage CreateCommand(IReadOnlyList file) + { + if (file.Count != 1) + { + var error = new ValidationError($"Can only upload one file, found {file.Count} files."); + + throw new ValidationException("Cannot create asset.", error); + } + + return new UploadAppImage { File = file[0].OpenReadStream, Image = new AppImage(file[0].ContentType) }; + } + + private static FileStream GetTempStream() + { + var tempFileName = Path.GetTempFileName(); + + return new FileStream(tempFileName, + FileMode.Create, + FileAccess.ReadWrite, + FileShare.Delete, 1024 * 16, + FileOptions.Asynchronous | + FileOptions.DeleteOnClose | + FileOptions.SequentialScan); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs index 426d7fbe5..aa91fc5fe 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs @@ -36,6 +36,16 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models [RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")] public string Name { get; set; } + /// + /// The optional label of the app. + /// + public string Label { get; set; } + + /// + /// The optional description of the app. + /// + public string Description { get; set; } + /// /// The version of the app. /// @@ -88,7 +98,6 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models var result = SimpleMapper.Map(app, new AppDto()); result.Permissions = permissions.ToIds(); - result.PlanName = plans.GetPlanForApp(app)?.Name; if (controller.Includes(AllPermissions.ForApp(AllPermissions.AppApi, app.Name), permissions)) { @@ -100,10 +109,8 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models result.CanAccessContent = true; } - if (controller.HasPermission(AllPermissions.AppPlansChange, app.Name)) - { - result.PlanUpgrade = plans.GetPlanUpgradeForApp(app)?.Name; - } + result.SetPlan(app, plans, controller); + result.SetImage(app, controller); return result.CreateLinks(controller, permissions); } @@ -125,6 +132,24 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models return new PermissionSet(permissions); } + private void SetPlan(IAppEntity app, IAppPlansProvider plans, ApiController controller) + { + if (controller.HasPermission(AllPermissions.AppPlansChange, app.Name)) + { + PlanUpgrade = plans.GetPlanUpgradeForApp(app)?.Name; + } + + PlanName = plans.GetPlanForApp(app)?.Name; + } + + private void SetImage(IAppEntity app, ApiController controller) + { + if (app.Image != null) + { + AddGetLink("image", controller.Url(x => nameof(x.GetImage), new { app = app.Name })); + } + } + private AppDto CreateLinks(ApiController controller, PermissionSet permissions) { var values = new { app = Name }; @@ -136,6 +161,18 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteApp), values)); } + if (controller.HasPermission(AllPermissions.AppUpdateGeneral, Name, additional: permissions)) + { + AddPutLink("update", controller.Url(x => nameof(x.UpdateApp), values)); + } + + if (controller.HasPermission(AllPermissions.AppUpdateImage, Name, additional: permissions)) + { + AddPostLink("image/upload", controller.Url(x => nameof(x.UploadImage), values)); + + AddDeleteLink("image/delete", controller.Url(x => nameof(x.DeleteImage), values)); + } + if (controller.HasPermission(AllPermissions.AppAssetsRead, Name, additional: permissions)) { AddGetLink("assets", controller.Url(x => nameof(x.GetAssets), values)); diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguageDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguageDto.cs index 85f6ef7ce..43992079e 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguageDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguageDto.cs @@ -46,7 +46,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// public bool IsOptional { get; set; } - public static AppLanguageDto FromLanguage(LanguageConfig language, IAppEntity app, ApiController controller) + public static AppLanguageDto FromLanguage(LanguageConfig language, IAppEntity app) { var result = SimpleMapper.Map(language.Language, new AppLanguageDto @@ -56,10 +56,10 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models Fallback = language.LanguageFallbacks.ToArray() }); - return result.CreateLinks(controller, app); + return result; } - private AppLanguageDto CreateLinks(ApiController controller, IAppEntity app) + public AppLanguageDto WithLinks(ApiController controller, IAppEntity app) { var values = new { app = app.Name, language = Iso2Code }; diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguagesDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguagesDto.cs index 77580f5e9..11c2063f9 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguagesDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguagesDto.cs @@ -27,9 +27,9 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models var result = new AppLanguagesDto { Items = app.LanguagesConfig.OfType() - .Select(x => AppLanguageDto.FromLanguage(x, app, controller)) - .OrderByDescending(x => x.IsMaster) - .ThenBy(x => x.Iso2Code) + .Select(x => AppLanguageDto.FromLanguage(x, app)) + .Select(x => x.WithLinks(controller, app)) + .OrderByDescending(x => x.IsMaster).ThenBy(x => x.Iso2Code) .ToArray() }; diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AssignContributorDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AssignContributorDto.cs index ede15b4d8..0df2db061 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AssignContributorDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AssignContributorDto.cs @@ -8,6 +8,7 @@ using System.ComponentModel.DataAnnotations; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure.Reflection; +using Roles = Squidex.Domain.Apps.Core.Apps.Role; namespace Squidex.Areas.Api.Controllers.Apps.Models { @@ -22,7 +23,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// /// The role of the contributor. /// - public string Role { get; set; } + public string Role { get; set; } = Roles.Developer; /// /// Set to true to invite the user if he does not exist. @@ -31,7 +32,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models public AssignContributor ToCommand() { - return SimpleMapper.Map(this, new AssignContributor { IsInviting = Invite }); + return SimpleMapper.Map(this, new AssignContributor()); } } } \ No newline at end of file diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs index a7a344251..6781ee20b 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs @@ -38,14 +38,14 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// public string Role { get; set; } - public static ClientDto FromClient(string id, AppClient client, ApiController controller, string app) + public static ClientDto FromClient(string id, AppClient client) { var result = SimpleMapper.Map(client, new ClientDto { Id = id }); - return result.CreateLinks(controller, app); + return result; } - private ClientDto CreateLinks(ApiController controller, string app) + public ClientDto WithLinks(ApiController controller, string app) { var values = new { app, id = Id }; diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientsDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientsDto.cs index 7116e99f2..1d456251e 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientsDto.cs @@ -23,12 +23,18 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models public static ClientsDto FromApp(IAppEntity app, ApiController controller) { + var appName = app.Name; + var result = new ClientsDto { - Items = app.Clients.Select(x => ClientDto.FromClient(x.Key, x.Value, controller, app.Name)).ToArray() + Items = + app.Clients + .Select(x => ClientDto.FromClient(x.Key, x.Value)) + .Select(x => x.WithLinks(controller, appName)) + .ToArray() }; - return result.CreateLinks(controller, app.Name); + return result.CreateLinks(controller, appName); } private ClientsDto CreateLinks(ApiController controller, string app) diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorDto.cs index 4929ed76b..9efa1133a 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorDto.cs @@ -5,33 +5,57 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Squidex.Shared; +using Squidex.Shared.Users; using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Apps.Models { public sealed class ContributorDto : Resource { + private const string NotFound = "- not found -"; + /// /// The id of the user that contributes to the app. /// [Required] public string ContributorId { get; set; } + /// + /// The display name. + /// + [Required] + public string ContributorName { get; set; } + /// /// The role of the contributor. /// public string Role { get; set; } - public static ContributorDto FromIdAndRole(string id, string role, ApiController controller, string app) + public static ContributorDto FromIdAndRole(string id, string role) { var result = new ContributorDto { ContributorId = id, Role = role }; - return result.CreateLinks(controller, app); + return result; + } + + public ContributorDto WithUser(IDictionary users) + { + if (users.TryGetValue(ContributorId, out var user)) + { + ContributorName = user.DisplayName(); + } + else + { + ContributorName = NotFound; + } + + return this; } - private ContributorDto CreateLinks(ApiController controller, string app) + public ContributorDto WithLinks(ApiController controller, string app) { if (!controller.IsUser(ContributorId)) { diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs index 540725c75..805a7e227 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs @@ -5,12 +5,15 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; +using System.Threading.Tasks; using Newtonsoft.Json; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Shared; +using Squidex.Shared.Users; using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Apps.Models @@ -29,29 +32,50 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models public int MaxContributors { get; set; } /// - /// The metadata. + /// The metadata to provide information about this request. /// [JsonProperty("_meta")] public ContributorsMetadata Metadata { get; set; } - public static ContributorsDto FromApp(IAppEntity app, IAppPlansProvider plans, ApiController controller, bool isInvited) + public static async Task FromAppAsync(IAppEntity app, ApiController controller, IUserResolver userResolver, IAppPlansProvider plans, bool invited) { + var users = await userResolver.QueryManyAsync(app.Contributors.Keys.ToArray()); + var result = new ContributorsDto { - Items = app.Contributors.Select(x => ContributorDto.FromIdAndRole(x.Key, x.Value, controller, app.Name)).ToArray(), + Items = + app.Contributors + .Select(x => ContributorDto.FromIdAndRole(x.Key, x.Value)) + .Select(x => x.WithUser(users)) + .Select(x => x.WithLinks(controller, app.Name)) + .OrderBy(x => x.ContributorName) + .ToArray() }; + result.WithInvited(invited); + result.WithPlan(app, plans); + + return result.CreateLinks(controller, app.Name); + } + + private ContributorsDto WithPlan(IAppEntity app, IAppPlansProvider plans) + { + MaxContributors = plans.GetPlanForApp(app).MaxContributors; + + return this; + } + + private ContributorsDto WithInvited(bool isInvited) + { if (isInvited) { - result.Metadata = new ContributorsMetadata + Metadata = new ContributorsMetadata { IsInvited = isInvited.ToString() }; } - result.MaxContributors = plans.GetPlanForApp(app).MaxContributors; - - return result.CreateLinks(controller, app.Name); + return this; } private ContributorsDto CreateLinks(ApiController controller, string app) diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/RoleDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/RoleDto.cs index ff0ddf201..1617c69a6 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/RoleDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/RoleDto.cs @@ -44,23 +44,33 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models [Required] public IEnumerable Permissions { get; set; } - public static RoleDto FromRole(Role role, IAppEntity app, ApiController controller) + public static RoleDto FromRole(Role role, IAppEntity app) { var permissions = role.Permissions.WithoutApp(app.Name); var result = new RoleDto { Name = role.Name, - NumClients = app.Clients.Count(x => Role.IsRole(x.Value.Role, role.Name)), - NumContributors = app.Contributors.Count(x => Role.IsRole(x.Value, role.Name)), + NumClients = GetNumClients(role, app), + NumContributors = GetNumContributors(role, app), Permissions = permissions.ToIds(), IsDefaultRole = Role.IsDefaultRole(role.Name) }; - return result.CreateLinks(controller, app.Name); + return result; } - private RoleDto CreateLinks(ApiController controller, string app) + private static int GetNumContributors(Role role, IAppEntity app) + { + return app.Contributors.Count(x => Role.IsRole(x.Value, role.Name)); + } + + private static int GetNumClients(Role role, IAppEntity app) + { + return app.Clients.Count(x => Role.IsRole(x.Value.Role, role.Name)); + } + + public RoleDto WithLinks(ApiController controller, string app) { var values = new { app, name = Name }; diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/RolesDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/RolesDto.cs index b43c32041..c7daa77df 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/RolesDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/RolesDto.cs @@ -23,12 +23,19 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models public static RolesDto FromApp(IAppEntity app, ApiController controller) { + var appName = app.Name; + var result = new RolesDto { - Items = app.Roles.Values.Select(x => RoleDto.FromRole(x, app, controller)).OrderBy(x => x.Name).ToArray() + Items = + app.Roles.Values + .Select(x => RoleDto.FromRole(x, app)) + .Select(x => x.WithLinks(controller, appName)) + .OrderBy(x => x.Name) + .ToArray() }; - return result.CreateLinks(controller, app.Name); + return result.CreateLinks(controller, appName); } private RolesDto CreateLinks(ApiController controller, string app) diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateAppDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateAppDto.cs new file mode 100644 index 000000000..7bd05b95a --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateAppDto.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class UpdateAppDto + { + /// + /// The optional label of your app. + /// + public string Label { get; set; } + + /// + /// The optional description of your app. + /// + public string Description { get; set; } + + public UpdateApp ToCommand() + { + return SimpleMapper.Map(this, new UpdateApp()); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs index 1601340cc..b026d6dba 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Apps; using Squidex.Infrastructure.Reflection; using Squidex.Shared; using Squidex.Web; @@ -44,7 +45,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// public Status Initial { get; set; } - public static WorkflowDto FromWorkflow(Guid id, Workflow workflow, ApiController controller, string app) + public static WorkflowDto FromWorkflow(Guid id, Workflow workflow) { var result = SimpleMapper.Map(workflow, new WorkflowDto { @@ -54,12 +55,12 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models Id = id }); - return result.CreateLinks(controller, app, id); + return result; } - private WorkflowDto CreateLinks(ApiController controller, string app, Guid id) + public WorkflowDto WithLinks(ApiController controller, string app) { - var values = new { app, id }; + var values = new { app, id = Id }; if (controller.HasPermission(Permissions.AppWorkflowsUpdate, app)) { diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs index 5e3515eba..d95aa9a3d 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs @@ -31,16 +31,22 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models public static async Task FromAppAsync(IWorkflowsValidator workflowsValidator, IAppEntity app, ApiController controller) { + var appName = app.Name; + var result = new WorkflowsDto { - Items = app.Workflows.Select(x => WorkflowDto.FromWorkflow(x.Key, x.Value, controller, app.Name)).ToArray(), + Items = + app.Workflows + .Select(x => WorkflowDto.FromWorkflow(x.Key, x.Value)) + .Select(x => x.WithLinks(controller, appName)) + .ToArray() }; var errors = await workflowsValidator.ValidateAsync(app.Id, app.Workflows); result.Errors = errors.ToArray(); - return result.CreateLinks(controller, app.Name); + return result.CreateLinks(controller, appName); } private WorkflowsDto CreateLinks(ApiController controller, string app) diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs index 1017d85ee..dcfcd5491 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs @@ -91,7 +91,7 @@ namespace Squidex.Areas.Api.Controllers.Assets [Route("assets/{id}/")] [ProducesResponseType(typeof(FileResult), 200)] [ApiCosts(0.5)] - public async Task GetAssetContent(Guid id, string more, [FromQuery] AssetQuery query) + public async Task GetAssetContent(Guid id, [FromQuery] AssetQuery query) { var asset = await assetRepository.FindAssetAsync(id); diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs index 2f2b2afcc..83ed3a14b 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs @@ -36,8 +36,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models [Required] public StatusInfoDto[] Statuses { get; set; } - public static async Task FromContentsAsync(IResultList contents, - Context context, ApiController controller, ISchemaEntity schema, IContentWorkflow contentWorkflow) + public static async Task FromContentsAsync(IResultList contents, Context context, ApiController controller, ISchemaEntity schema, IContentWorkflow workflow) { var result = new ContentsDto { @@ -45,14 +44,14 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models Items = contents.Select(x => ContentDto.FromContent(context, x, controller)).ToArray() }; - await result.AssignStatusesAsync(contentWorkflow, schema); + await result.AssignStatusesAsync(workflow, schema); return result.CreateLinks(controller, schema.AppId.Name, schema.SchemaDef.Name); } - private async Task AssignStatusesAsync(IContentWorkflow contentWorkflow, ISchemaEntity schema) + private async Task AssignStatusesAsync(IContentWorkflow workflow, ISchemaEntity schema) { - var allStatuses = await contentWorkflow.GetAllAsync(schema); + var allStatuses = await workflow.GetAllAsync(schema); Statuses = allStatuses.Select(StatusInfoDto.FromStatusInfo).ToArray(); } diff --git a/src/Squidex/Areas/Frontend/Middlewares/IndexMiddleware.cs b/src/Squidex/Areas/Frontend/Middlewares/IndexMiddleware.cs index cf4a2dde8..cd7bfbb74 100644 --- a/src/Squidex/Areas/Frontend/Middlewares/IndexMiddleware.cs +++ b/src/Squidex/Areas/Frontend/Middlewares/IndexMiddleware.cs @@ -34,16 +34,19 @@ namespace Squidex.Areas.Frontend.Middlewares await next(context); - context.Response.Body = responseBody; + if (context.Response.StatusCode != 304) + { + context.Response.Body = responseBody; - var html = Encoding.UTF8.GetString(responseBuffer.ToArray()); + var html = Encoding.UTF8.GetString(responseBuffer.ToArray()); - html = html.AdjustHtml(context); + html = html.AdjustHtml(context); - context.Response.ContentLength = Encoding.UTF8.GetByteCount(html); - context.Response.Body = responseBody; + context.Response.ContentLength = Encoding.UTF8.GetByteCount(html); + context.Response.Body = responseBody; - await context.Response.WriteAsync(html); + await context.Response.WriteAsync(html); + } } else { diff --git a/src/Squidex/app-config/webpack.config.js b/src/Squidex/app-config/webpack.config.js index 9a4117253..f755c7f79 100644 --- a/src/Squidex/app-config/webpack.config.js +++ b/src/Squidex/app-config/webpack.config.js @@ -18,8 +18,8 @@ const plugins = { CircularDependencyPlugin: require('circular-dependency-plugin'), // https://github.com/jantimon/html-webpack-plugin HtmlWebpackPlugin: require('html-webpack-plugin'), - // https://github.com/mishoo/UglifyJS2/tree/harmony - UglifyJsPlugin: require('uglifyjs-webpack-plugin'), + // https://webpack.js.org/plugins/terser-webpack-plugin/ + TerserPlugin: require('terser-webpack-plugin'), // https://www.npmjs.com/package/@ngtools/webpack NgToolsWebpack: require('@ngtools/webpack'), // https://github.com/NMFR/optimize-css-assets-webpack-plugin @@ -268,14 +268,15 @@ module.exports = function (env) { if (isProduction) { config.optimization = { minimizer: [ - new plugins.UglifyJsPlugin({ - uglifyOptions: { - compress: false, - ecma: 6, + new plugins.TerserPlugin({ + terserOptions: { + compress: true, + ecma: 5, mangle: true, output: { comments: false - } + }, + safari10: true }, extractComments: true }), diff --git a/src/Squidex/app/features/administration/pages/users/user-page.component.ts b/src/Squidex/app/features/administration/pages/users/user-page.component.ts index c930eb1ee..dcc115d66 100644 --- a/src/Squidex/app/features/administration/pages/users/user-page.component.ts +++ b/src/Squidex/app/features/administration/pages/users/user-page.component.ts @@ -42,9 +42,9 @@ export class UserPageComponent extends ResourceOwner implements OnInit { this.own( this.usersState.selectedUser .subscribe(selectedUser => { - this.user = selectedUser!; - if (selectedUser) { + this.user = selectedUser; + this.isEditable = this.user.canUpdate; this.userForm.load(selectedUser); diff --git a/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts b/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts index 6815d1093..8bb0607c2 100644 --- a/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts +++ b/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts @@ -139,10 +139,10 @@ describe('EventConsumersService', () => { function eventConsumerResponse(id: number) { return { name: `event-consumer${id}`, - position: `position-${id}`, + position: `position${id}`, isStopped: true, isResetting: true, - error: `failure-${id}`, + error: `failure${id}`, _links: { reset: { method: 'PUT', href: `/event-consumers/${id}/reset` } } @@ -159,6 +159,6 @@ export function createEventConsumer(id: number, suffix = '') { `event-consumer${id}`, true, true, - `failure-${id}${suffix}`, - `position-${id}${suffix}`); + `failure${id}${suffix}`, + `position${id}${suffix}`); } \ No newline at end of file diff --git a/src/Squidex/app/features/api/api-area.component.html b/src/Squidex/app/features/api/api-area.component.html index f53c81b01..8d6b3ee9f 100644 --- a/src/Squidex/app/features/api/api-area.component.html +++ b/src/Squidex/app/features/api/api-area.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.html b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.html index d762911d5..53fcce4f3 100644 --- a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.html +++ b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.html @@ -1,4 +1,4 @@ - +
diff --git a/src/Squidex/app/features/apps/pages/apps-page.component.html b/src/Squidex/app/features/apps/pages/apps-page.component.html index 104f08827..60fac9e55 100644 --- a/src/Squidex/app/features/apps/pages/apps-page.component.html +++ b/src/Squidex/app/features/apps/pages/apps-page.component.html @@ -16,17 +16,28 @@
-

{{app.name}}

- -
- Edit - - -  | - Content · - Assets · - Settings - +
+
+ +
+
+

{{app.displayName}}

+ + + +
+ {{app.description}} +
+
diff --git a/src/Squidex/app/features/apps/pages/apps-page.component.scss b/src/Squidex/app/features/apps/pages/apps-page.component.scss index ca1de18bf..49daad2ed 100644 --- a/src/Squidex/app/features/apps/pages/apps-page.component.scss +++ b/src/Squidex/app/features/apps/pages/apps-page.component.scss @@ -21,7 +21,7 @@ & { margin-right: 1rem; margin-bottom: 1rem; - width: 16rem; + width: 20rem; float: left; } @@ -29,6 +29,14 @@ width: 33rem; } + &-links { + margin-top: .5rem; + } + + &-left { + padding-right: .75rem; + } + &-image { text-align: center; } @@ -37,6 +45,7 @@ color: $color-text-decent; font-weight: normal; font-size: .9rem; + margin-top: .5rem; } &-more { @@ -51,13 +60,19 @@ color: $color-title; font-weight: light; font-size: 1.2rem; - margin-top: .4rem; + margin-top: 0; + margin-bottom: 0; } &-template { .card-body { min-height: 15.5rem; } + + .card-title { + margin-top: 1rem; + margin-bottom: .75rem; + } } &-href { diff --git a/src/Squidex/app/features/assets/pages/assets-page.component.html b/src/Squidex/app/features/assets/pages/assets-page.component.html index 07aed4612..b946bf314 100644 --- a/src/Squidex/app/features/assets/pages/assets-page.component.html +++ b/src/Squidex/app/features/assets/pages/assets-page.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/content/pages/content/content-page.component.html b/src/Squidex/app/features/content/pages/content/content-page.component.html index 21a0c6170..1a56a578a 100644 --- a/src/Squidex/app/features/content/pages/content/content-page.component.html +++ b/src/Squidex/app/features/content/pages/content/content-page.component.html @@ -1,4 +1,4 @@ - +
diff --git a/src/Squidex/app/features/content/pages/contents/contents-page.component.html b/src/Squidex/app/features/content/pages/contents/contents-page.component.html index 762adaa88..5283e4160 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-page.component.html +++ b/src/Squidex/app/features/content/pages/contents/contents-page.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/content/pages/schemas/schemas-page.component.html b/src/Squidex/app/features/content/pages/schemas/schemas-page.component.html index ac121fd9e..9adbbd8a0 100644 --- a/src/Squidex/app/features/content/pages/schemas/schemas-page.component.html +++ b/src/Squidex/app/features/content/pages/schemas/schemas-page.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/dashboard/pages/dashboard-page.component.html b/src/Squidex/app/features/dashboard/pages/dashboard-page.component.html index 5a9c1cd00..f11416433 100644 --- a/src/Squidex/app/features/dashboard/pages/dashboard-page.component.html +++ b/src/Squidex/app/features/dashboard/pages/dashboard-page.component.html @@ -1,5 +1,5 @@ - +
@@ -7,7 +7,7 @@

Hi {{authState.user?.displayName}}

- Welcome to {{app.name}} dashboard. + Welcome to {{app.displayName}} dashboard.
diff --git a/src/Squidex/app/features/rules/pages/events/rule-events-page.component.html b/src/Squidex/app/features/rules/pages/events/rule-events-page.component.html index 9c9c6f5cb..a53333758 100644 --- a/src/Squidex/app/features/rules/pages/events/rule-events-page.component.html +++ b/src/Squidex/app/features/rules/pages/events/rule-events-page.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/rules/pages/rules/rules-page.component.html b/src/Squidex/app/features/rules/pages/rules/rules-page.component.html index bbc51051f..7a346488f 100644 --- a/src/Squidex/app/features/rules/pages/rules/rules-page.component.html +++ b/src/Squidex/app/features/rules/pages/rules/rules-page.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.scss b/src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.scss index 8b410ef66..eae569083 100644 --- a/src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.scss +++ b/src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.scss @@ -1,6 +1,6 @@ @import '_vars'; @import '_mixins'; -texarea { +textarea { resize: none; } \ No newline at end of file diff --git a/src/Squidex/app/features/schemas/pages/schema/schema-page.component.html b/src/Squidex/app/features/schemas/pages/schema/schema-page.component.html index a3aad8268..d55b9e6f6 100644 --- a/src/Squidex/app/features/schemas/pages/schema/schema-page.component.html +++ b/src/Squidex/app/features/schemas/pages/schema/schema-page.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html b/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html index 7ffc491fd..6af0c2b56 100644 --- a/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html +++ b/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/settings/declarations.ts b/src/Squidex/app/features/settings/declarations.ts index 1a0553935..32b8bfd08 100644 --- a/src/Squidex/app/features/settings/declarations.ts +++ b/src/Squidex/app/features/settings/declarations.ts @@ -10,6 +10,7 @@ export * from './pages/backups/pipes'; export * from './pages/clients/client.component'; export * from './pages/clients/clients-page.component'; export * from './pages/contributors/contributors-page.component'; +export * from './pages/contributors/import-contributors-dialog.component'; export * from './pages/languages/language.component'; export * from './pages/languages/languages-page.component'; export * from './pages/more/more-page.component'; diff --git a/src/Squidex/app/features/settings/module.ts b/src/Squidex/app/features/settings/module.ts index 031f1a72e..2cb535224 100644 --- a/src/Squidex/app/features/settings/module.ts +++ b/src/Squidex/app/features/settings/module.ts @@ -21,6 +21,7 @@ import { ClientComponent, ClientsPageComponent, ContributorsPageComponent, + ImportContributorsDialogComponent, LanguageComponent, LanguagesPageComponent, MorePageComponent, @@ -42,10 +43,7 @@ const routes: Routes = [ component: SettingsAreaComponent, children: [ { - path: '' - }, - { - path: 'more', + path: '', component: MorePageComponent }, { @@ -203,6 +201,7 @@ const routes: Routes = [ ClientComponent, ClientsPageComponent, ContributorsPageComponent, + ImportContributorsDialogComponent, LanguageComponent, LanguagesPageComponent, MorePageComponent, diff --git a/src/Squidex/app/features/settings/pages/backups/backups-page.component.html b/src/Squidex/app/features/settings/pages/backups/backups-page.component.html index f55d7a783..820645e5b 100644 --- a/src/Squidex/app/features/settings/pages/backups/backups-page.component.html +++ b/src/Squidex/app/features/settings/pages/backups/backups-page.component.html @@ -1,4 +1,4 @@ - + @@ -34,15 +34,7 @@
-
- -
-
- -
-
- -
+
diff --git a/src/Squidex/app/features/settings/pages/backups/backups-page.component.scss b/src/Squidex/app/features/settings/pages/backups/backups-page.component.scss index 7d14e7dd0..fbb752506 100644 --- a/src/Squidex/app/features/settings/pages/backups/backups-page.component.scss +++ b/src/Squidex/app/features/settings/pages/backups/backups-page.component.scss @@ -1,29 +1,2 @@ @import '_vars'; -@import '_mixins'; - -$circle-size: 2.8rem; - -.backup-status { - & { - @include circle($circle-size); - line-height: $circle-size + .1rem; - text-align: center; - font-size: .4 * $circle-size; - font-weight: normal; - background: $color-border; - color: $color-dark-foreground; - vertical-align: middle; - } - - &-pending { - color: inherit; - } - - &-failed { - background: $color-theme-error; - } - - &-success { - background: $color-theme-green; - } -} \ No newline at end of file +@import '_mixins'; \ No newline at end of file diff --git a/src/Squidex/app/features/settings/pages/clients/clients-page.component.html b/src/Squidex/app/features/settings/pages/clients/clients-page.component.html index 9093184f7..420a3d3bb 100644 --- a/src/Squidex/app/features/settings/pages/clients/clients-page.component.html +++ b/src/Squidex/app/features/settings/pages/clients/clients-page.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html b/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html index d59f1c7ca..e3bf65586 100644 --- a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html +++ b/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html @@ -1,4 +1,4 @@ - + @@ -6,11 +6,17 @@ - + +
+ +
@@ -21,33 +27,43 @@
- - - - - - - - - - - -
- - - {{contributor.contributorId | sqxUserName}} - - - - -
+ + + + + + + + + + + + +
+ + + + + + + +
+ + +
+ + +
+ No contributors found. +
+
+ + + Big team? Hide many contributors at once + @@ -92,4 +112,10 @@ + + + + + \ No newline at end of file diff --git a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts b/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts index 0d5e85e6b..ed698c636 100644 --- a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts +++ b/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts @@ -16,10 +16,9 @@ import { AutocompleteSource, ContributorDto, ContributorsState, + DialogModel, DialogService, RolesState, - Types, - UserDto, UsersService } from '@app/shared'; @@ -57,6 +56,8 @@ export class UsersDataSource implements AutocompleteSource { export class ContributorsPageComponent implements OnInit { public assignContributorForm = new AssignContributorForm(this.formBuilder); + public importDialog = new DialogModel(); + constructor( public readonly appsState: AppsState, public readonly contributorsState: ContributorsState, @@ -77,6 +78,18 @@ export class ContributorsPageComponent implements OnInit { this.contributorsState.load(true); } + public goPrev() { + this.contributorsState.goPrev(); + } + + public goNext() { + this.contributorsState.goNext(); + } + + public search(query: string) { + this.contributorsState.search(query); + } + public remove(contributor: ContributorDto) { this.contributorsState.revoke(contributor); } @@ -89,15 +102,7 @@ export class ContributorsPageComponent implements OnInit { const value = this.assignContributorForm.submit(); if (value) { - let user = value.user; - - if (Types.is(user, UserDto)) { - user = user.id; - } - - const requestDto = { contributorId: user, role: 'Editor', invite: true }; - - this.contributorsState.assign(requestDto) + this.contributorsState.assign(value) .subscribe(isCreated => { this.assignContributorForm.submitCompleted(); diff --git a/src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.html b/src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.html new file mode 100644 index 000000000..714e44347 --- /dev/null +++ b/src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.html @@ -0,0 +1,43 @@ +
+ + + Import contributors + + + +
+ +
+
+ {{status.email}} +
+ +
+ + +
+
+
+ + + + +
+ + + + Emails detected: {{emails}} + + +   + +
+ + + + + +
+
diff --git a/src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.scss b/src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.scss new file mode 100644 index 000000000..21a9d0e8e --- /dev/null +++ b/src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.scss @@ -0,0 +1,10 @@ +@import '_vars'; +@import '_mixins'; + +textarea { + resize: none; +} + +.content { + min-height: 300px; +} \ No newline at end of file diff --git a/src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.ts b/src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.ts new file mode 100644 index 000000000..170a7a244 --- /dev/null +++ b/src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.ts @@ -0,0 +1,96 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Component, EventEmitter, Output } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; +import { empty, of } from 'rxjs'; +import { catchError, mergeMap, tap } from 'rxjs/operators'; + +import { + ContributorsState, + ErrorDto, + ImportContributorsForm +} from '@app/shared'; + +interface ImportStatus { + email: string; + result: 'Pending' | 'Failed' | 'Success'; + resultText: string; +} + +@Component({ + selector: 'sqx-import-contributors-dialog', + styleUrls: ['./import-contributors-dialog.component.scss'], + templateUrl: './import-contributors-dialog.component.html' +}) +export class ImportContributorsDialogComponent { + @Output() + public close = new EventEmitter(); + + public importForm = new ImportContributorsForm(this.formBuilder); + public importStatus: ImportStatus[]; + + constructor( + private readonly formBuilder: FormBuilder, + private readonly contributorsState: ContributorsState + ) { + } + + public import() { + const contributors = this.importForm.submit(); + + if (contributors && contributors.length > 0) { + this.importStatus = []; + + for (let contributor of contributors) { + this.importStatus.push({ + email: contributor.contributorId, + result: 'Pending', + resultText: 'Pending' + }); + } + + of(...contributors).pipe( + mergeMap(c => + this.contributorsState.assign(c, { silent: true }).pipe( + tap(created => { + let status = this.importStatus.find(x => x.email === c.contributorId); + + if (status) { + status.resultText = getSuccess(created); + status.result = 'Success'; + } + }), + catchError((error: ErrorDto) => { + let status = this.importStatus.find(x => x.email === c.contributorId); + + if (status) { + status.resultText = getError(error); + status.result = 'Failed'; + } + + return empty(); + }) + ), 1) + ).subscribe(); + } + } + + public emitClose() { + this.close.emit(); + } +} + +function getError(error: ErrorDto): string { + return error.details[0]; +} + +function getSuccess(created: boolean | undefined): string { + return created ? + 'User has been invited and assigned.' : + 'User has been assigned'; +} diff --git a/src/Squidex/app/features/settings/pages/languages/languages-page.component.html b/src/Squidex/app/features/settings/pages/languages/languages-page.component.html index 0081e6c18..ab5435476 100644 --- a/src/Squidex/app/features/settings/pages/languages/languages-page.component.html +++ b/src/Squidex/app/features/settings/pages/languages/languages-page.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/settings/pages/more/more-page.component.html b/src/Squidex/app/features/settings/pages/more/more-page.component.html index 3b8869f24..b3340a151 100644 --- a/src/Squidex/app/features/settings/pages/more/more-page.component.html +++ b/src/Squidex/app/features/settings/pages/more/more-page.component.html @@ -1,11 +1,91 @@ - + - + - More + Settings +
+

General

+ +
+
+ + +
+ + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ +
+
+
+
+
+

Image

+ +
+
+
+
+
+ +
+ + +
+ + + + + +
+ +
+
+
Drop to update
+
+
+
+
+
+ Drop an file to replace the app image. Use a square size. + + + Upload File + + + +
+
+
+
+

Danger Zone

@@ -19,7 +99,7 @@
-