Browse Source

Improved privacy by hiding the email of the user.

pull/264/head
Sebastian Stehle 8 years ago
parent
commit
af645a420b
  1. 4
      src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs
  2. 23
      src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs
  3. 12
      src/Squidex.Domain.Users.MongoDb/MongoUserStore.cs
  4. 10
      src/Squidex.Domain.Users/UserExtensions.cs
  5. 3
      src/Squidex.Domain.Users/UserManagerExtensions.cs
  6. 2
      src/Squidex.Shared/Identity/SquidexClaimTypes.cs
  7. 2
      src/Squidex.Shared/Users/IUserResolver.cs
  8. 11
      src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs
  9. 1
      src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs
  10. 2
      src/Squidex/Areas/Api/Controllers/Apps/Models/AssignAppContributorDto.cs
  11. 20
      src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorAssignedDto.cs
  12. 2
      src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs
  13. 9
      src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs
  14. 1
      src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs
  15. 9
      src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs
  16. 26
      src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs
  17. 9
      src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs
  18. 6
      src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs
  19. 6
      src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs
  20. 4
      src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs
  21. 8
      src/Squidex/Areas/Api/Controllers/Users/UsersController.cs
  22. 2
      src/Squidex/Areas/IdentityServer/Controllers/Profile/ChangeProfileModel.cs
  23. 3
      src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs
  24. 2
      src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileVM.cs
  25. 12
      src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml
  26. 1
      src/Squidex/app/features/administration/pages/users/user-page.component.ts
  27. 14
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html
  28. 11
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.scss
  29. 13
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts
  30. 7
      src/Squidex/app/framework/angular/autocomplete.component.ts
  31. 40
      src/Squidex/app/shared/components/pipes.ts
  32. 6
      src/Squidex/app/shared/module.ts
  33. 13
      src/Squidex/app/shared/services/app-contributors.service.spec.ts
  34. 16
      src/Squidex/app/shared/services/app-contributors.service.ts
  35. 20
      src/Squidex/app/shared/services/users-provider.service.spec.ts
  36. 10
      src/Squidex/app/shared/services/users-provider.service.ts
  37. 68
      src/Squidex/app/shared/services/users.service.spec.ts
  38. 37
      src/Squidex/app/shared/services/users.service.ts
  39. 12
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs
  40. 34
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppContributorsTests.cs
  41. 2
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppTests.cs

4
src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs

@ -70,11 +70,13 @@ namespace Squidex.Domain.Apps.Entities.Apps
});
case AssignContributor assigneContributor:
return UpdateAsync(assigneContributor, async c =>
return UpdateReturnAsync(assigneContributor, async c =>
{
await GuardAppContributors.CanAssign(Snapshot.Contributors, c, userResolver, appPlansProvider.GetPlan(Snapshot.Plan?.PlanId));
AssignContributor(c);
return EntityCreatedResult.Create(c.ContributorId, NewVersion);
});
case RemoveContributor removeContributor:

23
src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs

@ -34,20 +34,27 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
}
else
{
if (await users.FindByIdAsync(command.ContributorId) == null)
var user = await users.FindByIdOrEmailAsync(command.ContributorId);
if (user == null)
{
error(new ValidationError("Cannot find contributor id.", nameof(command.ContributorId)));
}
else if (contributors.TryGetValue(command.ContributorId, out var existing))
else
{
if (existing == command.Permission)
command.ContributorId = user.Id;
if (contributors.TryGetValue(command.ContributorId, out var existing))
{
error(new ValidationError("Contributor has already this permission.", nameof(command.Permission)));
if (existing == command.Permission)
{
error(new ValidationError("Contributor has already this permission.", nameof(command.Permission)));
}
}
else if (plan.MaxContributors == contributors.Count)
{
error(new ValidationError("You have reached the maximum number of contributors for your plan."));
}
}
else if (plan.MaxContributors == contributors.Count)
{
error(new ValidationError("You have reached the maximum number of contributors for your plan."));
}
}
});

12
src/Squidex.Domain.Users.MongoDb/MongoUserStore.cs

@ -12,6 +12,7 @@ using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using MongoDB.Bson;
using MongoDB.Driver;
using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.Tasks;
@ -72,9 +73,16 @@ namespace Squidex.Domain.Users.MongoDb
return new MongoUser { Email = email, UserName = email };
}
public async Task<IUser> FindByIdAsync(string id)
public async Task<IUser> FindByIdOrEmailAsync(string id)
{
return await Collection.Find(x => x.Id == id).FirstOrDefaultAsync();
if (ObjectId.TryParse(id, out var parsed))
{
return await Collection.Find(x => x.Id == id).FirstOrDefaultAsync();
}
else
{
return await Collection.Find(x => x.NormalizedEmail == id.ToUpperInvariant()).FirstOrDefaultAsync();
}
}
public async Task<IUser> FindByIdAsync(string userId, CancellationToken cancellationToken)

10
src/Squidex.Domain.Users/UserExtensions.cs

@ -35,6 +35,11 @@ namespace Squidex.Domain.Users
user.SetClaim(SquidexClaimTypes.SquidexPictureUrl, GravatarHelper.CreatePictureUrl(email));
}
public static void SetHidden(this IUser user, bool value)
{
user.SetClaim(SquidexClaimTypes.SquidexHidden, value.ToString());
}
public static void SetConsent(this IUser user)
{
user.SetClaim(SquidexClaimTypes.SquidexConsent, "true");
@ -45,6 +50,11 @@ namespace Squidex.Domain.Users
user.SetClaim(SquidexClaimTypes.SquidexConsentForEmails, value.ToString());
}
public static bool IsHidden(this IUser user)
{
return user.HasClaimValue(SquidexClaimTypes.SquidexHidden, "true");
}
public static bool HasConsent(this IUser user)
{
return user.HasClaimValue(SquidexClaimTypes.SquidexConsent, "true");

3
src/Squidex.Domain.Users/UserManagerExtensions.cs

@ -71,8 +71,9 @@ namespace Squidex.Domain.Users
return user;
}
public static Task<IdentityResult> UpdateAsync(this UserManager<IUser> userManager, IUser user, string email, string displayName)
public static Task<IdentityResult> UpdateAsync(this UserManager<IUser> userManager, IUser user, string email, string displayName, bool hidden)
{
user.SetHidden(hidden);
user.SetEmail(email);
user.SetDisplayName(displayName);

2
src/Squidex.Shared/Identity/SquidexClaimTypes.cs

@ -17,6 +17,8 @@ namespace Squidex.Shared.Identity
public static readonly string SquidexConsentForEmails = "urn:squidex:consent:emails";
public static readonly string SquidexHidden = "urn:squidex:hidden";
public static readonly string Prefix = "urn:squidex:";
}
}

2
src/Squidex.Shared/Users/IUserResolver.cs

@ -11,6 +11,6 @@ namespace Squidex.Shared.Users
{
public interface IUserResolver
{
Task<IUser> FindByIdAsync(string id);
Task<IUser> FindByIdOrEmailAsync(string idOrEmail);
}
}

11
src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs

@ -65,19 +65,24 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// <param name="app">The name of the app.</param>
/// <param name="request">Contributor object that needs to be added to the app.</param>
/// <returns>
/// 204 => User assigned to app.
/// 200 => User assigned to app.
/// 400 => User is already assigned to the app or not found.
/// 404 => App not found.
/// </returns>
[HttpPost]
[Route("apps/{app}/contributors/")]
[ProducesResponseType(typeof(ContributorAssignedDto), 201)]
[ProducesResponseType(typeof(ErrorDto), 400)]
[ApiCosts(1)]
public async Task<IActionResult> PostContributor(string app, [FromBody] AssignAppContributorDto request)
{
await CommandBus.PublishAsync(SimpleMapper.Map(request, new AssignContributor()));
var command = SimpleMapper.Map(request, new AssignContributor());
var context = await CommandBus.PublishAsync(command);
return NoContent();
var result = context.Result<EntityCreatedResult<string>>();
var response = new ContributorAssignedDto { ContributorId = result.IdOrValue };
return Ok(response);
}
/// <summary>

1
src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs

@ -99,7 +99,6 @@ namespace Squidex.Areas.Api.Controllers.Apps
public async Task<IActionResult> PostApp([FromBody] CreateAppDto request)
{
var command = SimpleMapper.Map(request, new CreateApp());
var context = await CommandBus.PublishAsync(command);
var result = context.Result<EntityCreatedResult<Guid>>();

2
src/Squidex/Areas/Api/Controllers/Apps/Models/AssignAppContributorDto.cs

@ -15,7 +15,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
public sealed class AssignAppContributorDto
{
/// <summary>
/// The id of the user to add to the app.
/// The id or email of the user to add to the app.
/// </summary>
[Required]
public string ContributorId { get; set; }

20
src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorAssignedDto.cs

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
namespace Squidex.Areas.Api.Controllers.Apps.Models
{
public sealed class ContributorAssignedDto
{
/// <summary>
/// The id of the user that has been assigned as contributor.
/// </summary>
[Required]
public string ContributorId { get; set; }
}
}

2
src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs

@ -268,7 +268,6 @@ namespace Squidex.Areas.Api.Controllers.Contents
await contentQuery.FindSchemaAsync(App, name);
var command = new UpdateContent { ContentId = id, Data = request.ToCleaned() };
var context = await CommandBus.PublishAsync(command);
var result = context.Result<ContentDataChangedResult>();
@ -301,7 +300,6 @@ namespace Squidex.Areas.Api.Controllers.Contents
await contentQuery.FindSchemaAsync(App, name);
var command = new PatchContent { ContentId = id, Data = request.ToCleaned() };
var context = await CommandBus.PublishAsync(command);
var result = context.Result<ContentDataChangedResult>();

9
src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs

@ -11,6 +11,7 @@ using NSwag.Annotations;
using Squidex.Areas.Api.Controllers.Schemas.Models;
using Squidex.Domain.Apps.Entities.Schemas.Commands;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Reflection;
using Squidex.Pipeline;
namespace Squidex.Areas.Api.Controllers.Schemas
@ -50,13 +51,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
[ApiCosts(1)]
public async Task<IActionResult> PostField(string app, string name, [FromBody] AddFieldDto request)
{
var command = new AddField
{
Name = request.Name,
Partitioning = request.Partitioning,
Properties = request.Properties.ToProperties()
};
var command = SimpleMapper.Map(request, new AddField { Properties = request.Properties.ToProperties() });
var context = await CommandBus.PublishAsync(command);
var result = context.Result<EntityCreatedResult<long>>();

1
src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs

@ -120,7 +120,6 @@ namespace Squidex.Areas.Api.Controllers.Schemas
public async Task<IActionResult> PostSchema(string app, [FromBody] CreateSchemaDto request)
{
var command = request.ToCommand();
var context = await CommandBus.PublishAsync(command);
var result = context.Result<EntityCreatedResult<Guid>>();

9
src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs

@ -11,13 +11,22 @@ namespace Squidex.Areas.Api.Controllers.Users.Models
{
public sealed class CreateUserDto
{
/// <summary>
/// The email of the user. Unique value.
/// </summary>
[Required]
[EmailAddress]
public string Email { get; set; }
/// <summary>
/// The display name (usually first name and last name) of the user.
/// </summary>
[Required]
public string DisplayName { get; set; }
/// <summary>
/// The password of the user.
/// </summary>
[Required]
public string Password { get; set; }
}

26
src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs

@ -0,0 +1,26 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
namespace Squidex.Areas.Api.Controllers.Users.Models
{
public sealed class PublicUserDto
{
/// <summary>
/// The id of the user.
/// </summary>
[Required]
public string Id { get; set; }
/// <summary>
/// The display name (usually first name and last name) of the user.
/// </summary>
[Required]
public string DisplayName { get; set; }
}
}

9
src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs

@ -11,13 +11,22 @@ namespace Squidex.Areas.Api.Controllers.Users.Models
{
public sealed class UpdateUserDto
{
/// <summary>
/// The email of the user. Unique value.
/// </summary>
[Required]
[EmailAddress]
public string Email { get; set; }
/// <summary>
/// The display name (usually first name and last name) of the user.
/// </summary>
[Required]
public string DisplayName { get; set; }
/// <summary>
/// The password of the user.
/// </summary>
public string Password { get; set; }
}
}

6
src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs

@ -11,10 +11,10 @@ namespace Squidex.Areas.Api.Controllers.Users.Models
{
public sealed class UserCreatedDto
{
/// <summary>
/// The id of the user.
/// </summary>
[Required]
public string Id { get; set; }
[Required]
public string PictureUrl { get; set; }
}
}

6
src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs

@ -23,12 +23,6 @@ namespace Squidex.Areas.Api.Controllers.Users.Models
[Required]
public string Email { get; set; }
/// <summary>
/// The url to the profile picture of the user.
/// </summary>
[Required]
public string PictureUrl { get; set; }
/// <summary>
/// The display name (usually first name and last name) of the user.
/// </summary>

4
src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs

@ -82,7 +82,7 @@ namespace Squidex.Areas.Api.Controllers.Users
{
var user = await userManager.CreateAsync(userFactory, request.Email, request.DisplayName, request.Password);
var response = new UserCreatedDto { Id = user.Id, PictureUrl = user.PictureUrl() };
var response = new UserCreatedDto { Id = user.Id };
return Ok(response);
}
@ -129,7 +129,7 @@ namespace Squidex.Areas.Api.Controllers.Users
private static UserDto Map(IUser user)
{
return SimpleMapper.Map(user, new UserDto { DisplayName = user.DisplayName(), PictureUrl = user.PictureUrl() });
return SimpleMapper.Map(user, new UserDto { DisplayName = user.DisplayName() });
}
private bool IsSelf(string id)

8
src/Squidex/Areas/Api/Controllers/Users/UsersController.cs

@ -65,12 +65,12 @@ namespace Squidex.Areas.Api.Controllers.Users
[ApiAuthorize]
[HttpGet]
[Route("users/")]
[ProducesResponseType(typeof(UserDto[]), 200)]
[ProducesResponseType(typeof(PublicUserDto[]), 200)]
public async Task<IActionResult> GetUsers(string query)
{
var entities = await userManager.QueryByEmailAsync(query ?? string.Empty);
var models = entities.Select(x => SimpleMapper.Map(x, new UserDto { DisplayName = x.DisplayName(), PictureUrl = x.PictureUrl() })).ToArray();
var models = entities.Where(x => !x.IsHidden()).Select(x => SimpleMapper.Map(x, new UserDto { DisplayName = x.DisplayName() })).ToArray();
return Ok(models);
}
@ -86,7 +86,7 @@ namespace Squidex.Areas.Api.Controllers.Users
[ApiAuthorize]
[HttpGet]
[Route("users/{id}/")]
[ProducesResponseType(typeof(UserDto), 200)]
[ProducesResponseType(typeof(PublicUserDto), 200)]
public async Task<IActionResult> GetUser(string id)
{
var entity = await userManager.FindByIdAsync(id);
@ -96,7 +96,7 @@ namespace Squidex.Areas.Api.Controllers.Users
return NotFound();
}
var response = SimpleMapper.Map(entity, new UserDto { DisplayName = entity.DisplayName(), PictureUrl = entity.PictureUrl() });
var response = SimpleMapper.Map(entity, new UserDto { DisplayName = entity.DisplayName() });
return Ok(response);
}

2
src/Squidex/Areas/IdentityServer/Controllers/Profile/ChangeProfileModel.cs

@ -17,5 +17,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
[Required(ErrorMessage = "DisplayName is required.")]
public string DisplayName { get; set; }
public bool IsHidden { get; set; }
}
}

3
src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs

@ -83,7 +83,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
[Route("/account/profile/update/")]
public Task<IActionResult> UpdateProfile(ChangeProfileModel model)
{
return MakeChangeAsync(user => userManager.UpdateAsync(user, model.Email, model.DisplayName),
return MakeChangeAsync(user => userManager.UpdateAsync(user, model.Email, model.DisplayName, model.IsHidden),
"Account updated successfully.");
}
@ -195,6 +195,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
ExternalLogins = user.Logins,
ExternalProviders = externalProviders,
DisplayName = user.DisplayName(),
IsHidden = user.IsHidden(),
HasPassword = await userManager.HasPasswordAsync(user),
HasPasswordAuth = identityOptions.Value.AllowPasswordAuth,
SuccessMessage = successMessage

2
src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileVM.cs

@ -22,6 +22,8 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
public string SuccessMessage { get; set; }
public bool IsHidden { get; set; }
public bool HasPassword { get; set; }
public bool HasPasswordAuth { get; set; }

12
src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml

@ -52,7 +52,7 @@
</div>
}
<input type="email" ap class="form-control" asp-for="Email" name="email" id="email" />
<input type="email" ap class="form-control" asp-for="Email" id="email" />
</div>
<div class="form-group">
@ -65,7 +65,15 @@
</div>
}
<input type="text" class="form-control" asp-for="DisplayName" name="displayName" id="displayName"/>
<input type="text" class="form-control" asp-for="DisplayName" id="displayName"/>
</div>
<div class="form-group">
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="IsHidden" id="hidden" />
<label class="form-check-label" for="hidden">Do not show my profile to other users</label>
</div>
</div>
<button type="submit" class="btn btn-primary">Save</button>

1
src/Squidex/app/features/administration/pages/users/user-page.component.ts

@ -68,7 +68,6 @@ export class UserPageComponent implements OnInit {
created.id,
requestDto.email,
requestDto.displayName,
created.pictureUrl!,
false);
this.ctx.notifyInfo('User created successfully.');

14
src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html

@ -27,16 +27,13 @@
<td class="cell-auto">
<span class="user-name table-cell">{{contributor.contributorId | sqxUserName}}</span>
</td>
<td class="cell-auto">
<span class="user-email table-cell">{{contributor.contributorId | sqxUserEmail}}</span>
</td>
<td class="cell-time">
<select class="form-control" [ngModel]="contributor.permission" (ngModelChange)="changePermission(contributor, $event)" [disabled]="userId === contributor.contributorId">
<option *ngFor="let permission of usersPermissions" [ngValue]="permission">{{permission}}</option>
</select>
</td>
<td class="cell-actions">
<button type="button" class="btn btn-link btn-danger" [disabled]="userId === contributor.contributorId" (click)="removeContributor(contributor)">
<button *ngIf="ctx.userId !== contributor.contributorId" type="button" class="btn btn-link btn-danger" (click)="removeContributor(contributor)">
<i class="icon-bin2"></i>
</button>
</td>
@ -50,12 +47,13 @@
<form [formGroup]="addContributorForm" (ngSubmit)="assignContributor()">
<div class="row no-gutters">
<div class="col">
<sqx-autocomplete [source]="usersDataSource" formControlName="user" [inputName]="'contributor'" placeholder="Find existing user" displayProperty="email">
<sqx-autocomplete [source]="usersDataSource" formControlName="user" [inputName]="'contributor'" placeholder="Find existing user" displayProperty="displayName">
<ng-template let-user="$implicit">
<img class="user-picture autocomplete-user-picture" [attr.src]="user | sqxUserDtoPicture" />
<span class="autocomplete-user">
<img class="user-picture autocomplete-user-picture" [attr.src]="user | sqxUserDtoPicture" />
<span class="user-name autocomplete-user-name">{{user.displayName}}</span>
<span class="user-email autocomplete-user-email">{{user.email}}</span>
<span class="user-name autocomplete-user-name">{{user.displayName}}</span>
</span>
</ng-template>
</sqx-autocomplete>
</div>

11
src/Squidex/app/features/settings/pages/contributors/contributors-page.component.scss

@ -2,14 +2,11 @@
@import '_mixins';
.autocomplete-user {
&-picture {
float: left;
margin-top: .4rem;
& {
@include truncate;
}
&-name,
&-email {
@include truncate;
margin-left: 3rem;
&-name {
margin-left: .25rem;
}
}

13
src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts

@ -16,6 +16,7 @@ import {
AppContributorsService,
AutocompleteSource,
HistoryChannelUpdated,
PublicUserDto,
UsersService
} from 'shared';
@ -110,16 +111,20 @@ export class ContributorsPageComponent implements OnInit {
}
public assignContributor() {
const requestDto = new AppContributorDto(this.addContributorForm.controls['user'].value.id, 'Editor');
let value: any = this.addContributorForm.controls['user'].value;
if (value instanceof PublicUserDto) {
value = value.id;
}
const requestDto = new AppContributorDto(value, 'Editor');
this.appContributorsService.postContributor(this.ctx.appName, requestDto, this.appContributors.version)
.subscribe(dto => {
this.updateContributors(this.appContributors.addContributor(requestDto, dto.version));
this.updateContributors(this.appContributors.addContributor(new AppContributorDto(dto.payload.contributorId, requestDto.permission), dto.version));
this.resetContributorForm();
}, error => {
this.ctx.notifyError(error);
this.resetContributorForm();
});
}

7
src/Squidex/app/framework/angular/autocomplete.component.ts

@ -61,15 +61,18 @@ export class AutocompleteComponent implements ControlValueAccessor, OnDestroy, O
public ngOnInit() {
this.subscription =
this.queryInput.valueChanges
.do(query => {
this.callChange(query);
})
.map(query => <string>query)
.map(query => query ? query.trim() : query)
.distinctUntilChanged()
.debounceTime(200)
.do(query => {
if (!query) {
this.reset();
}
})
.distinctUntilChanged()
.debounceTime(200)
.filter(query => !!query && !!this.source)
.switchMap(query => this.source.find(query)).catch(error => Observable.of([]))
.subscribe(items => {

40
src/Squidex/app/shared/components/pipes.ts

@ -10,7 +10,7 @@ import { Observable, Subscription } from 'rxjs';
import { ApiUrlConfig, MathHelper } from 'framework';
import { UserDto, UsersProviderService } from './../declarations-base';
import { PublicUserDto, UsersProviderService } from './../declarations-base';
class UserAsyncPipe implements OnDestroy {
private lastUserId: string;
@ -88,42 +88,6 @@ export class UserNameRefPipe extends UserAsyncPipe implements PipeTransform {
}
}
@Pipe({
name: 'sqxUserEmail',
pure: false
})
export class UserEmailPipe extends UserAsyncPipe implements PipeTransform {
constructor(users: UsersProviderService, changeDetector: ChangeDetectorRef) {
super(users, changeDetector);
}
public transform(userId: string): string | null {
return super.transformInternal(userId, users => users.getUser(userId).map(u => u.email));
}
}
@Pipe({
name: 'sqxUserEmailRef',
pure: false
})
export class UserEmailRefPipe extends UserAsyncPipe implements PipeTransform {
constructor(users: UsersProviderService, changeDetector: ChangeDetectorRef) {
super(users, changeDetector);
}
public transform(userId: string): string | null {
return super.transformInternal(userId, users => {
const parts = userId.split(':');
if (parts[0] === 'subject') {
return users.getUser(parts[1]).map(u => u.email);
} else {
return Observable.of(null);
}
});
}
}
@Pipe({
name: 'sqxUserDtoPicture',
pure: false
@ -134,7 +98,7 @@ export class UserDtoPicture implements PipeTransform {
) {
}
public transform(user: UserDto): string | null {
public transform(user: PublicUserDto): string | null {
return this.apiUrl.buildUrl(`api/users/${user.id}/picture`);
}
}

6
src/Squidex/app/shared/module.ts

@ -53,8 +53,6 @@ import {
UnsetAppGuard,
UsagesService,
UserDtoPicture,
UserEmailPipe,
UserEmailRefPipe,
UserNamePipe,
UserNameRefPipe,
UserIdPicturePipe,
@ -83,8 +81,6 @@ import {
LanguageSelectorComponent,
MarkdownEditorComponent,
UserDtoPicture,
UserEmailPipe,
UserEmailRefPipe,
UserIdPicturePipe,
UserNamePipe,
UserNameRefPipe,
@ -104,8 +100,6 @@ import {
LanguageSelectorComponent,
MarkdownEditorComponent,
UserDtoPicture,
UserEmailPipe,
UserEmailRefPipe,
UserIdPicturePipe,
UserNamePipe,
UserNameRefPipe,

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

@ -14,7 +14,8 @@ import {
AppContributorDto,
AppContributorsDto,
AppContributorsService,
Version
Version,
ContributorAssignedDto
} from './../';
describe('AppContributorsDto', () => {
@ -122,14 +123,20 @@ describe('AppContributorsService', () => {
const dto = new AppContributorDto('123', 'Owner');
appContributorsService.postContributor('my-app', dto, version).subscribe();
let contributorAssignedDto: ContributorAssignedDto | null = null;
appContributorsService.postContributor('my-app', dto, version).subscribe(result => {
contributorAssignedDto = result.payload;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/contributors');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toEqual(version.value);
req.flush({});
req.flush({ contributorId: '123' });
expect(contributorAssignedDto!.contributorId).toEqual('123');
}));
it('should make delete request to remove contributor',

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

@ -58,6 +58,13 @@ export class AppContributorDto {
}
}
export class ContributorAssignedDto {
constructor(
public readonly contributorId: string
) {
}
}
@Injectable()
export class AppContributorsService {
constructor(
@ -87,10 +94,17 @@ export class AppContributorsService {
.pretifyError('Failed to load contributors. Please reload.');
}
public postContributor(appName: string, dto: AppContributorDto, version: Version): Observable<Versioned<any>> {
public postContributor(appName: string, dto: AppContributorDto, version: Version): Observable<Versioned<ContributorAssignedDto>> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/contributors`);
return HTTP.postVersioned(this.http, url, dto, version)
.map(response => {
const body: any = response.payload.body;
const result = new ContributorAssignedDto(body.contributorId);
return new Versioned(response.version, result);
})
.do(() => {
this.analytics.trackEvent('Contributor', 'Configured', appName);
})

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

@ -11,7 +11,7 @@ import { IMock, Mock, Times } from 'typemoq';
import {
AuthService,
Profile,
UserDto,
PublicUserDto,
UsersProviderService,
UsersService
} from './../';
@ -28,12 +28,12 @@ describe('UsersProviderService', () => {
});
it('should return users service when user not cached', () => {
const user = new UserDto('123', 'mail@domain.com', 'User1', 'path/to/image', true);
const user = new PublicUserDto('123', 'User1');
usersService.setup(x => x.getUser('123'))
.returns(() => Observable.of(user)).verifiable(Times.once());
let resultingUser: UserDto | null = null;
let resultingUser: PublicUserDto | null = null;
usersProviderService.getUser('123').subscribe(result => {
resultingUser = result;
@ -45,14 +45,14 @@ describe('UsersProviderService', () => {
});
it('should return provide user from cache', () => {
const user = new UserDto('123', 'mail@domain.com', 'User1', 'path/to/image', true);
const user = new PublicUserDto('123', 'User1');
usersService.setup(x => x.getUser('123'))
.returns(() => Observable.of(user)).verifiable(Times.once());
usersProviderService.getUser('123');
let resultingUser: UserDto | null = null;
let resultingUser: PublicUserDto | null = null;
usersProviderService.getUser('123').subscribe(result => {
resultingUser = result;
@ -64,7 +64,7 @@ describe('UsersProviderService', () => {
});
it('should return me when user is current user', () => {
const user = new UserDto('123', 'mail@domain.com', 'User1', 'path/to/image', true);
const user = new PublicUserDto('123', 'User1');
authService.setup(x => x.user)
.returns(() => new Profile(<any>{ profile: { sub: '123'}}));
@ -72,13 +72,13 @@ describe('UsersProviderService', () => {
usersService.setup(x => x.getUser('123'))
.returns(() => Observable.of(user)).verifiable(Times.once());
let resultingUser: UserDto | null = null;
let resultingUser: PublicUserDto | null = null;
usersProviderService.getUser('123').subscribe(result => {
resultingUser = result;
}).unsubscribe();
expect(resultingUser).toEqual(new UserDto('123', 'mail@domain.com', 'Me', 'path/to/image', true));
expect(resultingUser).toEqual(new PublicUserDto('123', 'Me'));
usersService.verifyAll();
});
@ -90,13 +90,13 @@ describe('UsersProviderService', () => {
usersService.setup(x => x.getUser('123'))
.returns(() => Observable.throw('NOT FOUND')).verifiable(Times.once());
let resultingUser: UserDto | null = null;
let resultingUser: PublicUserDto | null = null;
usersProviderService.getUser('123').subscribe(result => {
resultingUser = result;
}).unsubscribe();
expect(resultingUser).toEqual(new UserDto('NOT FOUND', 'NOT FOUND', 'NOT FOUND', null, false));
expect(resultingUser).toEqual(new PublicUserDto('unknown', 'unknown'));
usersService.verifyAll();
});

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

@ -8,13 +8,13 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { UserDto, UsersService } from './users.service';
import { PublicUserDto, UsersService } from './users.service';
import { AuthService } from './auth.service';
@Injectable()
export class UsersProviderService {
private readonly caches: { [id: string]: Observable<UserDto> } = {};
private readonly caches: { [id: string]: Observable<PublicUserDto> } = {};
constructor(
private readonly usersService: UsersService,
@ -22,14 +22,14 @@ export class UsersProviderService {
) {
}
public getUser(id: string, me: string | null = 'Me'): Observable<UserDto> {
public getUser(id: string, me: string | null = 'Me'): Observable<PublicUserDto> {
let result = this.caches[id];
if (!result) {
const request =
this.usersService.getUser(id)
.catch(error => {
return Observable.of(new UserDto('NOT FOUND', 'NOT FOUND', 'NOT FOUND', null, false));
return Observable.of(new PublicUserDto('Unknown', 'Unknown'));
})
.publishLast();
@ -41,7 +41,7 @@ export class UsersProviderService {
return result
.map(dto => {
if (me && this.authService.user && dto.id === this.authService.user.id) {
dto = new UserDto(dto.id, dto.email, me, dto.pictureUrl, dto.isLocked);
dto = new PublicUserDto(dto.id, me);
}
return dto;
}).share();

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

@ -11,6 +11,7 @@ import { inject, TestBed } from '@angular/core/testing';
import {
ApiUrlConfig,
CreateUserDto,
PublicUserDto,
UpdateUserDto,
UserDto,
UserManagementService,
@ -20,7 +21,7 @@ import {
describe('UserDto', () => {
it('should update email and display name property when unlocking', () => {
const user_1 = new UserDto('1', 'sebastian@squidex.io', 'Sebastian', 'picture', true);
const user_1 = new UserDto('1', 'sebastian@squidex.io', 'Sebastian', true);
const user_2 = user_1.update('qaisar@squidex.io', 'Qaisar');
expect(user_2.email).toEqual('qaisar@squidex.io');
@ -28,14 +29,14 @@ describe('UserDto', () => {
});
it('should update isLocked property when locking', () => {
const user_1 = new UserDto('1', 'sebastian@squidex.io', 'Sebastian', 'picture', false);
const user_1 = new UserDto('1', 'sebastian@squidex.io', 'Sebastian', false);
const user_2 = user_1.lock();
expect(user_2.isLocked).toBeTruthy();
});
it('should update isLocked property when unlocking', () => {
const user_1 = new UserDto('1', 'sebastian@squidex.io', 'Sebastian', 'picture', true);
const user_1 = new UserDto('1', 'sebastian@squidex.io', 'Sebastian', true);
const user_2 = user_1.unlock();
expect(user_2.isLocked).toBeFalsy();
@ -62,7 +63,7 @@ describe('UsersService', () => {
it('should make get request to get many users',
inject([UsersService, HttpTestingController], (usersService: UsersService, httpMock: HttpTestingController) => {
let users: UserDto[] | null = null;
let users: PublicUserDto[] | null = null;
usersService.getUsers().subscribe(result => {
users = result;
@ -76,31 +77,25 @@ describe('UsersService', () => {
req.flush([
{
id: '123',
email: 'mail1@domain.com',
displayName: 'User1',
pictureUrl: 'path/to/image1',
isLocked: true
displayName: 'User1'
},
{
id: '456',
email: 'mail2@domain.com',
displayName: 'User2',
pictureUrl: 'path/to/image2',
isLocked: true
displayName: 'User2'
}
]);
expect(users).toEqual(
[
new UserDto('123', 'mail1@domain.com', 'User1', 'path/to/image1', true),
new UserDto('456', 'mail2@domain.com', 'User2', 'path/to/image2', true)
new UserDto('123', 'mail1@domain.com', 'User1', true),
new UserDto('456', 'mail2@domain.com', 'User2', true)
]);
}));
it('should make get request with query to get many users',
inject([UsersService, HttpTestingController], (usersService: UsersService, httpMock: HttpTestingController) => {
let users: UserDto[] | null = null;
let users: PublicUserDto[] | null = null;
usersService.getUsers('my-query').subscribe(result => {
users = result;
@ -114,31 +109,25 @@ describe('UsersService', () => {
req.flush([
{
id: '123',
email: 'mail1@domain.com',
displayName: 'User1',
pictureUrl: 'path/to/image1',
isLocked: true
displayName: 'User1'
},
{
id: '456',
email: 'mail2@domain.com',
displayName: 'User2',
pictureUrl: 'path/to/image2',
isLocked: true
displayName: 'User2'
}
]);
expect(users).toEqual(
[
new UserDto('123', 'mail1@domain.com', 'User1', 'path/to/image1', true),
new UserDto('456', 'mail2@domain.com', 'User2', 'path/to/image2', true)
new PublicUserDto('123', 'User1'),
new PublicUserDto('456', 'User2')
]);
}));
it('should make get request to get single user',
inject([UsersService, HttpTestingController], (usersService: UsersService, httpMock: HttpTestingController) => {
let user: UserDto | null = null;
let user: PublicUserDto | null = null;
usersService.getUser('123').subscribe(result => {
user = result;
@ -149,15 +138,9 @@ describe('UsersService', () => {
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({
id: '123',
email: 'mail1@domain.com',
displayName: 'User1',
pictureUrl: 'path/to/image1',
isLocked: true
});
req.flush({ id: '123', displayName: 'User1' });
expect(user).toEqual(new UserDto('123', 'mail1@domain.com', 'User1', 'path/to/image1', true));
expect(user).toEqual(new PublicUserDto('123', 'User1'));
}));
});
@ -199,14 +182,12 @@ describe('UserManagementService', () => {
id: '123',
email: 'mail1@domain.com',
displayName: 'User1',
pictureUrl: 'path/to/image1',
isLocked: true
},
{
id: '456',
email: 'mail2@domain.com',
displayName: 'User2',
pictureUrl: 'path/to/image2',
isLocked: true
}
]
@ -214,8 +195,8 @@ describe('UserManagementService', () => {
expect(users).toEqual(
new UsersDto(100, [
new UserDto('123', 'mail1@domain.com', 'User1', 'path/to/image1', true),
new UserDto('456', 'mail2@domain.com', 'User2', 'path/to/image2', true)
new UserDto('123', 'mail1@domain.com', 'User1', true),
new UserDto('456', 'mail2@domain.com', 'User2', true)
]));
}));
@ -255,8 +236,8 @@ describe('UserManagementService', () => {
expect(users).toEqual(
new UsersDto(100, [
new UserDto('123', 'mail1@domain.com', 'User1', 'path/to/image1', true),
new UserDto('456', 'mail2@domain.com', 'User2', 'path/to/image2', true)
new UserDto('123', 'mail1@domain.com', 'User1', true),
new UserDto('456', 'mail2@domain.com', 'User2', true)
]));
}));
@ -278,11 +259,10 @@ describe('UserManagementService', () => {
id: '123',
email: 'mail1@domain.com',
displayName: 'User1',
pictureUrl: 'path/to/image1',
isLocked: true
});
expect(user).toEqual(new UserDto('123', 'mail1@domain.com', 'User1', 'path/to/image1', true));
expect(user).toEqual(new UserDto('123', 'mail1@domain.com', 'User1', true));
}));
it('should make post request to create user',
@ -301,9 +281,9 @@ describe('UserManagementService', () => {
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({ id: '123', pictureUrl: 'path/to/image1' });
req.flush({ id: '123' });
expect(user).toEqual(new UserDto('123', dto.email, dto.displayName, 'path/to/image1', false));
expect(user).toEqual(new UserDto('123', dto.email, dto.displayName, false));
}));
it('should make put request to update user',

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

@ -26,21 +26,20 @@ export class UserDto {
public readonly id: string,
public readonly email: string,
public readonly displayName: string,
public readonly pictureUrl: string | null,
public readonly isLocked: boolean
) {
}
public update(email: string, displayName: string): UserDto {
return new UserDto(this.id, email, displayName, this.pictureUrl, this.isLocked);
return new UserDto(this.id, email, displayName, this.isLocked);
}
public lock(): UserDto {
return new UserDto(this.id, this.email, this.displayName, this.pictureUrl, true);
return new UserDto(this.id, this.email, this.displayName, true);
}
public unlock(): UserDto {
return new UserDto(this.id, this.email, this.displayName, this.pictureUrl, false);
return new UserDto(this.id, this.email, this.displayName, false);
}
}
@ -62,6 +61,14 @@ export class UpdateUserDto {
}
}
export class PublicUserDto {
constructor(
public readonly id: string,
public readonly displayName: string
) {
}
}
@Injectable()
export class UsersService {
constructor(
@ -70,7 +77,7 @@ export class UsersService {
) {
}
public getUsers(query?: string): Observable<UserDto[]> {
public getUsers(query?: string): Observable<PublicUserDto[]> {
const url = this.apiUrl.buildUrl(`api/users?query=${query || ''}`);
return HTTP.getVersioned<any>(this.http, url)
@ -80,30 +87,24 @@ export class UsersService {
const items: any[] = body;
return items.map(item => {
return new UserDto(
return new PublicUserDto(
item.id,
item.email,
item.displayName,
item.pictureUrl,
item.isLocked);
item.displayName);
});
})
.pretifyError('Failed to load users. Please reload.');
}
public getUser(id: string): Observable<UserDto> {
public getUser(id: string): Observable<PublicUserDto> {
const url = this.apiUrl.buildUrl(`api/users/${id}`);
return HTTP.getVersioned<any>(this.http, url)
.map(response => {
const body = response.payload.body;
return new UserDto(
return new PublicUserDto(
body.id,
body.email,
body.displayName,
body.pictureUrl,
body.isLocked);
body.displayName);
})
.pretifyError('Failed to load user. Please reload.');
}
@ -131,7 +132,6 @@ export class UserManagementService {
item.id,
item.email,
item.displayName,
item.pictureUrl,
item.isLocked);
});
@ -151,7 +151,6 @@ export class UserManagementService {
body.id,
body.email,
body.displayName,
body.pictureUrl,
body.isLocked);
})
.pretifyError('Failed to load user. Please reload.');
@ -164,7 +163,7 @@ export class UserManagementService {
.map(response => {
const body = response.payload.body;
return new UserDto(body.id, dto.email, dto.displayName, body.pictureUrl, false);
return new UserDto(body.id, dto.email, dto.displayName, false);
})
.pretifyError('Failed to create user. Please reload.');
}

12
tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs

@ -27,6 +27,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
private readonly IAppPlansProvider appPlansProvider = A.Fake<IAppPlansProvider>();
private readonly IAppPlanBillingManager appPlansBillingManager = A.Fake<IAppPlanBillingManager>();
private readonly IUser user = A.Fake<IUser>();
private readonly IUserResolver userResolver = A.Fake<IUserResolver>();
private readonly string contributorId = Guid.NewGuid().ToString();
private readonly string clientId = "client";
@ -46,10 +47,13 @@ namespace Squidex.Domain.Apps.Entities.Apps
public AppGrainTests()
{
A.CallTo(() => appProvider.GetAppAsync(AppName))
.Returns((IAppEntity)null);
.Returns((IAppEntity)null);
A.CallTo(() => userResolver.FindByIdAsync(contributorId))
.Returns(A.Fake<IUser>());
A.CallTo(() => user.Id)
.Returns(contributorId);
A.CallTo(() => userResolver.FindByIdOrEmailAsync(contributorId))
.Returns(user);
initialPatterns = new InitialPatterns
{
@ -163,7 +167,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
var result = await sut.ExecuteAsync(CreateCommand(command));
result.ShouldBeEquivalent(new EntitySavedResult(5));
result.ShouldBeEquivalent(EntityCreatedResult.Create(contributorId, 5));
Assert.Equal(AppContributorPermission.Editor, sut.Snapshot.Contributors[contributorId]);

34
tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppContributorsTests.cs

@ -20,14 +20,29 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
{
public class GuardAppContributorsTests
{
private readonly IUser user1 = A.Fake<IUser>();
private readonly IUser user2 = A.Fake<IUser>();
private readonly IUser user3 = A.Fake<IUser>();
private readonly IUserResolver users = A.Fake<IUserResolver>();
private readonly IAppLimitsPlan appPlan = A.Fake<IAppLimitsPlan>();
private readonly AppContributors contributors_0 = AppContributors.Empty;
public GuardAppContributorsTests()
{
A.CallTo(() => users.FindByIdAsync(A<string>.Ignored))
.Returns(A.Fake<IUser>());
A.CallTo(() => user1.Id).Returns("1");
A.CallTo(() => user2.Id).Returns("2");
A.CallTo(() => user3.Id).Returns("3");
A.CallTo(() => users.FindByIdOrEmailAsync("1")).Returns(user1);
A.CallTo(() => users.FindByIdOrEmailAsync("2")).Returns(user2);
A.CallTo(() => users.FindByIdOrEmailAsync("3")).Returns(user3);
A.CallTo(() => users.FindByIdOrEmailAsync("1@email.com")).Returns(user1);
A.CallTo(() => users.FindByIdOrEmailAsync("2@email.com")).Returns(user2);
A.CallTo(() => users.FindByIdOrEmailAsync("3@email.com")).Returns(user3);
A.CallTo(() => users.FindByIdOrEmailAsync("notfound"))
.Returns(Task.FromResult<IUser>(null));
A.CallTo(() => appPlan.MaxContributors)
.Returns(10);
@ -62,10 +77,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
[Fact]
public Task CanAssign_should_throw_exception_if_user_not_found()
{
A.CallTo(() => users.FindByIdAsync(A<string>.Ignored))
.Returns(Task.FromResult<IUser>(null));
var command = new AssignContributor { ContributorId = "1", Permission = (AppContributorPermission)10 };
var command = new AssignContributor { ContributorId = "notfound", Permission = (AppContributorPermission)10 };
return Assert.ThrowsAsync<ValidationException>(() => GuardAppContributors.CanAssign(contributors_0, command, users, appPlan));
}
@ -84,6 +96,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
return Assert.ThrowsAsync<ValidationException>(() => GuardAppContributors.CanAssign(contributors_2, command, users, appPlan));
}
[Fact]
public async Task CanAssign_assign_if_if_user_added_by_email()
{
var command = new AssignContributor { ContributorId = "1@email.com" };
await GuardAppContributors.CanAssign(contributors_0, command, users, appPlan);
Assert.Equal("1", command.ContributorId);
}
[Fact]
public Task CanAssign_should_not_throw_exception_if_user_found()
{

2
tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppTests.cs

@ -27,7 +27,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
A.CallTo(() => apps.GetAppAsync("new-app"))
.Returns(Task.FromResult<IAppEntity>(null));
A.CallTo(() => users.FindByIdAsync(A<string>.Ignored))
A.CallTo(() => users.FindByIdOrEmailAsync(A<string>.Ignored))
.Returns(A.Fake<IUser>());
A.CallTo(() => appPlans.GetPlan("free"))

Loading…
Cancel
Save