Browse Source

App contributor management

pull/1/head
Sebastian 10 years ago
parent
commit
d0a7027a2f
  1. 1
      Squidex.sln.DotSettings
  2. 46
      src/Squidex.Core/Squidex.Core.csproj
  3. 63
      src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  4. 2
      src/Squidex.Read/Apps/IAppContributorEntity.cs
  5. 2
      src/Squidex.Read/Apps/Repositories/IAppRepository.cs
  6. 34
      src/Squidex.Read/Apps/Services/Implementations/CachingAppProvider.cs
  7. 8
      src/Squidex.Read/Schemas/Services/Implementations/CachingSchemaProvider.cs
  8. 2
      src/Squidex.Read/Users/Repositories/IUserRepository.cs
  9. 2
      src/Squidex.Store.MongoDb/Apps/MongoAppContributorEntity.cs
  10. 10
      src/Squidex.Store.MongoDb/Apps/MongoAppRepository.cs
  11. 4
      src/Squidex.Store.MongoDb/Users/MongoUserRepository.cs
  12. 2
      src/Squidex.Write/Apps/AppCommandHandler.cs
  13. 1
      src/Squidex/Configurations/Domain/ReadModule.cs
  14. 21
      src/Squidex/Modules/Api/Apps/AppContributorsController.cs
  15. 12
      src/Squidex/Modules/Api/Apps/AppController.cs
  16. 3
      src/Squidex/Modules/Api/Apps/Models/AppContributorDto.cs
  17. 3
      src/Squidex/Modules/Api/Apps/Models/AssignContributorDto.cs
  18. 6
      src/Squidex/Modules/Api/Apps/Models/ListAppDto.cs
  19. 13
      src/Squidex/Modules/Api/Schemas/SchemaFieldsController.cs
  20. 11
      src/Squidex/Modules/Api/Schemas/SchemasController.cs
  21. 4
      src/Squidex/Modules/Api/Users/Models/UserDto.cs
  22. 4
      src/Squidex/Modules/Api/Users/UsersController.cs
  23. 8
      src/Squidex/Modules/UI/Account/AccountController.cs
  24. 6
      src/Squidex/Pipeline/AppFilterAttribute.cs
  25. 2
      src/Squidex/Views/Account/Login.cshtml
  26. 2
      src/Squidex/app/app.module.ts
  27. 15
      src/Squidex/app/app.routes.ts
  28. 16
      src/Squidex/app/components/internal/app/dashboard/dashboard-page.component.html
  29. 32
      src/Squidex/app/components/internal/app/left-menu.component.html
  30. 102
      src/Squidex/app/components/internal/app/left-menu.component.scss
  31. 39
      src/Squidex/app/components/internal/app/left-menu.component.ts
  32. 20
      src/Squidex/app/components/internal/app/schemas/schemas-page.component.html
  33. 67
      src/Squidex/app/components/internal/app/settings/contributors-page.component.html
  34. 28
      src/Squidex/app/components/internal/app/settings/contributors-page.component.scss
  35. 162
      src/Squidex/app/components/internal/app/settings/contributors-page.component.ts
  36. 14
      src/Squidex/app/components/internal/app/settings/credentials-page.component.html
  37. 6
      src/Squidex/app/components/internal/app/settings/credentials-page.component.scss
  38. 39
      src/Squidex/app/components/internal/app/settings/credentials-page.component.ts
  39. 14
      src/Squidex/app/components/internal/app/settings/languages-page.component.html
  40. 6
      src/Squidex/app/components/internal/app/settings/languages-page.component.scss
  41. 39
      src/Squidex/app/components/internal/app/settings/languages-page.component.ts
  42. 3
      src/Squidex/app/components/internal/declarations.ts
  43. 9
      src/Squidex/app/components/internal/module.ts
  44. 12
      src/Squidex/app/components/layout/app-form.component.html
  45. 2
      src/Squidex/app/components/layout/apps-menu.component.html
  46. 9
      src/Squidex/app/components/layout/apps-menu.component.scss
  47. 6
      src/Squidex/app/components/layout/profile-menu.component.html
  48. 43
      src/Squidex/app/components/layout/profile-menu.component.scss
  49. 25
      src/Squidex/app/framework/angular/animations.ts
  50. 13
      src/Squidex/app/framework/services/local-store.service.ts
  51. 16
      src/Squidex/app/shared/services/app-contributors.service.spec.ts
  52. 10
      src/Squidex/app/shared/services/app-contributors.service.ts
  53. 6
      src/Squidex/app/shared/services/apps-store.service.spec.ts
  54. 12
      src/Squidex/app/shared/services/apps.service.spec.ts
  55. 8
      src/Squidex/app/shared/services/apps.service.ts
  56. 8
      src/Squidex/app/shared/services/languages.service.spec.ts
  57. 6
      src/Squidex/app/shared/services/languages.service.ts
  58. 16
      src/Squidex/app/shared/services/users-provider.service.spec.ts
  59. 16
      src/Squidex/app/shared/services/users-provider.service.ts
  60. 43
      src/Squidex/app/shared/services/users.service.spec.ts
  61. 25
      src/Squidex/app/shared/services/users.service.ts
  62. 12
      src/Squidex/app/theme/_bootstrap.scss
  63. 39
      src/Squidex/app/theme/_completer.scss
  64. 67
      src/Squidex/app/theme/_layout.scss
  65. 28
      src/Squidex/app/theme/_mixins.scss
  66. 5
      src/Squidex/app/theme/_vars.scss
  67. 9
      src/Squidex/app/theme/_vendor-overrides.scss
  68. 3
      src/Squidex/app/theme/theme.scss
  69. 1
      src/Squidex/package.json

1
Squidex.sln.DotSettings

@ -90,4 +90,5 @@
<s:String x:Key="/Default/CodeStyle/Naming/XamlNaming/UserRules/=XAML_005FFIELD/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /&gt;</s:String> <s:String x:Key="/Default/CodeStyle/Naming/XamlNaming/UserRules/=XAML_005FFIELD/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/XamlNaming/UserRules/=XAML_005FRESOURCE/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /&gt;</s:String> <s:String x:Key="/Default/CodeStyle/Naming/XamlNaming/UserRules/=XAML_005FRESOURCE/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /&gt;</s:String>
<s:Boolean x:Key="/Default/CodeStyle/TypeScriptCodeStyle/ExplicitPublicModifier/@EntryValue">True</s:Boolean> <s:Boolean x:Key="/Default/CodeStyle/TypeScriptCodeStyle/ExplicitPublicModifier/@EntryValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002EJavaScript_002ECodeStyle_002ESettingsUpgrade_002EJsParsFormattingSettingsUpgrader/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002EJavaScript_002ECodeStyle_002ESettingsUpgrade_002EJsWrapperSettingsUpgrader/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary> <s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002EJavaScript_002ECodeStyle_002ESettingsUpgrade_002EJsWrapperSettingsUpgrader/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

46
src/Squidex.Core/Squidex.Core.csproj

@ -0,0 +1,46 @@
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" />
<PropertyGroup>
<TargetFramework>netcoreapp1.0</TargetFramework>
<AssemblyName>Squidex.Core</AssemblyName>
</PropertyGroup>
<ItemGroup>
<Compile Include="**\*.cs" />
<EmbeddedResource Include="**\*.resx" />
<EmbeddedResource Include="compiler\resources\**\*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Squidex.Infrastructure\Squidex.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Sdk">
<Version>1.0.0-alpha-20161104-2</Version>
<PrivateAssets>All</PrivateAssets>
</PackageReference>
<PackageReference Include="NETStandard.Library">
<Version>1.6.0</Version>
</PackageReference>
<PackageReference Include="NodaTime">
<Version>2.0.0-alpha20160729</Version>
</PackageReference>
<PackageReference Include="protobuf-net">
<Version>2.1.0</Version>
</PackageReference>
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'netcoreapp1.0' ">
<PackageReference Include="Microsoft.NETCore.App">
<Version>1.0.1</Version>
</PackageReference>
</ItemGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DefineConstants>$(DefineConstants);RELEASE</DefineConstants>
</PropertyGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

63
src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -0,0 +1,63 @@
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" />
<PropertyGroup>
<TargetFramework>netcoreapp1.0</TargetFramework>
<AssemblyName>Squidex.Infrastructure</AssemblyName>
</PropertyGroup>
<ItemGroup>
<Compile Include="**\*.cs" />
<EmbeddedResource Include="**\*.resx" Exclude="bin\**;obj\**;**\*.xproj;packages\**" />
<EmbeddedResource Include="*.csv;compiler\resources\**\*" Exclude="bin\**;obj\**;**\*.xproj;packages\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Sdk">
<Version>1.0.0-alpha-20161104-2</Version>
<PrivateAssets>All</PrivateAssets>
</PackageReference>
<PackageReference Include="Autofac">
<Version>4.1.0</Version>
</PackageReference>
<PackageReference Include="EventStore.ClientAPI.NetCore">
<Version>0.0.1-alpha</Version>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Logging">
<Version>1.0.0</Version>
</PackageReference>
<PackageReference Include="NETStandard.Library">
<Version>1.6.0</Version>
</PackageReference>
<PackageReference Include="Newtonsoft.Json">
<Version>9.0.2-beta1</Version>
</PackageReference>
<PackageReference Include="NodaTime">
<Version>2.0.0-alpha20160729</Version>
</PackageReference>
<PackageReference Include="protobuf-net">
<Version>2.1.0</Version>
</PackageReference>
<PackageReference Include="System.Linq">
<Version>4.1.0</Version>
</PackageReference>
<PackageReference Include="System.Reflection.TypeExtensions">
<Version>4.1.0</Version>
</PackageReference>
<PackageReference Include="System.Security.Claims">
<Version>4.0.1</Version>
</PackageReference>
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'netcoreapp1.0' ">
<PackageReference Include="Microsoft.NETCore.App">
<Version>1.0.1</Version>
</PackageReference>
</ItemGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DefineConstants>$(DefineConstants);RELEASE</DefineConstants>
</PropertyGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

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

@ -12,7 +12,7 @@ namespace Squidex.Read.Apps
{ {
public interface IAppContributorEntity public interface IAppContributorEntity
{ {
string SubjectId { get; } string ContributorId { get; }
PermissionLevel Permission { get; } PermissionLevel Permission { get; }
} }

2
src/Squidex.Read/Apps/Repositories/IAppRepository.cs

@ -13,7 +13,7 @@ namespace Squidex.Read.Apps.Repositories
{ {
public interface IAppRepository public interface IAppRepository
{ {
Task<IReadOnlyList<IAppEntity>> QueryAllAsync(string currentSubjectId); Task<IReadOnlyList<IAppEntity>> QueryAllAsync(string subjectId);
Task<IAppEntity> FindAppByNameAsync(string name); Task<IAppEntity> FindAppByNameAsync(string name);
} }

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

@ -9,7 +9,10 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Squidex.Events.Apps;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.CQRS;
using Squidex.Infrastructure.CQRS.Events;
using Squidex.Read.Apps.Repositories; using Squidex.Read.Apps.Repositories;
using Squidex.Read.Utils; using Squidex.Read.Utils;
@ -17,7 +20,7 @@ using Squidex.Read.Utils;
namespace Squidex.Read.Apps.Services.Implementations namespace Squidex.Read.Apps.Services.Implementations
{ {
public class CachingAppProvider : CachingProvider, IAppProvider public class CachingAppProvider : CachingProvider, IAppProvider, ILiveEventConsumer
{ {
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(30); private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(30);
private readonly IAppRepository appRepository; private readonly IAppRepository appRepository;
@ -39,7 +42,7 @@ namespace Squidex.Read.Apps.Services.Implementations
{ {
Guard.NotNullOrEmpty(name, nameof(name)); Guard.NotNullOrEmpty(name, nameof(name));
var cacheKey = BulidCacheKey(name); var cacheKey = BuildModelCacheKey(name);
var cacheItem = Cache.Get<CacheItem>(cacheKey); var cacheItem = Cache.Get<CacheItem>(cacheKey);
if (cacheItem == null) if (cacheItem == null)
@ -49,12 +52,37 @@ namespace Squidex.Read.Apps.Services.Implementations
cacheItem = new CacheItem { Entity = app }; cacheItem = new CacheItem { Entity = app };
Cache.Set(cacheKey, cacheItem, new MemoryCacheEntryOptions { SlidingExpiration = CacheDuration }); Cache.Set(cacheKey, cacheItem, new MemoryCacheEntryOptions { SlidingExpiration = CacheDuration });
if (cacheItem.Entity != null)
{
Cache.Set(BuildNamesCacheKey(cacheItem.Entity.Id), cacheItem.Entity.Name, CacheDuration);
}
} }
return cacheItem.Entity; return cacheItem.Entity;
} }
private static string BulidCacheKey(string name) public Task On(Envelope<IEvent> @event)
{
if (@event.Payload is AppContributorAssigned || @event.Payload is AppContributorRemoved)
{
var appName = Cache.Get<string>(BuildNamesCacheKey(@event.Headers.AggregateId()));
if (appName != null)
{
Cache.Remove(BuildModelCacheKey(appName));
}
}
return Task.FromResult(true);
}
private static string BuildNamesCacheKey(Guid schemaId)
{
return $"App_Names_{schemaId}";
}
private static string BuildModelCacheKey(string name)
{ {
return $"App_{name}"; return $"App_{name}";
} }

8
src/Squidex.Read/Schemas/Services/Implementations/CachingSchemaProvider.cs

@ -42,7 +42,7 @@ namespace Squidex.Read.Schemas.Services.Implementations
{ {
Guard.NotNullOrEmpty(name, nameof(name)); Guard.NotNullOrEmpty(name, nameof(name));
var cacheKey = BuildModelsCacheKey(appId, name); var cacheKey = BuildModelCacheKey(appId, name);
var cacheItem = Cache.Get<CacheItem>(cacheKey); var cacheItem = Cache.Get<CacheItem>(cacheKey);
if (cacheItem == null) if (cacheItem == null)
@ -70,16 +70,16 @@ namespace Squidex.Read.Schemas.Services.Implementations
if (oldName != null) if (oldName != null)
{ {
Cache.Remove(BuildModelsCacheKey(@event.Headers.AppId(), oldName)); Cache.Remove(BuildModelCacheKey(@event.Headers.AppId(), oldName));
} }
} }
return Task.FromResult(true); return Task.FromResult(true);
} }
private static string BuildModelsCacheKey(Guid appId, string name) private static string BuildModelCacheKey(Guid appId, string name)
{ {
return $"Schemas_Models_{appId}_{name}"; return $"Schema_{appId}_{name}";
} }
private static string BuildNamesCacheKey(Guid schemaId) private static string BuildNamesCacheKey(Guid schemaId)

2
src/Squidex.Read/Users/Repositories/IUserRepository.cs

@ -13,7 +13,7 @@ namespace Squidex.Read.Users.Repositories
{ {
public interface IUserRepository public interface IUserRepository
{ {
Task<List<IUserEntity>> FindUsersByEmail(string email); Task<List<IUserEntity>> FindUsersByQuery(string query);
Task<IUserEntity> FindUserByIdAsync(string id); Task<IUserEntity> FindUserByIdAsync(string id);
} }

2
src/Squidex.Store.MongoDb/Apps/MongoAppContributorEntity.cs

@ -16,7 +16,7 @@ namespace Squidex.Store.MongoDb.Apps
{ {
[BsonRequired] [BsonRequired]
[BsonElement] [BsonElement]
public string SubjectId { get; set; } public string ContributorId { get; set; }
[BsonRequired] [BsonRequired]
[BsonElement] [BsonElement]

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

@ -38,10 +38,10 @@ namespace Squidex.Store.MongoDb.Apps
return collection.Indexes.CreateOneAsync(IndexKeys.Ascending(x => x.Name)); return collection.Indexes.CreateOneAsync(IndexKeys.Ascending(x => x.Name));
} }
public async Task<IReadOnlyList<IAppEntity>> QueryAllAsync(string currentSubjectId) public async Task<IReadOnlyList<IAppEntity>> QueryAllAsync(string subjectId)
{ {
var entities = var entities =
await Collection.Find(s => s.Contributors.Any(c => c.SubjectId == currentSubjectId)).ToListAsync(); await Collection.Find(s => s.Contributors.Any(c => c.ContributorId == subjectId)).ToListAsync();
return entities; return entities;
} }
@ -61,18 +61,18 @@ namespace Squidex.Store.MongoDb.Apps
public Task On(AppContributorRemoved @event, EnvelopeHeaders headers) public Task On(AppContributorRemoved @event, EnvelopeHeaders headers)
{ {
return Collection.UpdateAsync(headers, a => a.Contributors.RemoveAll(c => c.SubjectId == @event.ContributorId)); return Collection.UpdateAsync(headers, a => a.Contributors.RemoveAll(c => c.ContributorId == @event.ContributorId));
} }
public Task On(AppContributorAssigned @event, EnvelopeHeaders headers) public Task On(AppContributorAssigned @event, EnvelopeHeaders headers)
{ {
return Collection.UpdateAsync(headers, a => return Collection.UpdateAsync(headers, a =>
{ {
var contributor = a.Contributors.Find(x => x.SubjectId == @event.ContributorId); var contributor = a.Contributors.Find(x => x.ContributorId == @event.ContributorId);
if (contributor == null) if (contributor == null)
{ {
contributor = new MongoAppContributorEntity { SubjectId = @event.ContributorId }; contributor = new MongoAppContributorEntity { ContributorId = @event.ContributorId };
a.Contributors.Add(contributor); a.Contributors.Add(contributor);
} }

4
src/Squidex.Store.MongoDb/Users/MongoUserRepository.cs

@ -28,9 +28,9 @@ namespace Squidex.Store.MongoDb.Users
this.userManager = userManager; this.userManager = userManager;
} }
public Task<List<IUserEntity>> FindUsersByEmail(string email) public Task<List<IUserEntity>> FindUsersByQuery(string query)
{ {
var users = userManager.Users.Where(x => x.NormalizedEmail.Contains(email)).Take(10).ToList(); var users = userManager.Users.Where(x => x.NormalizedEmail.Contains(query.ToUpper())).Take(10).ToList();
return Task.FromResult(users.Select(x => (IUserEntity)new MongoUserEntity(x)).ToList()); return Task.FromResult(users.Select(x => (IUserEntity)new MongoUserEntity(x)).ToList());
} }

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

@ -56,7 +56,7 @@ namespace Squidex.Write.Apps
{ {
if (await userRepository.FindUserByIdAsync(command.ContributorId) == null) if (await userRepository.FindUserByIdAsync(command.ContributorId) == null)
{ {
var error = new ValidationError($"Cannot find contributor '{command.ContributorId}", nameof(AssignContributor.ContributorId)); var error = new ValidationError($"Cannot find contributor '{command.ContributorId ?? "UNKNOWN"}'", nameof(AssignContributor.ContributorId));
throw new ValidationException("Cannot assign contributor to app", error); throw new ValidationException("Cannot assign contributor to app", error);
} }

1
src/Squidex/Configurations/Domain/ReadModule.cs

@ -21,6 +21,7 @@ namespace Squidex.Configurations.Domain
{ {
builder.RegisterType<CachingAppProvider>() builder.RegisterType<CachingAppProvider>()
.As<IAppProvider>() .As<IAppProvider>()
.As<ILiveEventConsumer>()
.SingleInstance(); .SingleInstance();
builder.RegisterType<CachingSchemaProvider>() builder.RegisterType<CachingSchemaProvider>()

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

@ -14,7 +14,7 @@ using Squidex.Infrastructure.CQRS.Commands;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
using Squidex.Modules.Api.Apps.Models; using Squidex.Modules.Api.Apps.Models;
using Squidex.Pipeline; using Squidex.Pipeline;
using Squidex.Read.Apps.Repositories; using Squidex.Read.Apps.Services;
using Squidex.Write.Apps.Commands; using Squidex.Write.Apps.Commands;
namespace Squidex.Modules.Api.Apps namespace Squidex.Modules.Api.Apps
@ -22,22 +22,21 @@ namespace Squidex.Modules.Api.Apps
[Authorize] [Authorize]
[ApiExceptionFilter] [ApiExceptionFilter]
[ServiceFilter(typeof(AppFilterAttribute))] [ServiceFilter(typeof(AppFilterAttribute))]
[Route("apps/{app}")]
public class AppContributorsController : ControllerBase public class AppContributorsController : ControllerBase
{ {
private readonly IAppRepository appRepository; private readonly IAppProvider appProvider;
public AppContributorsController(ICommandBus commandBus, IAppRepository appRepository) public AppContributorsController(ICommandBus commandBus, IAppProvider appProvider)
: base(commandBus) : base(commandBus)
{ {
this.appRepository = appRepository; this.appProvider = appProvider;
} }
[HttpGet] [HttpGet]
[Route("contributors")] [Route("apps/{app}/contributors/")]
public async Task<IActionResult> GetContributors(string app) public async Task<IActionResult> GetContributors(string app)
{ {
var entity = await appRepository.FindAppByNameAsync(app); var entity = await appProvider.FindAppByNameAsync(app);
if (entity == null) if (entity == null)
{ {
@ -49,9 +48,9 @@ namespace Squidex.Modules.Api.Apps
return Ok(model); return Ok(model);
} }
[HttpPut] [HttpPost]
[Route("contributors")] [Route("apps/{app}/contributors/")]
public async Task<IActionResult> PutContributor([FromBody] AssignContributorDto model) public async Task<IActionResult> PostContributor([FromBody] AssignContributorDto model)
{ {
await CommandBus.PublishAsync(SimpleMapper.Map(model, new AssignContributor())); await CommandBus.PublishAsync(SimpleMapper.Map(model, new AssignContributor()));
@ -59,7 +58,7 @@ namespace Squidex.Modules.Api.Apps
} }
[HttpDelete] [HttpDelete]
[Route("contributors/{contributorId}")] [Route("apps/{app}/contributors/{contributorId}/")]
public async Task<IActionResult> PutContributor(string contributorId) public async Task<IActionResult> PutContributor(string contributorId)
{ {
await CommandBus.PublishAsync(new RemoveContributor { ContributorId = contributorId }); await CommandBus.PublishAsync(new RemoveContributor { ContributorId = contributorId });

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

@ -38,9 +38,17 @@ namespace Squidex.Modules.Api.Apps
[Route("apps/")] [Route("apps/")]
public async Task<List<ListAppDto>> Query() public async Task<List<ListAppDto>> Query()
{ {
var schemas = await appRepository.QueryAllAsync(HttpContext.User.OpenIdSubject()); var subject = HttpContext.User.OpenIdSubject();
var schemas = await appRepository.QueryAllAsync(subject);
return schemas.Select(s => SimpleMapper.Map(s, new ListAppDto())).ToList(); return schemas.Select(s =>
{
var dto = SimpleMapper.Map(s, new ListAppDto());
dto.Permission = s.Contributors.Single(x => x.ContributorId == subject).Permission;
return dto;
}).ToList();
} }
[HttpPost] [HttpPost]

3
src/Squidex/Modules/Api/Apps/Models/AppContributorDto.cs

@ -6,6 +6,8 @@
// All rights reserved. // All rights reserved.
// ========================================================================== // ==========================================================================
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Squidex.Core.Apps; using Squidex.Core.Apps;
namespace Squidex.Modules.Api.Apps.Models namespace Squidex.Modules.Api.Apps.Models
@ -14,6 +16,7 @@ namespace Squidex.Modules.Api.Apps.Models
{ {
public string ContributorId { get; set; } public string ContributorId { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public PermissionLevel Permission { get; set; } public PermissionLevel Permission { get; set; }
} }
} }

3
src/Squidex/Modules/Api/Apps/Models/AssignContributorDto.cs

@ -6,6 +6,8 @@
// All rights reserved. // All rights reserved.
// ========================================================================== // ==========================================================================
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Squidex.Core.Apps; using Squidex.Core.Apps;
namespace Squidex.Modules.Api.Apps.Models namespace Squidex.Modules.Api.Apps.Models
@ -14,6 +16,7 @@ namespace Squidex.Modules.Api.Apps.Models
{ {
public string ContributorId { get; set; } public string ContributorId { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public PermissionLevel Permission { get; set; } public PermissionLevel Permission { get; set; }
} }
} }

6
src/Squidex/Modules/Api/Apps/Models/ListAppDto.cs

@ -7,6 +7,9 @@
// ========================================================================== // ==========================================================================
using System; using System;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Squidex.Core.Apps;
namespace Squidex.Modules.Api.Apps.Models namespace Squidex.Modules.Api.Apps.Models
{ {
@ -19,5 +22,8 @@ namespace Squidex.Modules.Api.Apps.Models
public DateTime Created { get; set; } public DateTime Created { get; set; }
public DateTime LastModified { get; set; } public DateTime LastModified { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public PermissionLevel Permission { get; set; }
} }
} }

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

@ -20,7 +20,6 @@ namespace Squidex.Modules.Api.Schemas
[Authorize] [Authorize]
[ApiExceptionFilter] [ApiExceptionFilter]
[ServiceFilter(typeof(AppFilterAttribute))] [ServiceFilter(typeof(AppFilterAttribute))]
[Route("apps/{app}")]
public class SchemasFieldsController : ControllerBase public class SchemasFieldsController : ControllerBase
{ {
public SchemasFieldsController(ICommandBus commandBus) public SchemasFieldsController(ICommandBus commandBus)
@ -29,7 +28,7 @@ namespace Squidex.Modules.Api.Schemas
} }
[HttpPost] [HttpPost]
[Route("schemas/{name}/fields/")] [Route("apps/{app}/schemas/{name}/fields/")]
public Task Add(string name, [FromBody] CreateFieldDto model) public Task Add(string name, [FromBody] CreateFieldDto model)
{ {
var command = SimpleMapper.Map(model, new AddField()); var command = SimpleMapper.Map(model, new AddField());
@ -38,7 +37,7 @@ namespace Squidex.Modules.Api.Schemas
} }
[HttpPut] [HttpPut]
[Route("schemas/{name}/fields/{fieldId:long}/")] [Route("apps/{app}/schemas/{name}/fields/{fieldId:long}/")]
public Task Update(string name, long fieldId, [FromBody] UpdateFieldDto model) public Task Update(string name, long fieldId, [FromBody] UpdateFieldDto model)
{ {
var command = SimpleMapper.Map(model, new UpdateField()); var command = SimpleMapper.Map(model, new UpdateField());
@ -47,7 +46,7 @@ namespace Squidex.Modules.Api.Schemas
} }
[HttpPut] [HttpPut]
[Route("schemas/{name}/fields/{fieldId:long}/hide/")] [Route("apps/{app}/schemas/{name}/fields/{fieldId:long}/hide/")]
public Task Hide(string name, long fieldId) public Task Hide(string name, long fieldId)
{ {
var command = new HideField { FieldId = fieldId }; var command = new HideField { FieldId = fieldId };
@ -56,7 +55,7 @@ namespace Squidex.Modules.Api.Schemas
} }
[HttpPut] [HttpPut]
[Route("schemas/{name}/fields/{fieldId:long}/show/")] [Route("apps/{app}/schemas/{name}/fields/{fieldId:long}/show/")]
public Task Show(string name, long fieldId) public Task Show(string name, long fieldId)
{ {
var command = new ShowField { FieldId = fieldId }; var command = new ShowField { FieldId = fieldId };
@ -74,7 +73,7 @@ namespace Squidex.Modules.Api.Schemas
} }
[HttpPut] [HttpPut]
[Route("schemas/{name}/fields/{fieldId:long}/disable/")] [Route("apps/{app}/schemas/{name}/fields/{fieldId:long}/disable/")]
public Task Disable(string name, long fieldId) public Task Disable(string name, long fieldId)
{ {
var command = new DisableField { FieldId = fieldId }; var command = new DisableField { FieldId = fieldId };
@ -83,7 +82,7 @@ namespace Squidex.Modules.Api.Schemas
} }
[HttpDelete] [HttpDelete]
[Route("schemas/{name}/fields/{fieldId:long}/")] [Route("apps/{app}/schemas/{name}/fields/{fieldId:long}/")]
public Task Delete(string name, long fieldId) public Task Delete(string name, long fieldId)
{ {
var command = new DeleteField { FieldId = fieldId }; var command = new DeleteField { FieldId = fieldId };

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

@ -25,7 +25,6 @@ namespace Squidex.Modules.Api.Schemas
[Authorize] [Authorize]
[ApiExceptionFilter] [ApiExceptionFilter]
[ServiceFilter(typeof(AppFilterAttribute))] [ServiceFilter(typeof(AppFilterAttribute))]
[Route("apps/{app}")]
public class SchemasController : ControllerBase public class SchemasController : ControllerBase
{ {
private readonly ISchemaRepository schemaRepository; private readonly ISchemaRepository schemaRepository;
@ -37,7 +36,7 @@ namespace Squidex.Modules.Api.Schemas
} }
[HttpGet] [HttpGet]
[Route("schemas/")] [Route("apps/{app}/schemas/")]
public async Task<List<ListSchemaDto>> Query() public async Task<List<ListSchemaDto>> Query()
{ {
var schemas = await schemaRepository.QueryAllAsync(AppId); var schemas = await schemaRepository.QueryAllAsync(AppId);
@ -46,7 +45,7 @@ namespace Squidex.Modules.Api.Schemas
} }
[HttpGet] [HttpGet]
[Route("schemas/{name}/")] [Route("apps/{app}/schemas/{name}/")]
public async Task<ActionResult> Get(string name) public async Task<ActionResult> Get(string name)
{ {
var entity = await schemaRepository.FindSchemaAsync(AppId, name); var entity = await schemaRepository.FindSchemaAsync(AppId, name);
@ -62,7 +61,7 @@ namespace Squidex.Modules.Api.Schemas
} }
[HttpPost] [HttpPost]
[Route("schemas/")] [Route("apps/{app}/schemas/")]
public async Task<ActionResult> Create([FromBody] CreateSchemaDto model) public async Task<ActionResult> Create([FromBody] CreateSchemaDto model)
{ {
var command = SimpleMapper.Map(model, new CreateSchema { AggregateId = Guid.NewGuid() }); var command = SimpleMapper.Map(model, new CreateSchema { AggregateId = Guid.NewGuid() });
@ -73,7 +72,7 @@ namespace Squidex.Modules.Api.Schemas
} }
[HttpPut] [HttpPut]
[Route("schemas/{name}/")] [Route("apps/{app}/schemas/{name}/")]
public async Task<ActionResult> Update(string name, [FromBody] UpdateSchemaDto model) public async Task<ActionResult> Update(string name, [FromBody] UpdateSchemaDto model)
{ {
var command = SimpleMapper.Map(model, new UpdateSchema()); var command = SimpleMapper.Map(model, new UpdateSchema());
@ -84,7 +83,7 @@ namespace Squidex.Modules.Api.Schemas
} }
[HttpDelete] [HttpDelete]
[Route("schemas/{name}/")] [Route("apps/{app}/schemas/{name}/")]
public async Task<ActionResult> Delete(string name) public async Task<ActionResult> Delete(string name)
{ {
await CommandBus.PublishAsync(new DeleteSchema()); await CommandBus.PublishAsync(new DeleteSchema());

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

@ -11,8 +11,10 @@ namespace Squidex.Modules.Api.Users.Models
public sealed class UserDto public sealed class UserDto
{ {
public string Id { get; set; } public string Id { get; set; }
public string Email { get; set; }
public string ProfileUrl { get; set; } public string PictureUrl { get; set; }
public string DisplayName { get; set; } public string DisplayName { get; set; }
} }

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

@ -30,9 +30,9 @@ namespace Squidex.Modules.Api.Users
[HttpGet] [HttpGet]
[Route("users")] [Route("users")]
public async Task<IActionResult> GetUsers(string email) public async Task<IActionResult> GetUsers(string query)
{ {
var entities = await userRepository.FindUsersByEmail(email); var entities = await userRepository.FindUsersByQuery(query ?? string.Empty);
var model = entities.Select(x => SimpleMapper.Map(x, new UserDto())).ToList(); var model = entities.Select(x => SimpleMapper.Map(x, new UserDto())).ToList();

8
src/Squidex/Modules/UI/Account/AccountController.cs

@ -42,7 +42,7 @@ namespace Squidex.Modules.UI.Account
[Route("account/forbidden")] [Route("account/forbidden")]
public IActionResult Forbidden() public IActionResult Forbidden()
{ {
return View(); return View("Error");
} }
[HttpGet] [HttpGet]
@ -164,10 +164,10 @@ namespace Squidex.Modules.UI.Account
var user = new IdentityUser { Email = mail, UserName = mail }; var user = new IdentityUser { Email = mail, UserName = mail };
var profileUrl = externalLogin.Principal.Claims.FirstOrDefault(x => x.Type == ExtendedClaimTypes.SquidexPictureUrl); var pictureUrl = externalLogin.Principal.Claims.FirstOrDefault(x => x.Type == ExtendedClaimTypes.SquidexPictureUrl);
if (profileUrl != null) if (pictureUrl != null)
{ {
user.AddClaim(profileUrl); user.AddClaim(pictureUrl);
} }
var displayName = externalLogin.Principal.Claims.FirstOrDefault(x => x.Type == ExtendedClaimTypes.SquidexDisplayName); var displayName = externalLogin.Principal.Claims.FirstOrDefault(x => x.Type == ExtendedClaimTypes.SquidexDisplayName);

6
src/Squidex/Pipeline/AppFilterAttribute.cs

@ -7,10 +7,10 @@
// ========================================================================== // ==========================================================================
using System.Linq; using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Squidex.Infrastructure.Security;
using Squidex.Read.Apps.Services; using Squidex.Read.Apps.Services;
namespace Squidex.Pipeline namespace Squidex.Pipeline
@ -38,9 +38,9 @@ namespace Squidex.Pipeline
return; return;
} }
var subject = context.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; var subject = context.HttpContext.User.FindFirst(OpenIdClaims.Subject)?.Value;
if (subject == null || app.Contributors.Any(x => x.SubjectId == subject)) if (subject == null || app.Contributors.All(x => x.ContributorId != subject))
{ {
context.Result = new NotFoundResult(); context.Result = new NotFoundResult();
return; return;

2
src/Squidex/Views/Account/Login.cshtml

@ -20,7 +20,7 @@
<p> <p>
@foreach (var provider in Model.ExternalProviders) @foreach (var provider in Model.ExternalProviders)
{ {
<button type="submit" name="provider" id="loginButton" value="@provider.AuthenticationScheme" title="Log in using your @provider.DisplayDisplayName account">@provider.AuthenticationScheme</button> <button type="submit" name="provider" id="loginButton" value="@provider.AuthenticationScheme" title="Log in using your @provider.DisplayName account">@provider.AuthenticationScheme</button>
} }
</p> </p>
</div> </div>

2
src/Squidex/app/app.module.ts

@ -24,6 +24,7 @@ import {
MustBeAuthenticatedGuard, MustBeAuthenticatedGuard,
MustBeNotAuthenticatedGuard, MustBeNotAuthenticatedGuard,
LanguageService, LanguageService,
LocalStoreService,
SqxFrameworkModule, SqxFrameworkModule,
TitlesConfig, TitlesConfig,
TitleService, TitleService,
@ -62,6 +63,7 @@ const baseUrl = window.location.protocol + '//' + window.location.host + '/';
AppMustExistGuard, AppMustExistGuard,
AuthService, AuthService,
LanguageService, LanguageService,
LocalStoreService,
MustBeAuthenticatedGuard, MustBeAuthenticatedGuard,
MustBeNotAuthenticatedGuard, MustBeNotAuthenticatedGuard,
TitleService, TitleService,

15
src/Squidex/app/app.routes.ts

@ -11,9 +11,12 @@ import * as Ng2Router from '@angular/router';
import { import {
AppsPageComponent, AppsPageComponent,
AppAreaComponent, AppAreaComponent,
ContributorsPageComponent,
CredentialsPageComponent,
DashboardPageComponent, DashboardPageComponent,
InternalAreaComponent, InternalAreaComponent,
HomePageComponent, HomePageComponent,
LanguagesPageComponent,
LogoutPageComponent, LogoutPageComponent,
NotFoundPageComponent, NotFoundPageComponent,
SchemasPageComponent SchemasPageComponent
@ -56,6 +59,18 @@ export const routes: Ng2Router.Routes = [
{ {
path: 'schemas', path: 'schemas',
component: SchemasPageComponent component: SchemasPageComponent
},
{
path: 'contributors',
component: ContributorsPageComponent
},
{
path: 'credentials',
component: CredentialsPageComponent
},
{
path: 'languages',
component: LanguagesPageComponent
} }
] ]
} }

16
src/Squidex/app/components/internal/app/dashboard/dashboard-page.component.html

@ -1,10 +1,14 @@
<div class="layout-content"> <div class="layout">
<div class="layout-content-left layout-content-left--no-button"> <div class="layout-left">
<sqx-left-menu></sqx-left-menu> <sqx-left-menu></sqx-left-menu>
</div> </div>
<div class="layout-content-main"> <div class="layout-middle">
<h1> <div class="layout-middle-header">
<i class="layout-title-icon icon-dashboard"></i> Dashboard <h1>
</h1> <i class="layout-title-icon icon-dashboard"></i> Dashboard
</h1>
</div>
<div class="layout-middle-content">
</div>
</div> </div>
</div> </div>

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

@ -1,26 +1,38 @@
<ul class="nav"> <ul class="nav">
<li class="nav-item"> <li class="nav-item nav-item--dashboard">
<a class="nav-link nav-link--dashboard" routerLink="../dashboard" routerLinkActive="active"> <a class="nav-link" routerLink="../dashboard" routerLinkActive="active">
<i class="icon-dashboard"></i> Dashboard <i class="icon-dashboard"></i> Dashboard
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-items nav-item--schemas" *ngIf="permission !== 'Editor'">
<a class="nav-link nav-link--schemas" routerLink="../schemas" routerLinkActive="active"> <a class="nav-link" routerLink="../schemas" routerLinkActive="active">
<i class="icon-schemas"></i> Schemas <i class="icon-schemas"></i> Schemas
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item nav-item--content">
<a class="nav-link nav-link--content"> <a class="nav-link">
<i class="icon-content"></i> Content <i class="icon-content"></i> Content
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item nav-item--media">
<a class="nav-link nav-link--media"> <a class="nav-link">
<i class="icon-media"></i> Media</a> <i class="icon-media"></i> Media</a>
</li> </li>
<li class="nav-item"> <li class="nav-item nav-item--settings nav-item-group" *ngIf="permission === 'Owner'">
<a class="nav-link nav-link--settings"> <a class="nav-link" (click)="toggleSettingsMenu()">
<i class="icon-settings"></i> Settings <i class="icon-settings"></i> Settings
</a> </a>
<ul class="subnav" *ngIf="showSettingsMenu">
<li class="subnav-item">
<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>
</li>
<li class="subnav-item">
<a class="nav-link" routerLink="../languages" routerLinkActive="active">Languages</a>
</li>
</ul>
</li> </li>
</ul> </ul>

102
src/Squidex/app/components/internal/app/left-menu.component.scss

@ -2,16 +2,20 @@
@import '_mixins'; @import '_mixins';
$color-selection-background: #ebf0f2; $color-selection-background: #ebf0f2;
$color-subnav-text: #818181;
$color-subnav-dot: #bdc6d0;
$color-border-left-width: 4px; $color-border-left-width: 4px;
@mixin build-item($color) { @mixin build-item($color) {
&:hover, & .nav-link {
&.active { &:hover,
border-color: $color; &.active {
border-color: $color;
}
} }
& i { & i {
margin-right: .5rem;
color: $color; color: $color;
} }
} }
@ -20,9 +24,41 @@ $color-border-left-width: 4px;
& { & {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin-top: 40px; margin-left: -$padding-layout-h - 1px;
margin-left: -$padding-layout - 1px; margin-right: -$padding-layout-h;
margin-right: -$padding-layout; }
&-item {
&--media {
@include build-item($color-section-media);
}
&--content {
@include build-item($color-section-content);
}
&--dashboard {
@include build-item($color-section-dashboard);
}
&--schemas {
@include build-item($color-section-schemas);
}
&--settings {
@include build-item($color-section-settings);
}
}
&-item-group > a {
& {
position: relative;
}
&::after {
@include absolute(50%, 30px, auto, auto);
@include caret-bottom;
}
} }
&-link { &-link {
@ -35,16 +71,8 @@ $color-border-left-width: 4px;
font-size: 1rem; font-size: 1rem;
font-weight: 450; font-weight: 450;
line-height: 3rem; line-height: 3rem;
padding-left: $padding-layout - $color-border-left-width; padding-left: $padding-layout-h - $color-border-left-width;
padding-right: $padding-layout; padding-right: $padding-layout-h;
}
&.active {
font-weight: bold;
}
&:hover {
text-decoration: none;
} }
&:hover, &:hover,
@ -52,24 +80,42 @@ $color-border-left-width: 4px;
background: $color-selection-background; background: $color-selection-background;
} }
&--media { &.active {
@include build-item($color-section-media); font-weight: bold;
} }
&--content { &:hover {
@include build-item($color-section-content); text-decoration: none;
} }
}
}
&--dashboard { .subnav {
@include build-item($color-section-dashboard); & {
} list-style: none;
margin: 0;
padding: 0;
overflow: hidden;
}
&--schemas { .nav-link {
@include build-item($color-section-schemas); & {
color: $color-subnav-text;
padding-left: 55px;
font-size: .9rem;
font-weight: 450;
line-height: 2rem;
} }
}
&--settings { &-item {
@include build-item($color-section-settings); &::before {
content: '';
color: $color-subnav-dot;
float: left;
font-weight: bold;
line-height: 2rem;
margin-left: 35px;
} }
} }
} }

39
src/Squidex/app/components/internal/app/left-menu.component.ts

@ -7,9 +7,46 @@
import * as Ng2 from '@angular/core'; import * as Ng2 from '@angular/core';
import { AppsStoreService, LocalStoreService } from 'shared';
@Ng2.Component({ @Ng2.Component({
selector: 'sqx-left-menu', selector: 'sqx-left-menu',
styles, styles,
template template
}) })
export class LeftMenuComponent { } export class LeftMenuComponent implements Ng2.OnInit, Ng2.OnDestroy {
private appSubscription: any | null = null;
public get showSettingsMenu(): boolean {
return this.localStore.get('squidex:showSettingsMenu') === 'true';
}
public set showSettingsMenu(value: boolean) {
this.localStore.set('squidex:showSettingsMenu', value);
}
public permission: string | null = null;
constructor(
private readonly localStore: LocalStoreService,
private readonly appsStore: AppsStoreService
) {
}
public ngOnInit() {
this.appSubscription =
this.appsStore.selectedApp.subscribe(app => {
if (app) {
this.permission = app.permission;
}
});
}
public ngOnDestroy() {
this.appSubscription.unsubscribe();
}
public toggleSettingsMenu() {
this.showSettingsMenu = !this.showSettingsMenu;
}
}

20
src/Squidex/app/components/internal/app/schemas/schemas-page.component.html

@ -1,12 +1,16 @@
<div class="layout-content"> <div class="layout">
<div class="layout-content-left"> <div class="layout-left">
<button class="layout-new-button btn btn-success">Create Schema</button>
<sqx-left-menu></sqx-left-menu> <sqx-left-menu></sqx-left-menu>
</div> </div>
<div class="layout-content-main"> <div class="layout-middle">
<h1> <div class="layout-middle-header">
<i class="layout-title-icon icon-schemas"></i> Schemas <button class="layout-new-button btn btn-success pull-right">Create Schema</button>
</h1>
<h1>
<i class="layout-title-icon icon-schemas"></i> Schemas
</h1>
</div>
<div class="layout-middle-content">
</div>
</div> </div>
</div> </div>

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

@ -0,0 +1,67 @@
<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> Contributors
</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: 150px" />
<col style="width: 110px" />
</colgroup>
<tr *ngFor="let contributor of appContributors">
<td class="col-xs-7">
<img class="user-picture" [attr.src]="pictureUrl(contributor) | async" />
<span class="user-name">
{{displayName(contributor) | async}}
</span>
<span class="user-email">
{{email(contributor) | async}}
</span>
</td>
<td>
<select class="form-control" [(ngModel)]="contributor.permission" (ngModelChange)="saveContributor(contributor)" [disabled]="currrentUserId === contributor.contributorId">
<option *ngFor="let permission of usersPermissions">{{permission}}</option>
</select>
</td>
<td>
<button class="btn btn-block btn-danger" [disabled]="currrentUserId === contributor.contributorId" (click)="revokeContributor(contributor)">Revoke</button>
</td>
</tr>
</table>
</div>
<div class="card-footer">
<form class="form-inline" (submit)="addContributor()" >
<div class="form-group">
<ng2-completer
[autoMatch]="true"
[dataService]="usersDataSource"
[minSearchLength]="3"
[placeholder]="'Search user by e-email'"
[pause]="300"
[clearSelected]="false"
[textSearching]="'Searching...'"
[inputName]="contributor"
[ngModel]="selectedUserName"
[ngModelOptions]="{standalone: true}"
(selected)="selectUser($event)">
</ng2-completer>
</div>
<button type="submit" class="btn btn-primary" [disabled]="!selectedUser">Add</button>
</form>
</div>
</div>
</div>
</div>
</div>

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

@ -0,0 +1,28 @@
@import '_vars';
@import '_mixins';
.layout-title-icon {
color: $color-section-settings;
}
.card {
max-width: 700px;
}
.user {
&-picture {
@include circle(2.2rem);
float: left;
}
&-name,
&-email {
@include truncate;
padding-left: 10px;
}
&-email {
font-size: .8rem;
font-style: italic;
}
}

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

@ -0,0 +1,162 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Sebastian Stehle. All rights reserved
*/
import * as Ng2 from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import { CompleterBaseData, CompleterItem } from 'ng2-completer';
import {
AppContributorDto,
AppContributorsService,
AppsStoreService,
AuthService,
TitleService,
UserDto,
UsersService,
UsersProviderService
} from 'shared';
class UsersDataSource extends CompleterBaseData {
private remoteSearch: Subscription;
constructor(
private readonly usersService: UsersService,
private readonly component: ContributorsPageComponent,
) {
super();
}
public search(term: string): void {
this.cancel();
this.remoteSearch =
this.usersService.getUsers(term)
.map(users => {
const results: CompleterItem[] = [];
for (let u of users) {
if (!this.component.appContributors || !this.component.appContributors.find(t => t.contributorId === u.id)) {
results.push({ title: u.displayName, image: u.pictureUrl, originalObject: u, description: u.email });
}
}
this.next(results);
return results;
})
.catch(err => {
this.error(err);
return null;
}).subscribe();
}
public cancel() {
if (this.remoteSearch) {
this.remoteSearch.unsubscribe();
}
}
}
@Ng2.Component({
selector: 'sqx-contributor-page',
styles,
template
})
export class ContributorsPageComponent implements Ng2.OnInit {
private appSubscription: any | null = null;
private appName: string;
public appContributors: AppContributorDto[];
public selectedUserName: string | null = null;
public selectedUser: UserDto | null = null;
public currrentUserId: string;
public usersDataSource: UsersDataSource;
public usersPermissions = [
'Owner',
'Developer',
'Editor'
];
constructor(
private readonly titles: TitleService,
private readonly authService: AuthService,
private readonly appsStore: AppsStoreService,
private readonly appContributorsService: AppContributorsService,
private readonly usersProvider: UsersProviderService,
private readonly usersService: UsersService
) {
this.usersDataSource = new UsersDataSource(usersService, this);
}
public ngOnInit() {
this.currrentUserId = this.authService.user.id;
this.appSubscription =
this.appsStore.selectedApp.subscribe(app => {
if (app) {
this.appName = app.name;
this.titles.setTitle('{appName} | Settings | Contributors', { appName: app.name });
this.appContributorsService.getContributors(app.name).subscribe(contributors => {
this.appContributors = contributors;
});
}
});
}
public ngOnDestroy() {
this.appSubscription.unsubscribe();
}
public addContributor() {
if (!this.selectedUser) {
return;
}
const contributor = new AppContributorDto(this.selectedUser.id, 'Editor');
this.appContributorsService.postContributor(this.appName, contributor).subscribe();
this.appContributors.push(contributor);
this.selectedUser = null;
this.selectedUserName = null;
}
public revokeContributor(contributor: AppContributorDto) {
this.appContributorsService.deleteContributor(this.appName, contributor.contributorId).subscribe();
this.appContributors.splice(this.appContributors.indexOf(contributor), 1);
}
public saveContributor(contributor: AppContributorDto) {
this.appContributorsService.postContributor(this.appName, contributor).subscribe();
}
public selectUser(selection: CompleterItem | null) {
this.selectedUser = selection ? selection.originalObject : null;
}
public email(contributor: AppContributorDto): Observable<string> {
return this.usersProvider.getUser(contributor.contributorId).map(u => u.email);
}
public displayName(contributor: AppContributorDto): Observable<string> {
return this.usersProvider.getUser(contributor.contributorId).map(u => u.displayName);
}
public pictureUrl(contributor: AppContributorDto): Observable<string> {
return this.usersProvider.getUser(contributor.contributorId).map(u => u.pictureUrl);
}
}

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

@ -0,0 +1,14 @@
<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>
</div>
</div>

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

@ -0,0 +1,6 @@
@import '_vars';
@import '_mixins';
.layout-title-icon {
color: $color-section-settings;
}

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

@ -0,0 +1,39 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Sebastian Stehle. All rights reserved
*/
import * as Ng2 from '@angular/core';
import { AppsStoreService, TitleService } from 'shared';
@Ng2.Component({
selector: 'sqx-credentials-page',
styles,
template
})
export class CredentialsPageComponent implements Ng2.OnInit {
private appSubscription: any | null = null;
constructor(
private readonly titles: TitleService,
private readonly appsStore: AppsStoreService
) {
}
public ngOnInit() {
this.appSubscription =
this.appsStore.selectedApp.subscribe(app => {
if (app) {
this.titles.setTitle('{appName} | Settings | Credentials', { appName: app.name });
}
});
}
public ngOnDestroy() {
this.appSubscription.unsubscribe();
}
}

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

@ -0,0 +1,14 @@
<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> Languages
</h1>
</div>
<div class="layout-middle-content">
</div>
</div>
</div>

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

@ -0,0 +1,6 @@
@import '_vars';
@import '_mixins';
.layout-title-icon {
color: $color-section-settings;
}

39
src/Squidex/app/components/internal/app/settings/languages-page.component.ts

@ -0,0 +1,39 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Sebastian Stehle. All rights reserved
*/
import * as Ng2 from '@angular/core';
import { AppsStoreService, TitleService } from 'shared';
@Ng2.Component({
selector: 'sqx-languages-page',
styles,
template
})
export class LanguagesPageComponent implements Ng2.OnInit {
private appSubscription: any | null = null;
constructor(
private readonly titles: TitleService,
private readonly appsStore: AppsStoreService
) {
}
public ngOnInit() {
this.appSubscription =
this.appsStore.selectedApp.subscribe(app => {
if (app) {
this.titles.setTitle('{appName} | Settings | Languages', { appName: app.name });
}
});
}
public ngOnDestroy() {
this.appSubscription.unsubscribe();
}
}

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

@ -10,5 +10,8 @@ export * from './app/app-area.component';
export * from './app/left-menu.component'; export * from './app/left-menu.component';
export * from './app/dashboard/dashboard-page.component'; export * from './app/dashboard/dashboard-page.component';
export * from './app/schemas/schemas-page.component'; export * from './app/schemas/schemas-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'; export * from './internal-area.component';

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

@ -7,20 +7,26 @@
import * as Ng2 from '@angular/core'; import * as Ng2 from '@angular/core';
import { Ng2CompleterModule } from 'ng2-completer';
import { SqxFrameworkModule } from 'shared'; import { SqxFrameworkModule } from 'shared';
import { SqxLayoutModule } from 'components/layout'; import { SqxLayoutModule } from 'components/layout';
import { import {
AppAreaComponent, AppAreaComponent,
AppsPageComponent, AppsPageComponent,
ContributorsPageComponent,
CredentialsPageComponent,
DashboardPageComponent, DashboardPageComponent,
InternalAreaComponent, InternalAreaComponent,
LeftMenuComponent, LeftMenuComponent,
LanguagesPageComponent,
SchemasPageComponent SchemasPageComponent
} from './declarations'; } from './declarations';
@Ng2.NgModule({ @Ng2.NgModule({
imports: [ imports: [
Ng2CompleterModule,
SqxFrameworkModule, SqxFrameworkModule,
SqxLayoutModule SqxLayoutModule
], ],
@ -30,8 +36,11 @@ import {
declarations: [ declarations: [
AppAreaComponent, AppAreaComponent,
AppsPageComponent, AppsPageComponent,
ContributorsPageComponent,
CredentialsPageComponent,
DashboardPageComponent, DashboardPageComponent,
InternalAreaComponent, InternalAreaComponent,
LanguagesPageComponent,
LeftMenuComponent, LeftMenuComponent,
SchemasPageComponent SchemasPageComponent
] ]

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

@ -6,26 +6,26 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="app-name">DisplayName</label> <label for="app-name">Name</label>
<div class="errors-box" *ngIf="createForm.get('name').invalid && createForm.get('name').dirty" [@fade]> <div class="errors-box" *ngIf="createForm.get('name').invalid && createForm.get('name').dirty" [@fade]>
<div class="errors"> <div class="errors">
<span *ngIf="createForm.get('name').hasError('required')"> <span *ngIf="createForm.get('name').hasError('required')">
DisplayName is required. Name is required.
</span> </span>
<span *ngIf="createForm.get('name').hasError('maxlength')"> <span *ngIf="createForm.get('name').hasError('maxlength')">
DisplayName can not have more than 40 characters. Name can not have more than 40 characters.
</span> </span>
<span *ngIf="createForm.get('name').hasError('pattern')"> <span *ngIf="createForm.get('name').hasError('pattern')">
DisplayName can contain lower case letters (a-z), numbers and dashes only. Name can contain lower case letters (a-z), numbers and dashes only.
</span> </span>
</div> </div>
</div> </div>
<input type="text" class="form-control" id="app-name" formControlDisplayName="name" /> <input type="text" class="form-control" id="app-name" formControlName="name" />
<span class="form-hint"> <span class="form-hint">
The app name becomes part of the api url, e.g, https://<b>{{appDisplayName}}</b>.squidex.io/.<br /> 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. 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> </span>
</div> </div>

2
src/Squidex/app/components/layout/apps-menu.component.html

@ -1,6 +1,6 @@
<ul class="nav navbar-nav" *ngIf="apps"> <ul class="nav navbar-nav" *ngIf="apps">
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<span class="nav-link dropdown-toggle" id="app-name" (click)="modalMenu.toggle()">{{appDisplayName}}</span> <span class="nav-link dropdown-toggle" id="app-name" (click)="modalMenu.toggle()">{{appName}}</span>
<div class="dropdown-menu" *sqxModalView="modalMenu" closeAlways="true" [@fade]> <div class="dropdown-menu" *sqxModalView="modalMenu" closeAlways="true" [@fade]>
<a class="dropdown-item all-apps" [routerLink]="['/app']"> <a class="dropdown-item all-apps" [routerLink]="['/app']">

9
src/Squidex/app/components/layout/apps-menu.component.scss

@ -23,13 +23,10 @@
} }
&::before { &::before {
@include absolute(-18px, auto, auto, 10px); @include absolute(-10px, auto, auto, 10px);
@include caret-top;
border-color: transparent transparent $color-accent-dark; border-color: transparent transparent $color-accent-dark;
border-style: solid;
border-width: 10px; border-width: 10px;
content: '';
height: 0;
width: 0;
} }
} }
@ -56,8 +53,8 @@
@include opacity(.95); @include opacity(.95);
@include no-selection; @include no-selection;
padding-right: 15px; padding-right: 15px;
cursor: pointer;
color: $color-accent-dark; color: $color-accent-dark;
cursor: pointer;
width: 200px; width: 200px;
} }

6
src/Squidex/app/components/layout/profile-menu.component.html

@ -1,9 +1,11 @@
<ul class="nav navbar-nav"> <ul class="nav navbar-nav">
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<span class="nav-link dropdown-toggle" (click)="modalMenu.toggle()"> <span class="nav-link dropdown-toggle" (click)="modalMenu.toggle()">
<img [attr.src]="profilePictureUrl" /> <span class="user">
<img class="user-picture" [attr.src]="profilePictureUrl" />
<span>{{profileDisplayName}}</span> <span class="user-name">{{profileDisplayName}}</span>
</span>
</span> </span>
<div class="dropdown-menu" *sqxModalView="modalMenu" closeAlways="true" [@fade]> <div class="dropdown-menu" *sqxModalView="modalMenu" closeAlways="true" [@fade]>

43
src/Squidex/app/components/layout/profile-menu.component.scss

@ -3,14 +3,10 @@
$size-avatar: 2.2rem; $size-avatar: 2.2rem;
a { .user {
cursor: pointer; &-picture {
} @include circle(2.2rem);
}
img {
@include border-radius($size-avatar * .5);
height: $size-avatar;
width: $size-avatar;
} }
.navbar-nav { .navbar-nav {
@ -23,18 +19,27 @@ img {
} }
} }
.dropdown-menu { .nav-link {
& { &::after {
@include absolute(44px, 0, auto, auto); color: $color-theme-blue-light;
}
}
.dropdown {
&-item {
cursor: pointer;
} }
&::before { &-menu {
@include absolute(-18px, 10px, auto, auto); & {
border-color: transparent transparent $color-accent-dark; @include absolute(44px, 0, auto, auto);
border-style: solid; }
border-width: 10px;
content: ''; &::before {
height: 0; @include absolute(-10px, 10px, auto, auto);
width: 0; @include caret-top;
border-color: transparent transparent $color-accent-dark;
border-width: 10px;
}
} }
} }

25
src/Squidex/app/framework/angular/animations.ts

@ -15,7 +15,7 @@ export const fadeAnimation = (name = 'fade', timing = '200ms'): Ng2.AnimationEnt
Ng2.animate(timing, Ng2.style({ opacity: 1 })) Ng2.animate(timing, Ng2.style({ opacity: 1 }))
]), ]),
Ng2.transition(':leave', [ Ng2.transition(':leave', [
Ng2.style({ 'opacity': 1 }), Ng2.style({ opacity: 1 }),
Ng2.animate(timing, Ng2.style({ opacity: 0 })) Ng2.animate(timing, Ng2.style({ opacity: 0 }))
]), ]),
Ng2.state('true', Ng2.state('true',
@ -29,4 +29,27 @@ export const fadeAnimation = (name = 'fade', timing = '200ms'): Ng2.AnimationEnt
] ]
); );
}; };
export const heightAnimation = (name = 'height', timing = '200ms'): Ng2.AnimationEntryMetadata => {
return Ng2.trigger(
name, [
Ng2.transition(':enter', [
Ng2.style({ height: '0px' }),
Ng2.animate(timing, Ng2.style({ height: '*' }))
]),
Ng2.transition(':leave', [
Ng2.style({ height: '*' }),
Ng2.animate(timing, Ng2.style({ height: '0px' }))
]),
Ng2.state('true',
Ng2.style({ height: '*' })
),
Ng2.state('false',
Ng2.style({ height: '0px' })
),
Ng2.transition('1 => 0', Ng2.animate(timing)),
Ng2.transition('0 => 1', Ng2.animate(timing))
]
);
};

13
src/Squidex/app/framework/services/local-store.service.ts

@ -13,6 +13,7 @@ export const LocalStoreServiceFactory = () => {
@Ng2.Injectable() @Ng2.Injectable()
export class LocalStoreService { export class LocalStoreService {
private readonly fallback = {};
private store: any = localStorage; private store: any = localStorage;
public configureStore(store: any) { public configureStore(store: any) {
@ -20,10 +21,18 @@ export class LocalStoreService {
} }
public get(key: string): any { public get(key: string): any {
return this.store.getItem(key); try {
return this.store.getItem(key);
} catch (e) {
return this.fallback[key];
}
} }
public set(key: string, value: any) { public set(key: string, value: any) {
this.store.setItem(key, value); try {
this.store.setItem(key, value);
} catch (e) {
this.fallback[key] = value;
}
} }
} }

16
src/Squidex/app/shared/services/app-contributors.service.spec.ts

@ -12,7 +12,7 @@ import { Observable } from 'rxjs';
import { import {
ApiUrlConfig, ApiUrlConfig,
AppContributor, AppContributorDto,
AppContributorsService, AppContributorsService,
AuthService AuthService
} from './../'; } from './../';
@ -43,7 +43,7 @@ describe('AppContributorsService', () => {
)) ))
.verifiable(TypeMoq.Times.once()); .verifiable(TypeMoq.Times.once());
let contributors: AppContributor[] = null; let contributors: AppContributorDto[] = null;
appContributorsService.getContributors('my-app').subscribe(result => { appContributorsService.getContributors('my-app').subscribe(result => {
contributors = result; contributors = result;
@ -51,22 +51,23 @@ describe('AppContributorsService', () => {
expect(contributors).toEqual( expect(contributors).toEqual(
[ [
new AppContributor('123', 'Owner'), new AppContributorDto('123', 'Owner'),
new AppContributor('456', 'Editor'), new AppContributorDto('456', 'Editor'),
]); ]);
authService.verifyAll(); authService.verifyAll();
}); });
it('should make post request to assign contributor', () => { it('should make post request to assign contributor', () => {
const contributor = new AppContributor('123', 'Owner'); const contributor = new AppContributorDto('123', 'Owner');
authService.setup(x => x.authPost('http://service/p/api/apps/my-app/contributors', TypeMoq.It.is(c => c === contributor))) authService.setup(x => x.authPost('http://service/p/api/apps/my-app/contributors', TypeMoq.It.is(c => c === contributor)))
.returns(() => Observable.of( .returns(() => Observable.of(
new Ng2Http.Response( new Ng2Http.Response(
new Ng2Http.ResponseOptions() new Ng2Http.ResponseOptions()
) )
)); ))
.verifiable(TypeMoq.Times.once());
appContributorsService.postContributor('my-app', contributor); appContributorsService.postContributor('my-app', contributor);
@ -79,7 +80,8 @@ describe('AppContributorsService', () => {
new Ng2Http.Response( new Ng2Http.Response(
new Ng2Http.ResponseOptions() new Ng2Http.ResponseOptions()
) )
)); ))
.verifiable(TypeMoq.Times.once());
appContributorsService.deleteContributor('my-app', '123'); appContributorsService.deleteContributor('my-app', '123');

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

@ -13,7 +13,7 @@ import { ApiUrlConfig } from 'framework';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
export class AppContributor { export class AppContributorDto {
constructor( constructor(
public readonly contributorId: string, public readonly contributorId: string,
public readonly permission: string public readonly permission: string
@ -29,24 +29,24 @@ export class AppContributorsService {
) { ) {
} }
public getContributors(appName: string): Observable<AppContributor[]> { public getContributors(appName: string): Observable<AppContributorDto[]> {
return this.authService.authGet(this.apiUrl.buildUrl(`api/apps/${appName}/contributors`)) return this.authService.authGet(this.apiUrl.buildUrl(`api/apps/${appName}/contributors`))
.map(response => { .map(response => {
const body: any[] = response.json(); const body: any[] = response.json();
return body.map(item => { return body.map(item => {
return new AppContributor( return new AppContributorDto(
item.contributorId, item.contributorId,
item.permission); item.permission);
}); });
}); });
} }
public postContributor(appName: string, contributor: AppContributor): Observable<any> { public postContributor(appName: string, contributor: AppContributorDto): Observable<any> {
return this.authService.authPost(this.apiUrl.buildUrl(`api/apps/${appName}/contributors`), contributor); return this.authService.authPost(this.apiUrl.buildUrl(`api/apps/${appName}/contributors`), contributor);
} }
public deleteContributor(appName: string, contributorId: string): Observable<any> { public deleteContributor(appName: string, contributorId: string): Observable<any> {
return this.authService.authDelete(this.apiUrl.buildUrl(`api/apps/${appName}/contributors/{contributorId}`)); return this.authService.authDelete(this.apiUrl.buildUrl(`api/apps/${appName}/contributors/${contributorId}`));
} }
} }

6
src/Squidex/app/shared/services/apps-store.service.spec.ts

@ -18,8 +18,8 @@ import {
} from './../'; } from './../';
describe('AppsStoreService', () => { describe('AppsStoreService', () => {
const oldApps = [new AppDto('id', 'name', null, null)]; const oldApps = [new AppDto('id', 'old-name', null, null, 'Owner')];
const newApp = new AppDto('id', 'new-name', null, null); const newApp = new AppDto('id', 'new-name', null, null, 'Owner');
let appsService: TypeMoq.Mock<AppsService>; let appsService: TypeMoq.Mock<AppsService>;
let authService: TypeMoq.Mock<AuthService>; let authService: TypeMoq.Mock<AuthService>;
@ -156,7 +156,7 @@ describe('AppsStoreService', () => {
const store = new AppsStoreService(authService.object, appsService.object); const store = new AppsStoreService(authService.object, appsService.object);
store.selectApp('name').then((isSelected) => { store.selectApp('old-name').then((isSelected) => {
expect(isSelected).toBeTruthy(); expect(isSelected).toBeTruthy();
appsService.verifyAll(); appsService.verifyAll();

12
src/Squidex/app/shared/services/apps.service.spec.ts

@ -37,12 +37,14 @@ describe('AppsService', () => {
id: '123', id: '123',
name: 'name1', name: 'name1',
created: '2016-01-01', created: '2016-01-01',
lastModified: '2016-02-02' lastModified: '2016-02-02',
permission: 'Owner'
}, { }, {
id: '456', id: '456',
name: 'name2', name: 'name2',
created: '2017-01-01', created: '2017-01-01',
lastModified: '2017-02-02' lastModified: '2017-02-02',
permission: 'Editor'
}] }]
}) })
) )
@ -56,8 +58,8 @@ describe('AppsService', () => {
}).unsubscribe(); }).unsubscribe();
expect(apps).toEqual([ expect(apps).toEqual([
new AppDto('123', 'name1', DateTime.parseISO('2016-01-01'), DateTime.parseISO('2016-02-02')), new AppDto('123', 'name1', DateTime.parseISO('2016-01-01'), DateTime.parseISO('2016-02-02'), 'Owner'),
new AppDto('456', 'name2', DateTime.parseISO('2017-01-01'), DateTime.parseISO('2017-02-02')), new AppDto('456', 'name2', DateTime.parseISO('2017-01-01'), DateTime.parseISO('2017-02-02'), 'Editor'),
]); ]);
authService.verifyAll(); authService.verifyAll();
@ -83,7 +85,7 @@ describe('AppsService', () => {
newApp = result; newApp = result;
}).unsubscribe(); }).unsubscribe();
expect(newApp).toEqual(new AppDto('123', 'new-app', now, now)); expect(newApp).toEqual(new AppDto('123', 'new-app', now, now, 'Owner'));
authService.verifyAll(); authService.verifyAll();
}); });

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

@ -17,7 +17,8 @@ export class AppDto {
public readonly id: string, public readonly id: string,
public readonly name: string, public readonly name: string,
public readonly created: DateTime, public readonly created: DateTime,
public readonly lastModified: DateTime public readonly lastModified: DateTime,
public readonly permission: string
) { ) {
} }
} }
@ -47,7 +48,8 @@ export class AppsService {
item.id, item.id,
item.name, item.name,
DateTime.parseISO(item.created), DateTime.parseISO(item.created),
DateTime.parseISO(item.lastModified) DateTime.parseISO(item.lastModified),
item.permission
); );
}); });
}); });
@ -58,7 +60,7 @@ export class AppsService {
return this.authService.authPost(this.apiUrl.buildUrl('api/apps'), appToCreate) return this.authService.authPost(this.apiUrl.buildUrl('api/apps'), appToCreate)
.map(response => response.json()) .map(response => response.json())
.map(response => new AppDto(response.id, appToCreate.name, now, now)) .map(response => new AppDto(response.id, appToCreate.name, now, now, 'Owner'))
.catch(response => { .catch(response => {
if (response.status === 400) { if (response.status === 400) {
return Observable.throw('An app with the same name already exists.'); return Observable.throw('An app with the same name already exists.');

8
src/Squidex/app/shared/services/languages.service.spec.ts

@ -13,7 +13,7 @@ import { Observable } from 'rxjs';
import { import {
ApiUrlConfig, ApiUrlConfig,
AuthService, AuthService,
Language, LanguageDto,
LanguageService LanguageService
} from './../'; } from './../';
@ -43,7 +43,7 @@ describe('LanguageService', () => {
)) ))
.verifiable(TypeMoq.Times.once()); .verifiable(TypeMoq.Times.once());
let languages: Language[] = null; let languages: LanguageDto[] = null;
languageService.getLanguages().subscribe(result => { languageService.getLanguages().subscribe(result => {
languages = result; languages = result;
@ -51,8 +51,8 @@ describe('LanguageService', () => {
expect(languages).toEqual( expect(languages).toEqual(
[ [
new Language('de', 'German'), new LanguageDto('de', 'German'),
new Language('en', 'English'), new LanguageDto('en', 'English'),
]); ]);
authService.verifyAll(); authService.verifyAll();

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

@ -12,7 +12,7 @@ import { ApiUrlConfig } from 'framework';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
export class Language { export class LanguageDto {
constructor( constructor(
public readonly iso2Code: string, public readonly iso2Code: string,
public readonly englishName: string public readonly englishName: string
@ -28,13 +28,13 @@ export class LanguageService {
) { ) {
} }
public getLanguages(): Observable<Language[]> { public getLanguages(): Observable<LanguageDto[]> {
return this.authService.authGet(this.apiUrl.buildUrl('api/languages')) return this.authService.authGet(this.apiUrl.buildUrl('api/languages'))
.map(response => { .map(response => {
const body: any[] = response.json(); const body: any[] = response.json();
return body.map(item => { return body.map(item => {
return new Language( return new LanguageDto(
item.iso2Code, item.iso2Code,
item.englishName); item.englishName);
}); });

16
src/Squidex/app/shared/services/users-provider.service.spec.ts

@ -12,7 +12,7 @@ import { Observable } from 'rxjs';
import { import {
AuthService, AuthService,
Profile, Profile,
User, UserDto,
UsersProviderService, UsersProviderService,
UsersService, UsersService,
} from './../'; } from './../';
@ -29,13 +29,13 @@ describe('UsersProviderService', () => {
}); });
it('Should return users service when user not cached', () => { it('Should return users service when user not cached', () => {
const user = new User('123', 'path/to/image', 'mail@domain.com'); const user = new UserDto('123', 'mail@domain.com', 'User1', 'path/to/image', );
usersService.setup(x => x.getUser('123')) usersService.setup(x => x.getUser('123'))
.returns(() => Observable.of(user)) .returns(() => Observable.of(user))
.verifiable(TypeMoq.Times.once()); .verifiable(TypeMoq.Times.once());
let resultingUser: User = null; let resultingUser: UserDto = null;
usersProviderService.getUser('123').subscribe(result => { usersProviderService.getUser('123').subscribe(result => {
resultingUser = result; resultingUser = result;
@ -47,7 +47,7 @@ describe('UsersProviderService', () => {
}); });
it('Should return provide user from cache', () => { it('Should return provide user from cache', () => {
const user = new User('123', 'path/to/image', 'mail@domain.com'); const user = new UserDto('123', 'mail@domain.com', 'User1', 'path/to/image', );
usersService.setup(x => x.getUser('123')) usersService.setup(x => x.getUser('123'))
.returns(() => Observable.of(user)) .returns(() => Observable.of(user))
@ -55,7 +55,7 @@ describe('UsersProviderService', () => {
usersProviderService.getUser('123'); usersProviderService.getUser('123');
let resultingUser: User = null; let resultingUser: UserDto = null;
usersProviderService.getUser('123').subscribe(result => { usersProviderService.getUser('123').subscribe(result => {
resultingUser = result; resultingUser = result;
@ -67,7 +67,7 @@ describe('UsersProviderService', () => {
}); });
it('Should return Me when user is current user', () => { it('Should return Me when user is current user', () => {
const user = new User('123', 'path/to/image', 'mail@domain.com'); const user = new UserDto('123', 'mail@domain.com', 'User1', 'path/to/image', );
authService.setup(x => x.user) authService.setup(x => x.user)
.returns(() => new Profile(<any>{ profile: { sub: '123'}})); .returns(() => new Profile(<any>{ profile: { sub: '123'}}));
@ -76,13 +76,13 @@ describe('UsersProviderService', () => {
.returns(() => Observable.of(user)) .returns(() => Observable.of(user))
.verifiable(TypeMoq.Times.once()); .verifiable(TypeMoq.Times.once());
let resultingUser: User = null; let resultingUser: UserDto = null;
usersProviderService.getUser('123').subscribe(result => { usersProviderService.getUser('123').subscribe(result => {
resultingUser = result; resultingUser = result;
}).unsubscribe(); }).unsubscribe();
expect(resultingUser).toEqual(new User('123', 'path/to/image', 'Me')); expect(resultingUser).toEqual(new UserDto('123', 'mail@domain.com', 'Me', 'path/to/image', ));
usersService.verifyAll(); usersService.verifyAll();
}); });

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

@ -9,13 +9,13 @@ import * as Ng2 from '@angular/core';
import { Observable, } from 'rxjs'; import { Observable, } from 'rxjs';
import { User, UsersService } from './users.service'; import { UserDto, UsersService } from './users.service';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@Ng2.Injectable() @Ng2.Injectable()
export class UsersProviderService { export class UsersProviderService {
private readonly caches: { [id: string]: Observable<User> } = {}; private readonly caches: { [id: string]: Observable<UserDto> } = {};
constructor( constructor(
private readonly usersService: UsersService, private readonly usersService: UsersService,
@ -23,20 +23,24 @@ export class UsersProviderService {
) { ) {
} }
public getUser(id: string): Observable<User> { public getUser(id: string): Observable<UserDto> {
let result = this.caches[id]; let result = this.caches[id];
if (!result) { if (!result) {
result = this.caches[id] = const request =
this.usersService.getUser(id) this.usersService.getUser(id)
.map(u => { .map(u => {
if (this.authService.user && u.id === this.authService.user.id) { if (this.authService.user && u.id === this.authService.user.id) {
return new User(u.id, u.profileUrl, 'Me'); return new UserDto(u.id, u.email, 'Me', u.pictureUrl);
} else { } else {
return u; return u;
} }
}) })
.publishLast().refCount(); .publishLast();
request.connect();
result = this.caches[id] = request;
} }
return result; return result;

43
src/Squidex/app/shared/services/users.service.spec.ts

@ -13,7 +13,7 @@ import { Observable } from 'rxjs';
import { import {
ApiUrlConfig, ApiUrlConfig,
AuthService, AuthService,
User, UserDto,
UsersService, UsersService,
} from './../'; } from './../';
@ -33,19 +33,21 @@ describe('UsersService', () => {
new Ng2Http.ResponseOptions({ new Ng2Http.ResponseOptions({
body: [{ body: [{
id: '123', id: '123',
profileUrl: 'path/to/image1', email: 'mail1@domain.com',
displayName: 'mail1@domain.com' displayName: 'User1',
pictureUrl: 'path/to/image1'
}, { }, {
id: '456', id: '456',
profileUrl: 'path/to/image2', email: 'mail2@domain.com',
displayName: 'mail2@domain.com' displayName: 'User2',
pictureUrl: 'path/to/image2'
}] }]
}) })
) )
)) ))
.verifiable(TypeMoq.Times.once()); .verifiable(TypeMoq.Times.once());
let user: User[] = null; let user: UserDto[] = null;
usersService.getUsers().subscribe(result => { usersService.getUsers().subscribe(result => {
user = result; user = result;
@ -53,8 +55,8 @@ describe('UsersService', () => {
expect(user).toEqual( expect(user).toEqual(
[ [
new User('123', 'path/to/image1', 'mail1@domain.com'), new UserDto('123', 'mail1@domain.com', 'User1', 'path/to/image1'),
new User('456', 'path/to/image2', 'mail2@domain.com') new UserDto('456', 'mail2@domain.com', 'User2', 'path/to/image2')
]); ]);
authService.verifyAll(); authService.verifyAll();
@ -67,19 +69,21 @@ describe('UsersService', () => {
new Ng2Http.ResponseOptions({ new Ng2Http.ResponseOptions({
body: [{ body: [{
id: '123', id: '123',
profileUrl: 'path/to/image1', email: 'mail1@domain.com',
displayName: 'mail1@domain.com' displayName: 'User1',
pictureUrl: 'path/to/image1'
}, { }, {
id: '456', id: '456',
profileUrl: 'path/to/image2', email: 'mail2@domain.com',
displayName: 'mail2@domain.com' displayName: 'User2',
pictureUrl: 'path/to/image2'
}] }]
}) })
) )
)) ))
.verifiable(TypeMoq.Times.once()); .verifiable(TypeMoq.Times.once());
let user: User[] = null; let user: UserDto[] = null;
usersService.getUsers('my-query').subscribe(result => { usersService.getUsers('my-query').subscribe(result => {
user = result; user = result;
@ -87,8 +91,8 @@ describe('UsersService', () => {
expect(user).toEqual( expect(user).toEqual(
[ [
new User('123', 'path/to/image1', 'mail1@domain.com'), new UserDto('123', 'mail1@domain.com', 'User1', 'path/to/image1'),
new User('456', 'path/to/image2', 'mail2@domain.com') new UserDto('456', 'mail2@domain.com', 'User2', 'path/to/image2')
]); ]);
authService.verifyAll(); authService.verifyAll();
@ -101,21 +105,22 @@ describe('UsersService', () => {
new Ng2Http.ResponseOptions({ new Ng2Http.ResponseOptions({
body: { body: {
id: '123', id: '123',
profileUrl: 'path/to/image', email: 'mail1@domain.com',
displayName: 'mail@domain.com' displayName: 'User1',
pictureUrl: 'path/to/image1'
} }
}) })
) )
)) ))
.verifiable(TypeMoq.Times.once()); .verifiable(TypeMoq.Times.once());
let user: User = null; let user: UserDto = null;
usersService.getUser('123').subscribe(result => { usersService.getUser('123').subscribe(result => {
user = result; user = result;
}).unsubscribe(); }).unsubscribe();
expect(user).toEqual(new User('123', 'path/to/image', 'mail@domain.com')); expect(user).toEqual(new UserDto('123', 'mail1@domain.com', 'User1', 'path/to/image1'));
authService.verifyAll(); authService.verifyAll();
}); });

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

@ -13,11 +13,12 @@ import { ApiUrlConfig } from 'framework';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
export class User { export class UserDto {
constructor( constructor(
public readonly id: string, public readonly id: string,
public readonly profileUrl: string, public readonly email: string,
public readonly displayName: string public readonly displayName: string,
public readonly pictureUrl: string
) { ) {
} }
} }
@ -30,29 +31,31 @@ export class UsersService {
) { ) {
} }
public getUsers(query?: string): Observable<User[]> { public getUsers(query?: string): Observable<UserDto[]> {
return this.authService.authGet(this.apiUrl.buildUrl(`api/users/?query=${query || ''}`)) return this.authService.authGet(this.apiUrl.buildUrl(`api/users/?query=${query || ''}`))
.map(response => { .map(response => {
const body: any[] = response.json() || []; const body: any[] = response.json() || [];
return body.map(item => { return body.map(item => {
return new User( return new UserDto(
item.id, item.id,
item.profileUrl, item.email,
item.displayName); item.displayName,
item.pictureUrl);
}); });
}); });
} }
public getUser(id: string): Observable<User> { public getUser(id: string): Observable<UserDto> {
return this.authService.authGet(this.apiUrl.buildUrl(`api/users/${id}`)) return this.authService.authGet(this.apiUrl.buildUrl(`api/users/${id}`))
.map(response => { .map(response => {
const body: any = response.json(); const body: any = response.json();
return new User( return new UserDto(
body.id, body.id,
body.profileUrl, body.email,
body.displayName); body.displayName,
body.pictureUrl);
}); });
} }
} }

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

@ -1,6 +1,18 @@
@import '_mixins'; @import '_mixins';
@import '_vars'; @import '_vars';
.table {
&-fixed {
table-layout: fixed;
}
&-borderless {
td {
border: none;
}
}
}
.form-hint { .form-hint {
font-size: .8rem; font-size: .8rem;
} }

39
src/Squidex/app/theme/_completer.scss

@ -0,0 +1,39 @@
@import '_mixins';
@import '_vars';
.completer-dropdown {
& {
margin-top: 2px !important;
width: 400px !important;
}
.completer {
&-image {
@include circle(2.2rem);
}
&-row {
& {
@include clearfix;
width: auto !important;
margin: 0;
margin-bottom: 0 !important;
display: block !important;
}
}
&-selected-row {
background: $color-theme-blue-dark !important;
}
&-description {
font-style: italic;
}
&-title,
&-description {
@include truncate;
padding-left: .3rem;
}
}
}

67
src/Squidex/app/theme/_layout.scss

@ -15,48 +15,61 @@ h1 {
display: none; display: none;
} }
.card {
@include box-shadow(0, 2px, 2px, .1);
}
.layout { .layout {
& {
@include fixed(54px, 0, 0, 0);
@include flex-box;
@include flex-flow(row);
}
&-title-icon { &-title-icon {
margin-right: .6rem; margin-right: .6rem;
font-size: 1.7rem;
font-weight: normal;
}
&-left,
&-right,
&-middle-header,
&-middle-content {
padding: $padding-layout-v $padding-layout-h;
}
&-left,
&-right {
background: $sidebar-color;
position: relative;
width: 220px;
} }
&-new-button { &-right {
display: block; border-left: 1px solid $color-border;
width: 100%;
} }
&-content { &-left {
border-right: 1px solid $color-border;
}
&-middle {
& { & {
@include fixed(54px, 0, 0, 0); @include flex-grow(1);
@include flex-box; @include flex-box;
@include flex-flow(row); @include flex-flow(column);
} }
&-left, &-header {
&-right { @include box-shadow(0, 2px, 2px, .1);
background: $sidebar-color; background: $sidebar-color;
padding: $padding-layout; border-bottom: 1px solid $color-border;
position: relative;
width: 220px;
} }
&-right { &-content {
border-left: 1px solid $color-border;
}
&-left {
& {
border-right: 1px solid $color-border;
}
&--no-button {
padding-top: $padding-layout + 38px;
}
}
&-main {
@include flex-grow(1); @include flex-grow(1);
padding: $padding-layout; padding: $padding-layout-v;
} }
} }
} }

28
src/Squidex/app/theme/_mixins.scss

@ -1,6 +1,6 @@
@mixin clearfix() { @mixin clearfix() {
&::after { &::after {
content: ""; content: '';
display: table; display: table;
clear: both; clear: both;
} }
@ -72,6 +72,32 @@
box-sizing: border-box; box-sizing: border-box;
} }
@mixin caret-top() {
display: inline-block;
width: 0;
height: 0;
content: '';
border-bottom: .3em solid;
border-right: .3em solid transparent;
border-left: .3em solid transparent;
}
@mixin caret-bottom() {
display: inline-block;
width: 0;
height: 0;
content: '';
border-top: .3em solid;
border-right: .3em solid transparent;
border-left: .3em solid transparent;
}
@mixin circle($size) {
@include border-radius($size * .5);
width: $size;
height: $size;
}
@mixin wrap() { @mixin wrap() {
-ms-word-break: normal; -ms-word-break: normal;
word-break: normal; word-break: normal;

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

@ -27,5 +27,8 @@ $color-theme-error-dark: darken($color-theme-error, 5%);
$color-accent-dark: #fff; $color-accent-dark: #fff;
$padding-layout: 30px; $color-card-footer: #fff;
$padding-layout-h: 30px;
$padding-layout-v: 20px;

9
src/Squidex/app/theme/_vendor-overrides.scss

@ -7,4 +7,11 @@ $brand-primary: $color-theme-blue;
$brand-success: $color-theme-green; $brand-success: $color-theme-green;
$btn-secondary-bg: #dce5e8; $btn-secondary-bg: #dce5e8;
$btn-secondary-border: #dce5e8; $btn-secondary-border: #dce5e8;
$card-border-color: $color-border;
$card-cap-bg: $color-card-footer;
.completer-input {
@extend .form-control;
}

3
src/Squidex/app/theme/theme.scss

@ -1,2 +1,3 @@
@import '_bootstrap.scss'; @import '_bootstrap.scss';
@import '_layout.scss'; @import '_layout.scss';
@import '_completer.scss';

1
src/Squidex/package.json

@ -29,6 +29,7 @@
"immutable": "^3.8.1", "immutable": "^3.8.1",
"moment": "^2.14.0", "moment": "^2.14.0",
"mousetrap": "^1.6.0", "mousetrap": "^1.6.0",
"ng2-completer": "^0.2.3",
"oidc-client": "^1.2.1-beta.3", "oidc-client": "^1.2.1-beta.3",
"reflect-metadata": "^0.1.3", "reflect-metadata": "^0.1.3",
"rxjs": "5.0.0-beta.12", "rxjs": "5.0.0-beta.12",

Loading…
Cancel
Save