From 4870233ce8caf6bdda935e72e894ace65e6fbd48 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 17 Nov 2016 21:24:46 +0100 Subject: [PATCH] Contributors --- Squidex.sln.DotSettings | 19 +- .../Apps/AppContributorAssigned.cs | 2 +- .../Apps/AppContributorRemoved.cs | 19 ++ src/Squidex.Infrastructure/Language.cs | 79 ++++++++ .../Reflection/SimpleMapper.cs | 7 + src/Squidex.Infrastructure/language-codes.csv | 185 ++++++++++++++++++ src/Squidex.Infrastructure/project.json | 5 + src/Squidex.Read/Users/IUserEntity.cs | 21 ++ .../Users/Repositories/IUserRepository.cs | 20 ++ .../Apps/MongoAppRepository.cs | 9 +- src/Squidex.Store.MongoDb/MongoDbModule.cs | 6 + .../Users/MongoUserEntity.cs | 44 +++++ .../Users/MongoUserRepository.cs | 45 +++++ src/Squidex.Write/AppAggregateCommand.cs | 28 +++ src/Squidex.Write/Apps/AppCommandHandler.cs | 42 +++- src/Squidex.Write/Apps/AppDomainObject.cs | 84 +++++++- .../Apps/Commands/AssignContributor.cs | 29 +++ src/Squidex.Write/Apps/Commands/CreateApp.cs | 2 +- .../Apps/Commands/RemoveContributor.cs | 26 +++ .../Schemas/Commands/AddField.cs | 2 +- .../Schemas/Commands/CreateSchema.cs | 2 +- .../Schemas/SchemaCommandHandler.cs | 2 +- .../Schemas/SchemaDomainObject.cs | 40 +++- src/Squidex.Write/project.json | 4 +- .../Api/Apps/AppContributorsController.cs | 70 +++++++ .../Api/Apps/Models/AppContributorDto.cs | 19 ++ .../Api/Apps/Models/AssignContributorDto.cs | 19 ++ .../Api/Languages/LanguagesController.cs | 30 +++ .../Modules/Api/Schemas/SchemasController.cs | 4 +- .../Modules/Api/Users/Models/UserDto.cs | 19 ++ .../Modules/Api/Users/UsersController.cs | 58 ++++++ src/Squidex/Squidex.xproj | 8 +- src/Squidex/Startup.cs | 2 +- src/Squidex/Views/Account/Login.cshtml | 2 +- .../components/layout/app-form.component.html | 12 +- .../layout/apps-menu.component.html | 2 +- .../app/framework/angular/animations.ts | 6 + src/Squidex/project.json | 3 +- .../LanguageTests.cs | 56 ++++++ .../Apps/AppCommandHandlerTests.cs | 87 ++++++-- .../Apps/AppDomainObjectTests.cs | 91 ++++++++- .../Schemas/SchemaDomainObjectTests.cs | 29 ++- .../Utils/HandlerTestBase.cs | 65 ++++++ tests/Squidex.Write.Tests/project.json | 2 +- 44 files changed, 1217 insertions(+), 89 deletions(-) create mode 100644 src/Squidex.Events/Apps/AppContributorRemoved.cs create mode 100644 src/Squidex.Infrastructure/Language.cs create mode 100644 src/Squidex.Infrastructure/language-codes.csv create mode 100644 src/Squidex.Read/Users/IUserEntity.cs create mode 100644 src/Squidex.Read/Users/Repositories/IUserRepository.cs create mode 100644 src/Squidex.Store.MongoDb/Users/MongoUserEntity.cs create mode 100644 src/Squidex.Store.MongoDb/Users/MongoUserRepository.cs create mode 100644 src/Squidex.Write/AppAggregateCommand.cs create mode 100644 src/Squidex.Write/Apps/Commands/AssignContributor.cs create mode 100644 src/Squidex.Write/Apps/Commands/RemoveContributor.cs create mode 100644 src/Squidex/Modules/Api/Apps/AppContributorsController.cs create mode 100644 src/Squidex/Modules/Api/Apps/Models/AppContributorDto.cs create mode 100644 src/Squidex/Modules/Api/Apps/Models/AssignContributorDto.cs create mode 100644 src/Squidex/Modules/Api/Languages/LanguagesController.cs create mode 100644 src/Squidex/Modules/Api/Users/Models/UserDto.cs create mode 100644 src/Squidex/Modules/Api/Users/UsersController.cs create mode 100644 tests/Squidex.Infrastructure.Tests/LanguageTests.cs create mode 100644 tests/Squidex.Write.Tests/Utils/HandlerTestBase.cs diff --git a/Squidex.sln.DotSettings b/Squidex.sln.DotSettings index 9fed824e0..383436a20 100644 --- a/Squidex.sln.DotSettings +++ b/Squidex.sln.DotSettings @@ -6,13 +6,16 @@ True True True - True True True True True True True + False + True + + True @@ -36,19 +39,15 @@ <?xml version="1.0" encoding="utf-16"?><Profile name="Namespaces"><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings><EmbraceInRegion>False</EmbraceInRegion><RegionName></RegionName></CSOptimizeUsings><CSUpdateFileHeader>True</CSUpdateFileHeader></Profile> <?xml version="1.0" encoding="utf-16"?><Profile name="Typescript"><JsInsertSemicolon>True</JsInsertSemicolon><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><CorrectVariableKindsDescriptor>True</CorrectVariableKindsDescriptor><VariablesToInnerScopesDescriptor>True</VariablesToInnerScopesDescriptor><StringToTemplatesDescriptor>True</StringToTemplatesDescriptor><RemoveRedundantQualifiersTs>True</RemoveRedundantQualifiersTs><OptimizeImportsTs>True</OptimizeImportsTs></Profile> - False - True - True - True - False SingleQuoted - ========================================================================== + ========================================================================= $FILENAME$ Squidex Headless CMS ========================================================================== Copyright (c) Squidex Group All rights reserved. -========================================================================== +========================================================================== + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> @@ -91,8 +90,4 @@ <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> True - True - True - True - True True \ No newline at end of file diff --git a/src/Squidex.Events/Apps/AppContributorAssigned.cs b/src/Squidex.Events/Apps/AppContributorAssigned.cs index 5e2695496..fef08ed6f 100644 --- a/src/Squidex.Events/Apps/AppContributorAssigned.cs +++ b/src/Squidex.Events/Apps/AppContributorAssigned.cs @@ -15,7 +15,7 @@ namespace Squidex.Events.Apps [TypeName("AppContributorAssigned")] public class AppContributorAssigned : IEvent { - public string SubjectId { get; set; } + public string ContributorId { get; set; } public PermissionLevel Permission { get; set; } } diff --git a/src/Squidex.Events/Apps/AppContributorRemoved.cs b/src/Squidex.Events/Apps/AppContributorRemoved.cs new file mode 100644 index 000000000..b82e3c77d --- /dev/null +++ b/src/Squidex.Events/Apps/AppContributorRemoved.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// AppContributorRemoved.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Infrastructure; +using Squidex.Infrastructure.CQRS.Events; + +namespace Squidex.Events.Apps +{ + [TypeName("AppContributorRemoved")] + public class AppContributorRemoved : IEvent + { + public string ContributorId { get; set; } + } +} diff --git a/src/Squidex.Infrastructure/Language.cs b/src/Squidex.Infrastructure/Language.cs new file mode 100644 index 000000000..57fb67429 --- /dev/null +++ b/src/Squidex.Infrastructure/Language.cs @@ -0,0 +1,79 @@ +// ========================================================================= +// Language.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; + +namespace Squidex.Infrastructure +{ + public sealed class Language + { + private readonly string iso2Code; + private readonly string englishName; + private static readonly Dictionary allLanguages = new Dictionary(); + + static Language() + { + var resourceAssembly = typeof(Language).GetTypeInfo().Assembly; + var resourceStream = resourceAssembly.GetManifestResourceStream("Squidex.Infrastructure.language-codes.csv"); + + using (var reader = new StreamReader(resourceStream, Encoding.UTF8)) + { + reader.ReadLine(); + + while (!reader.EndOfStream) + { + var languageLine = reader.ReadLine(); + var languageIso2Code = languageLine.Substring(1, 2); + var languageEnglishName = languageLine.Substring(6, languageLine.Length - 7); + + allLanguages[languageIso2Code] = new Language(languageIso2Code, languageEnglishName); + } + } + } + + public static Language GetLanguage(string iso2Code) + { + Guard.NotNullOrEmpty(iso2Code, nameof(iso2Code)); + + try + { + return allLanguages[iso2Code]; + } + catch (KeyNotFoundException) + { + throw new NotSupportedException($"Language {iso2Code} is not supported"); + } + } + + public static IEnumerable AllLanguages + { + get { return allLanguages.Values; } + } + + public string EnglishName + { + get { return englishName; } + } + + public string Iso2Code + { + get { return iso2Code; } + } + + private Language(string iso2Code, string englishName) + { + this.iso2Code = iso2Code; + + this.englishName = englishName; + } + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Reflection/SimpleMapper.cs b/src/Squidex.Infrastructure/Reflection/SimpleMapper.cs index 0c09fde03..2ba1fe2cc 100644 --- a/src/Squidex.Infrastructure/Reflection/SimpleMapper.cs +++ b/src/Squidex.Infrastructure/Reflection/SimpleMapper.cs @@ -153,6 +153,13 @@ namespace Squidex.Infrastructure.Reflection } } + public static TDestination Map(TSource source) + where TSource : class + where TDestination : class, new() + { + return Map(source, new TDestination(), CultureInfo.CurrentCulture); + } + public static TDestination Map(TSource source, TDestination destination) where TSource : class where TDestination : class diff --git a/src/Squidex.Infrastructure/language-codes.csv b/src/Squidex.Infrastructure/language-codes.csv new file mode 100644 index 000000000..a8db380d2 --- /dev/null +++ b/src/Squidex.Infrastructure/language-codes.csv @@ -0,0 +1,185 @@ +"alpha2","English" +"aa","Afar" +"ab","Abkhazian" +"ae","Avestan" +"af","Afrikaans" +"ak","Akan" +"am","Amharic" +"an","Aragonese" +"ar","Arabic" +"as","Assamese" +"av","Avaric" +"ay","Aymara" +"az","Azerbaijani" +"ba","Bashkir" +"be","Belarusian" +"bg","Bulgarian" +"bh","Bihari languages" +"bi","Bislama" +"bm","Bambara" +"bn","Bengali" +"bo","Tibetan" +"br","Breton" +"bs","Bosnian" +"ca","Catalan; Valencian" +"ce","Chechen" +"ch","Chamorro" +"co","Corsican" +"cr","Cree" +"cs","Czech" +"cu","Church Slavic; Old Slavonic; Church Slavonic; Old Bulgarian; Old Church Slavonic" +"cv","Chuvash" +"cy","Welsh" +"da","Danish" +"de","German" +"dv","Divehi; Dhivehi; Maldivian" +"dz","Dzongkha" +"ee","Ewe" +"el","Greek, Modern (1453-)" +"en","English" +"eo","Esperanto" +"es","Spanish; Castilian" +"et","Estonian" +"eu","Basque" +"fa","Persian" +"ff","Fulah" +"fi","Finnish" +"fj","Fijian" +"fo","Faroese" +"fr","French" +"fy","Western Frisian" +"ga","Irish" +"gd","Gaelic; Scottish Gaelic" +"gl","Galician" +"gn","Guarani" +"gu","Gujarati" +"gv","Manx" +"ha","Hausa" +"he","Hebrew" +"hi","Hindi" +"ho","Hiri Motu" +"hr","Croatian" +"ht","Haitian; Haitian Creole" +"hu","Hungarian" +"hy","Armenian" +"hz","Herero" +"ia","Interlingua (International Auxiliary Language Association)" +"id","Indonesian" +"ie","Interlingue; Occidental" +"ig","Igbo" +"ii","Sichuan Yi; Nuosu" +"ik","Inupiaq" +"io","Ido" +"is","Icelandic" +"it","Italian" +"iu","Inuktitut" +"ja","Japanese" +"jv","Javanese" +"ka","Georgian" +"kg","Kongo" +"ki","Kikuyu; Gikuyu" +"kj","Kuanyama; Kwanyama" +"kk","Kazakh" +"kl","Kalaallisut; Greenlandic" +"km","Central Khmer" +"kn","Kannada" +"ko","Korean" +"kr","Kanuri" +"ks","Kashmiri" +"ku","Kurdish" +"kv","Komi" +"kw","Cornish" +"ky","Kirghiz; Kyrgyz" +"la","Latin" +"lb","Luxembourgish; Letzeburgesch" +"lg","Ganda" +"li","Limburgan; Limburger; Limburgish" +"ln","Lingala" +"lo","Lao" +"lt","Lithuanian" +"lu","Luba-Katanga" +"lv","Latvian" +"mg","Malagasy" +"mh","Marshallese" +"mi","Maori" +"mk","Macedonian" +"ml","Malayalam" +"mn","Mongolian" +"mr","Marathi" +"ms","Malay" +"mt","Maltese" +"my","Burmese" +"na","Nauru" +"nb","Bokmål, Norwegian; Norwegian Bokmål" +"nd","Ndebele, North; North Ndebele" +"ne","Nepali" +"ng","Ndonga" +"nl","Dutch; Flemish" +"nn","Norwegian Nynorsk; Nynorsk, Norwegian" +"no","Norwegian" +"nr","Ndebele, South; South Ndebele" +"nv","Navajo; Navaho" +"ny","Chichewa; Chewa; Nyanja" +"oc","Occitan (post 1500); Provençal" +"oj","Ojibwa" +"om","Oromo" +"or","Oriya" +"os","Ossetian; Ossetic" +"pa","Panjabi; Punjabi" +"pi","Pali" +"pl","Polish" +"ps","Pushto; Pashto" +"pt","Portuguese" +"qu","Quechua" +"rm","Romansh" +"rn","Rundi" +"ro","Romanian; Moldavian; Moldovan" +"ru","Russian" +"rw","Kinyarwanda" +"sa","Sanskrit" +"sc","Sardinian" +"sd","Sindhi" +"se","Northern Sami" +"sg","Sango" +"si","Sinhala; Sinhalese" +"sk","Slovak" +"sl","Slovenian" +"sm","Samoan" +"sn","Shona" +"so","Somali" +"sq","Albanian" +"sr","Serbian" +"ss","Swati" +"st","Sotho, Southern" +"su","Sundanese" +"sv","Swedish" +"sw","Swahili" +"ta","Tamil" +"te","Telugu" +"tg","Tajik" +"th","Thai" +"ti","Tigrinya" +"tk","Turkmen" +"tl","Tagalog" +"tn","Tswana" +"to","Tonga (Tonga Islands)" +"tr","Turkish" +"ts","Tsonga" +"tt","Tatar" +"tw","Twi" +"ty","Tahitian" +"ug","Uighur; Uyghur" +"uk","Ukrainian" +"ur","Urdu" +"uz","Uzbek" +"ve","Venda" +"vi","Vietnamese" +"vo","Volapük" +"wa","Walloon" +"wo","Wolof" +"xh","Xhosa" +"yi","Yiddish" +"yo","Yoruba" +"za","Zhuang; Chuang" +"zh","Chinese" +"zu","Zulu" diff --git a/src/Squidex.Infrastructure/project.json b/src/Squidex.Infrastructure/project.json index 76273b30b..a21e9b190 100644 --- a/src/Squidex.Infrastructure/project.json +++ b/src/Squidex.Infrastructure/project.json @@ -22,6 +22,11 @@ } } }, + "buildOptions": { + "embed": [ + "*.csv" + ] + }, "tooling": { "defaultNamespace": "Squidex.Infrastructure" } diff --git a/src/Squidex.Read/Users/IUserEntity.cs b/src/Squidex.Read/Users/IUserEntity.cs new file mode 100644 index 000000000..19279c485 --- /dev/null +++ b/src/Squidex.Read/Users/IUserEntity.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// IUserEntity.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Read.Users +{ + public interface IUserEntity + { + string Id { get; } + + string Email { get; } + + string PictureUrl { get; } + + string DisplayName { get; } + } +} diff --git a/src/Squidex.Read/Users/Repositories/IUserRepository.cs b/src/Squidex.Read/Users/Repositories/IUserRepository.cs new file mode 100644 index 000000000..d0882d25c --- /dev/null +++ b/src/Squidex.Read/Users/Repositories/IUserRepository.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// IUserRepository.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Squidex.Read.Users.Repositories +{ + public interface IUserRepository + { + Task> FindUsersByEmail(string email); + + Task FindUserByIdAsync(string id); + } +} diff --git a/src/Squidex.Store.MongoDb/Apps/MongoAppRepository.cs b/src/Squidex.Store.MongoDb/Apps/MongoAppRepository.cs index 132201159..e740ce3a8 100644 --- a/src/Squidex.Store.MongoDb/Apps/MongoAppRepository.cs +++ b/src/Squidex.Store.MongoDb/Apps/MongoAppRepository.cs @@ -59,15 +59,20 @@ namespace Squidex.Store.MongoDb.Apps return Collection.CreateAsync(headers, a => SimpleMapper.Map(@event, a)); } + public Task On(AppContributorRemoved @event, EnvelopeHeaders headers) + { + return Collection.UpdateAsync(headers, a => a.Contributors.RemoveAll(c => c.SubjectId == @event.ContributorId)); + } + public Task On(AppContributorAssigned @event, EnvelopeHeaders headers) { return Collection.UpdateAsync(headers, a => { - var contributor = a.Contributors.Find(x => x.SubjectId == @event.SubjectId); + var contributor = a.Contributors.Find(x => x.SubjectId == @event.ContributorId); if (contributor == null) { - contributor = new MongoAppContributorEntity { SubjectId = @event.SubjectId }; + contributor = new MongoAppContributorEntity { SubjectId = @event.ContributorId }; a.Contributors.Add(contributor); } diff --git a/src/Squidex.Store.MongoDb/MongoDbModule.cs b/src/Squidex.Store.MongoDb/MongoDbModule.cs index cee11d596..456a41245 100644 --- a/src/Squidex.Store.MongoDb/MongoDbModule.cs +++ b/src/Squidex.Store.MongoDb/MongoDbModule.cs @@ -16,9 +16,11 @@ using Squidex.Infrastructure.CQRS.Events; using Squidex.Infrastructure.CQRS.EventStore; using Squidex.Read.Apps.Repositories; using Squidex.Read.Schemas.Repositories; +using Squidex.Read.Users.Repositories; using Squidex.Store.MongoDb.Apps; using Squidex.Store.MongoDb.Infrastructure; using Squidex.Store.MongoDb.Schemas; +using Squidex.Store.MongoDb.Users; namespace Squidex.Store.MongoDb { @@ -63,6 +65,10 @@ namespace Squidex.Store.MongoDb .As() .SingleInstance(); + builder.RegisterType() + .As() + .SingleInstance(); + builder.RegisterType() .As() .As() diff --git a/src/Squidex.Store.MongoDb/Users/MongoUserEntity.cs b/src/Squidex.Store.MongoDb/Users/MongoUserEntity.cs new file mode 100644 index 000000000..0e34a3c26 --- /dev/null +++ b/src/Squidex.Store.MongoDb/Users/MongoUserEntity.cs @@ -0,0 +1,44 @@ +// ========================================================================== +// MongoUserEntity.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Microsoft.AspNetCore.Identity.MongoDB; +using Squidex.Infrastructure.Security; +using Squidex.Read.Users; + +namespace Squidex.Store.MongoDb.Users +{ + public class MongoUserEntity : IUserEntity + { + private readonly IdentityUser inner; + + public string Id + { + get { return inner.Id; } + } + + public string Email + { + get { return inner.Email; } + } + + public string DisplayName + { + get { return inner.Claims.Find(x => x.Type == ExtendedClaimTypes.SquidexDisplayName)?.Value; } + } + + public string PictureUrl + { + get { return inner.Claims.Find(x => x.Type == ExtendedClaimTypes.SquidexPictureUrl)?.Value; } + } + + public MongoUserEntity(IdentityUser inner) + { + this.inner = inner; + } + } +} diff --git a/src/Squidex.Store.MongoDb/Users/MongoUserRepository.cs b/src/Squidex.Store.MongoDb/Users/MongoUserRepository.cs new file mode 100644 index 000000000..9c84c5d06 --- /dev/null +++ b/src/Squidex.Store.MongoDb/Users/MongoUserRepository.cs @@ -0,0 +1,45 @@ +// ========================================================================== +// MongoUserRepository.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Linq; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.MongoDB; +using Squidex.Infrastructure; +using Squidex.Read.Users; +using Squidex.Read.Users.Repositories; + +namespace Squidex.Store.MongoDb.Users +{ + public sealed class MongoUserRepository : IUserRepository + { + private readonly UserManager userManager; + + public MongoUserRepository(UserManager userManager) + { + Guard.NotNull(userManager, nameof(userManager)); + + this.userManager = userManager; + } + + public Task> FindUsersByEmail(string email) + { + var users = userManager.Users.Where(x => x.NormalizedEmail.Contains(email)).Take(10).ToList(); + + return Task.FromResult(users.Select(x => (IUserEntity)new MongoUserEntity(x)).ToList()); + } + + public async Task FindUserByIdAsync(string id) + { + var user = await userManager.FindByIdAsync(id); + + return user != null ? new MongoUserEntity(user) : null; + } + } +} diff --git a/src/Squidex.Write/AppAggregateCommand.cs b/src/Squidex.Write/AppAggregateCommand.cs new file mode 100644 index 000000000..d527d2856 --- /dev/null +++ b/src/Squidex.Write/AppAggregateCommand.cs @@ -0,0 +1,28 @@ +// ========================================================================== +// AppAggregateCommand.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Squidex.Infrastructure.CQRS.Commands; + +namespace Squidex.Write +{ + public class AppAggregateCommand : AggregateCommand, IAppCommand + { + Guid IAppCommand.AppId + { + get + { + return AggregateId; + } + set + { + AggregateId = value; + } + } + } +} diff --git a/src/Squidex.Write/Apps/AppCommandHandler.cs b/src/Squidex.Write/Apps/AppCommandHandler.cs index 35ca835dd..60ed4e1b2 100644 --- a/src/Squidex.Write/Apps/AppCommandHandler.cs +++ b/src/Squidex.Write/Apps/AppCommandHandler.cs @@ -11,6 +11,7 @@ using Squidex.Infrastructure; using Squidex.Infrastructure.CQRS.Commands; using Squidex.Infrastructure.Dispatching; using Squidex.Read.Apps.Repositories; +using Squidex.Read.Users.Repositories; using Squidex.Write.Apps.Commands; namespace Squidex.Write.Apps @@ -18,28 +19,55 @@ namespace Squidex.Write.Apps public class AppCommandHandler : CommandHandler { private readonly IAppRepository appRepository; + private readonly IUserRepository userRepository; public AppCommandHandler( IDomainObjectFactory domainObjectFactory, IDomainObjectRepository domainObjectRepository, - IAppRepository appRepository) + IAppRepository appRepository, + IUserRepository userRepository) : base(domainObjectFactory, domainObjectRepository) { Guard.NotNull(appRepository, nameof(appRepository)); + Guard.NotNull(userRepository, nameof(userRepository)); this.appRepository = appRepository; + this.userRepository = userRepository; } - public async Task On(CreateApp command) + public Task On(CreateApp command) { - if (await appRepository.FindAppByNameAsync(command.Name) != null) + return CreateAsync(command, async x => { - var error = new ValidationError($"A app with name '{command.Name}' already exists", "Name"); + if (await appRepository.FindAppByNameAsync(command.Name) != null) + { + var error = new ValidationError($"A app with name '{command.Name}' already exists", nameof(CreateApp.Name)); - throw new ValidationException("Cannot create a new app", error); - } + throw new ValidationException("Cannot create a new app", error); + } - await CreateAsync(command, x => x.Create(command)); + x.Create(command); + }); + } + + public Task On(AssignContributor command) + { + return UpdateAsync(command, async x => + { + if (await userRepository.FindUserByIdAsync(command.ContributorId) == null) + { + var error = new ValidationError($"Cannot find contributor '{command.ContributorId}", nameof(AssignContributor.ContributorId)); + + throw new ValidationException("Cannot assign contributor to app", error); + } + + x.AssignContributor(command); + }); + } + + public Task On(RemoveContributor command) + { + return UpdateAsync(command, x => x.RemoveContributor(command)); } public override Task HandleAsync(CommandContext context) diff --git a/src/Squidex.Write/Apps/AppDomainObject.cs b/src/Squidex.Write/Apps/AppDomainObject.cs index bfd9d390d..fa34d9f15 100644 --- a/src/Squidex.Write/Apps/AppDomainObject.cs +++ b/src/Squidex.Write/Apps/AppDomainObject.cs @@ -13,13 +13,17 @@ using Squidex.Infrastructure; using Squidex.Infrastructure.CQRS; using Squidex.Infrastructure.CQRS.Events; using Squidex.Infrastructure.Dispatching; -using Squidex.Infrastructure.Reflection; using Squidex.Write.Apps.Commands; +using System.Collections.Generic; +using System.Linq; +using Squidex.Infrastructure.Reflection; +// ReSharper disable InvertIf namespace Squidex.Write.Apps { public sealed class AppDomainObject : DomainObject { + private readonly Dictionary contributors = new Dictionary(); private string name; public string Name @@ -27,6 +31,11 @@ namespace Squidex.Write.Apps get { return name; } } + public IReadOnlyDictionary Contributors + { + get { return contributors; } + } + public AppDomainObject(Guid id, int version) : base(id, version) { } @@ -36,19 +45,64 @@ namespace Squidex.Write.Apps name = @event.Name; } + public void On(AppContributorAssigned @event) + { + contributors[@event.ContributorId] = @event.Permission; + } + + public void On(AppContributorRemoved @event) + { + contributors.Remove(@event.ContributorId); + } + protected override void DispatchEvent(Envelope @event) { this.DispatchAction(@event.Payload); } - public void Create(CreateApp command) + public AppDomainObject Create(CreateApp command) { Guard.Valid(command, nameof(command), () => "Cannot create app"); VerifyNotCreated(); RaiseEvent(SimpleMapper.Map(command, new AppCreated())); - RaiseEvent(SimpleMapper.Map(command, new AppContributorAssigned { Permission = PermissionLevel.Owner })); + RaiseEvent(SimpleMapper.Map(command, new AppContributorAssigned { ContributorId = command.SubjectId, Permission = PermissionLevel.Owner })); + + return this; + } + + public AppDomainObject AssignContributor(AssignContributor command) + { + Guard.Valid(command, nameof(command), () => "Cannot assign contributor"); + + VerifyCreated(); + VerifyHasStillOwner(c => c[command.ContributorId] = command.Permission); + + RaiseEvent(SimpleMapper.Map(command, new AppContributorAssigned())); + + return this; + } + + public AppDomainObject RemoveContributor(RemoveContributor command) + { + Guard.Valid(command, nameof(command), () => "Cannot remove contributor"); + + VerifyCreated(); + VerifyContributorFound(command); + VerifyHasStillOwner(c => c.Remove(command.ContributorId)); + + RaiseEvent(SimpleMapper.Map(command, new AppContributorRemoved())); + + return this; + } + + private void VerifyCreated() + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new DomainException("App has not been created."); + } } private void VerifyNotCreated() @@ -58,5 +112,29 @@ namespace Squidex.Write.Apps throw new DomainException("App has already been created."); } } + + private void VerifyContributorFound(RemoveContributor command) + { + if (!contributors.ContainsKey(command.ContributorId)) + { + var error = new ValidationError("Contributor is not part of the app", "ContributorId"); + + throw new ValidationException("Cannot remove contributor", error); + } + } + + private void VerifyHasStillOwner(Action> change) + { + var contributorsCopy = new Dictionary(contributors); + + change(contributorsCopy); + + if (contributorsCopy.All(x => x.Value != PermissionLevel.Owner)) + { + var error = new ValidationError("Contributor is the last owner", "ContributorId"); + + throw new ValidationException("Cannot assign contributor", error); + } + } } } diff --git a/src/Squidex.Write/Apps/Commands/AssignContributor.cs b/src/Squidex.Write/Apps/Commands/AssignContributor.cs new file mode 100644 index 000000000..53f2c5305 --- /dev/null +++ b/src/Squidex.Write/Apps/Commands/AssignContributor.cs @@ -0,0 +1,29 @@ +// ========================================================================== +// AssignContributor.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using Squidex.Core.Apps; +using Squidex.Infrastructure; + +namespace Squidex.Write.Apps.Commands +{ + public class AssignContributor : AppAggregateCommand, IValidatable + { + public string ContributorId { get; set; } + + public PermissionLevel Permission { get; set; } + + public void Validate(IList errors) + { + if (string.IsNullOrWhiteSpace(ContributorId)) + { + errors.Add(new ValidationError("Contributor id not assigned", nameof (ContributorId))); + } + } + } +} \ No newline at end of file diff --git a/src/Squidex.Write/Apps/Commands/CreateApp.cs b/src/Squidex.Write/Apps/Commands/CreateApp.cs index fa3aa6407..60a435cc3 100644 --- a/src/Squidex.Write/Apps/Commands/CreateApp.cs +++ b/src/Squidex.Write/Apps/Commands/CreateApp.cs @@ -22,7 +22,7 @@ namespace Squidex.Write.Apps.Commands { if (!Name.IsSlug()) { - errors.Add(new ValidationError("Name must be a valid slug", nameof(Name))); + errors.Add(new ValidationError("DisplayName must be a valid slug", nameof(Name))); } } } diff --git a/src/Squidex.Write/Apps/Commands/RemoveContributor.cs b/src/Squidex.Write/Apps/Commands/RemoveContributor.cs new file mode 100644 index 000000000..11c2bbbec --- /dev/null +++ b/src/Squidex.Write/Apps/Commands/RemoveContributor.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// RemoveContributor.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using Squidex.Infrastructure; + +namespace Squidex.Write.Apps.Commands +{ + public class RemoveContributor : AppAggregateCommand, IValidatable + { + public string ContributorId { get; set; } + + public void Validate(IList errors) + { + if (string.IsNullOrWhiteSpace(ContributorId)) + { + errors.Add(new ValidationError("Contributor id not assigned", nameof(ContributorId))); + } + } + } +} \ No newline at end of file diff --git a/src/Squidex.Write/Schemas/Commands/AddField.cs b/src/Squidex.Write/Schemas/Commands/AddField.cs index be1cd31ef..29e197906 100644 --- a/src/Squidex.Write/Schemas/Commands/AddField.cs +++ b/src/Squidex.Write/Schemas/Commands/AddField.cs @@ -24,7 +24,7 @@ namespace Squidex.Write.Schemas.Commands { if (!Name.IsSlug()) { - errors.Add(new ValidationError("Name must be a valid slug", nameof(Name))); + errors.Add(new ValidationError("DisplayName must be a valid slug", nameof(Name))); } if (string.IsNullOrWhiteSpace(Type)) diff --git a/src/Squidex.Write/Schemas/Commands/CreateSchema.cs b/src/Squidex.Write/Schemas/Commands/CreateSchema.cs index a3aa3deca..2b29f18b9 100644 --- a/src/Squidex.Write/Schemas/Commands/CreateSchema.cs +++ b/src/Squidex.Write/Schemas/Commands/CreateSchema.cs @@ -31,7 +31,7 @@ namespace Squidex.Write.Schemas.Commands { if (!Name.IsSlug()) { - errors.Add(new ValidationError("Name must be a valid slug", nameof(Name))); + errors.Add(new ValidationError("DisplayName must be a valid slug", nameof(Name))); } } } diff --git a/src/Squidex.Write/Schemas/SchemaCommandHandler.cs b/src/Squidex.Write/Schemas/SchemaCommandHandler.cs index 034e57559..1b0fd6b30 100644 --- a/src/Squidex.Write/Schemas/SchemaCommandHandler.cs +++ b/src/Squidex.Write/Schemas/SchemaCommandHandler.cs @@ -47,7 +47,7 @@ namespace Squidex.Write.Schemas { if (await schemaProvider.FindSchemaIdByNameAsync(command.AppId, command.Name) != null) { - var error = new ValidationError($"A schema with name '{command.Name}' already exists", "Name"); + var error = new ValidationError($"A schema with name '{command.Name}' already exists", "DisplayName"); throw new ValidationException("Cannot create a new schema", error); } diff --git a/src/Squidex.Write/Schemas/SchemaDomainObject.cs b/src/Squidex.Write/Schemas/SchemaDomainObject.cs index 9f3c8f9ab..0ca0eee1b 100644 --- a/src/Squidex.Write/Schemas/SchemaDomainObject.cs +++ b/src/Squidex.Write/Schemas/SchemaDomainObject.cs @@ -103,7 +103,7 @@ namespace Squidex.Write.Schemas isDeleted = true; } - public void AddField(AddField command, FieldProperties properties) + public SchemaDomainObject AddField(AddField command, FieldProperties properties) { Guard.Valid(command, nameof(command), () => $"Cannot add field to schema {Id}"); Guard.NotNull(properties, nameof(properties)); @@ -111,9 +111,11 @@ namespace Squidex.Write.Schemas VerifyCreatedAndNotDeleted(); RaiseEvent(new FieldAdded { FieldId = ++totalFields, Name = command.Name, Properties = properties }); + + return this; } - public void UpdateField(UpdateField command, FieldProperties properties) + public SchemaDomainObject UpdateField(UpdateField command, FieldProperties properties) { Guard.Valid(command, nameof(command), () => $"Cannot update schema '{schema.Name} ({Id})'"); Guard.NotNull(properties, nameof(properties)); @@ -121,66 +123,84 @@ namespace Squidex.Write.Schemas VerifyCreatedAndNotDeleted(); RaiseEvent(new FieldUpdated { FieldId = command.FieldId, Properties = properties }); + + return this; } - public void Create(CreateSchema command) + public SchemaDomainObject Create(CreateSchema command) { Guard.Valid(command, nameof(command), () => "Cannot create schema"); VerifyNotCreated(); RaiseEvent(SimpleMapper.Map(command, new SchemaCreated())); + + return this; } - public void Update(UpdateSchema command) + public SchemaDomainObject Update(UpdateSchema command) { Guard.Valid(command, nameof(command), () => $"Cannot update schema '{schema.Name} ({Id})'"); VerifyCreatedAndNotDeleted(); RaiseEvent(SimpleMapper.Map(command, new SchemaUpdated())); + + return this; } - public void HideField(long fieldId) + public SchemaDomainObject HideField(long fieldId) { VerifyCreatedAndNotDeleted(); RaiseEvent(new FieldHidden { FieldId = fieldId }); + + return this; } - public void ShowField(long fieldId) + public SchemaDomainObject ShowField(long fieldId) { VerifyCreatedAndNotDeleted(); RaiseEvent(new FieldShown { FieldId = fieldId }); + + return this; } - public void DisableField(long fieldId) + public SchemaDomainObject DisableField(long fieldId) { VerifyCreatedAndNotDeleted(); RaiseEvent(new FieldDisabled { FieldId = fieldId }); + + return this; } - public void EnableField(long fieldId) + public SchemaDomainObject EnableField(long fieldId) { VerifyCreatedAndNotDeleted(); RaiseEvent(new FieldEnabled { FieldId = fieldId }); + + return this; } - public void DeleteField(long fieldId) + public SchemaDomainObject DeleteField(long fieldId) { VerifyCreatedAndNotDeleted(); RaiseEvent(new FieldDeleted { FieldId = fieldId }); + + return this; } - public void Delete() + public SchemaDomainObject Delete() { VerifyCreatedAndNotDeleted(); RaiseEvent(new SchemaDeleted()); + + return this; } private void VerifyNotCreated() diff --git a/src/Squidex.Write/project.json b/src/Squidex.Write/project.json index b958e7146..c9626ec19 100644 --- a/src/Squidex.Write/project.json +++ b/src/Squidex.Write/project.json @@ -2,12 +2,14 @@ "version": "1.0.0-*", "dependencies": { + "Microsoft.AspNetCore.Identity": "1.0.0", "NETStandard.Library": "1.6.0", "NodaTime": "2.0.0-alpha20160729", "Squidex.Core": "1.0.0-*", "Squidex.Events": "1.0.0-*", "Squidex.Infrastructure": "1.0.0-*", - "Squidex.Read": "1.0.0-*" + "Squidex.Read": "1.0.0-*", + "System.Linq": "4.1.0" }, "frameworks": { diff --git a/src/Squidex/Modules/Api/Apps/AppContributorsController.cs b/src/Squidex/Modules/Api/Apps/AppContributorsController.cs new file mode 100644 index 000000000..d55635b7d --- /dev/null +++ b/src/Squidex/Modules/Api/Apps/AppContributorsController.cs @@ -0,0 +1,70 @@ +// ========================================================================== +// AppContributorsController.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Squidex.Infrastructure.CQRS.Commands; +using Squidex.Infrastructure.Reflection; +using Squidex.Modules.Api.Apps.Models; +using Squidex.Pipeline; +using Squidex.Read.Apps.Repositories; +using Squidex.Write.Apps.Commands; + +namespace Squidex.Modules.Api.Apps +{ + [Authorize] + [ApiExceptionFilter] + [ServiceFilter(typeof(AppFilterAttribute))] + [Route("apps/{app}")] + public class AppContributorsController : ControllerBase + { + private readonly IAppRepository appRepository; + + public AppContributorsController(ICommandBus commandBus, IAppRepository appRepository) + : base(commandBus) + { + this.appRepository = appRepository; + } + + [HttpGet] + [Route("contributors")] + public async Task GetContributors(string app) + { + var entity = await appRepository.FindAppByNameAsync(app); + + if (entity == null) + { + return NotFound(); + } + + var model = entity.Contributors.Select(x => SimpleMapper.Map(x, new AppContributorDto())).ToList(); + + return Ok(model); + } + + [HttpPut] + [Route("contributors")] + public async Task PutContributor([FromBody] AssignContributorDto model) + { + await CommandBus.PublishAsync(SimpleMapper.Map(model, new AssignContributor())); + + return Ok(); + } + + [HttpDelete] + [Route("contributors/{contributorId}")] + public async Task PutContributor(string contributorId) + { + await CommandBus.PublishAsync(new RemoveContributor { ContributorId = contributorId }); + + return Ok(); + } + } +} diff --git a/src/Squidex/Modules/Api/Apps/Models/AppContributorDto.cs b/src/Squidex/Modules/Api/Apps/Models/AppContributorDto.cs new file mode 100644 index 000000000..eec804d5c --- /dev/null +++ b/src/Squidex/Modules/Api/Apps/Models/AppContributorDto.cs @@ -0,0 +1,19 @@ +// ========================================================================= +// AppContributorDto.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Core.Apps; + +namespace Squidex.Modules.Api.Apps.Models +{ + public sealed class AppContributorDto + { + public string ContributorId { get; set; } + + public PermissionLevel Permission { get; set; } + } +} diff --git a/src/Squidex/Modules/Api/Apps/Models/AssignContributorDto.cs b/src/Squidex/Modules/Api/Apps/Models/AssignContributorDto.cs new file mode 100644 index 000000000..c876c5367 --- /dev/null +++ b/src/Squidex/Modules/Api/Apps/Models/AssignContributorDto.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// PutContributorDto.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Core.Apps; + +namespace Squidex.Modules.Api.Apps.Models +{ + public class AssignContributorDto + { + public string ContributorId { get; set; } + + public PermissionLevel Permission { get; set; } + } +} \ No newline at end of file diff --git a/src/Squidex/Modules/Api/Languages/LanguagesController.cs b/src/Squidex/Modules/Api/Languages/LanguagesController.cs new file mode 100644 index 000000000..6ded38a65 --- /dev/null +++ b/src/Squidex/Modules/Api/Languages/LanguagesController.cs @@ -0,0 +1,30 @@ +// ========================================================================= +// LanguagesController.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Linq; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Squidex.Infrastructure; +using Squidex.Pipeline; + +namespace Squidex.Modules.Api.Languages +{ + [Authorize] + [ApiExceptionFilter] + public class LanguagesController : Controller + { + [HttpGet] + [Route("languages/")] + public IActionResult GetLanguages() + { + var model = Language.AllLanguages.ToList(); + + return Ok(model); + } + } +} diff --git a/src/Squidex/Modules/Api/Schemas/SchemasController.cs b/src/Squidex/Modules/Api/Schemas/SchemasController.cs index 00cdd936e..03cbde7dd 100644 --- a/src/Squidex/Modules/Api/Schemas/SchemasController.cs +++ b/src/Squidex/Modules/Api/Schemas/SchemasController.cs @@ -56,7 +56,9 @@ namespace Squidex.Modules.Api.Schemas return NotFound(); } - return Ok(SchemaDto.Create(entity.Schema)); + var model = SchemaDto.Create(entity.Schema); + + return Ok(model); } [HttpPost] diff --git a/src/Squidex/Modules/Api/Users/Models/UserDto.cs b/src/Squidex/Modules/Api/Users/Models/UserDto.cs new file mode 100644 index 000000000..8747312c2 --- /dev/null +++ b/src/Squidex/Modules/Api/Users/Models/UserDto.cs @@ -0,0 +1,19 @@ +// ========================================================================= +// UserDto.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Modules.Api.Users.Models +{ + public sealed class UserDto + { + public string Id { get; set; } + + public string ProfileUrl { get; set; } + + public string DisplayName { get; set; } + } +} diff --git a/src/Squidex/Modules/Api/Users/UsersController.cs b/src/Squidex/Modules/Api/Users/UsersController.cs new file mode 100644 index 000000000..b3480b900 --- /dev/null +++ b/src/Squidex/Modules/Api/Users/UsersController.cs @@ -0,0 +1,58 @@ +// ========================================================================= +// UsersController.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Squidex.Infrastructure.Reflection; +using Squidex.Modules.Api.Users.Models; +using Squidex.Pipeline; +using Squidex.Read.Users.Repositories; + +namespace Squidex.Modules.Api.Users +{ + [Authorize] + [ApiExceptionFilter] + public class UsersController : Controller + { + private readonly IUserRepository userRepository; + + public UsersController(IUserRepository userRepository) + { + this.userRepository = userRepository; + } + + [HttpGet] + [Route("users")] + public async Task GetUsers(string email) + { + var entities = await userRepository.FindUsersByEmail(email); + + var model = entities.Select(x => SimpleMapper.Map(x, new UserDto())).ToList(); + + return Ok(model); + } + + [HttpGet] + [Route("users/{id}/")] + public async Task GetUser(string id) + { + var entity = await userRepository.FindUserByIdAsync(id); + + if (entity == null) + { + return NotFound(); + } + + var model = SimpleMapper.Map(entity, new UserDto()); + + return Ok(model); + } + } +} diff --git a/src/Squidex/Squidex.xproj b/src/Squidex/Squidex.xproj index f1359169f..46f0ee3f9 100644 --- a/src/Squidex/Squidex.xproj +++ b/src/Squidex/Squidex.xproj @@ -16,11 +16,15 @@ 2.0 - True + True + + + + - + \ No newline at end of file diff --git a/src/Squidex/Startup.cs b/src/Squidex/Startup.cs index 69eea2e7a..e824bf497 100644 --- a/src/Squidex/Startup.cs +++ b/src/Squidex/Startup.cs @@ -181,7 +181,7 @@ namespace Squidex headers.CacheControl = new CacheControlHeaderValue { - MaxAge = TimeSpan.FromDays(60), + MaxAge = TimeSpan.FromDays(60) }; } }); diff --git a/src/Squidex/Views/Account/Login.cshtml b/src/Squidex/Views/Account/Login.cshtml index c25327cde..2cda11c29 100644 --- a/src/Squidex/Views/Account/Login.cshtml +++ b/src/Squidex/Views/Account/Login.cshtml @@ -20,7 +20,7 @@

@foreach (var provider in Model.ExternalProviders) { - + }

diff --git a/src/Squidex/app/components/layout/app-form.component.html b/src/Squidex/app/components/layout/app-form.component.html index eeecc6eee..7ca54ca58 100644 --- a/src/Squidex/app/components/layout/app-form.component.html +++ b/src/Squidex/app/components/layout/app-form.component.html @@ -6,26 +6,26 @@
- +
- Name is required. + DisplayName is required. - Name can not have more than 40 characters. + DisplayName can not have more than 40 characters. - Name can contain lower case letters (a-z), numbers and dashes only. + DisplayName can contain lower case letters (a-z), numbers and dashes only.
- + - The app name becomes part of the api url, e.g, https://{{appName}}.squidex.io/.
+ The app name becomes part of the api url, e.g, https://{{appDisplayName}}.squidex.io/.
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.
diff --git a/src/Squidex/app/components/layout/apps-menu.component.html b/src/Squidex/app/components/layout/apps-menu.component.html index ddae75a9c..eb5df4e2c 100644 --- a/src/Squidex/app/components/layout/apps-menu.component.html +++ b/src/Squidex/app/components/layout/apps-menu.component.html @@ -1,6 +1,6 @@