Skip to content

Commit

Permalink
User typeahead enabled for non-admin project managers (#1237)
Browse files Browse the repository at this point in the history
Add a new usersICanSee GQL query, which looks for:

* All users in one of my orgs
* All users in one of the projects I manage
* All users in one of the non-confidential projects I'm a member of

Note that projects with `isConfidential = null` are treated as public
(non-confidential) by this query.

The "Add Project Member" typeahead is now updated to use that query,
which allows project managers who aren't site admins to use it to find
users whose email address they don't know. E.g. if Test Manager has Test
Editor as part of project A, and he also manages project B, then when he
clicks on "Add Members" in project B, he can type Test Editor's name and
select him to add, without knowing his email address.

---------

Co-authored-by: Tim Haasdyk <[email protected]>
  • Loading branch information
rmunn and myieye authored Nov 26, 2024
1 parent a598cae commit 7eaf4f8
Show file tree
Hide file tree
Showing 13 changed files with 468 additions and 25 deletions.
9 changes: 9 additions & 0 deletions backend/LexBoxApi/GraphQL/LexQueries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,15 @@ public IQueryable<User> UsersInMyOrg(LexBoxDbContext context, LoggedInContext lo
return context.Users.Where(u => u.Organizations.Any(orgMember => myOrgIds.Contains(orgMember.OrgId)));
}

[UseOffsetPaging]
[UseProjection]
[UseFiltering]
[UseSorting]
public IQueryable<User> UsersICanSee(UserService userService, LoggedInContext loggedInContext)
{
return userService.UserQueryForTypeahead(loggedInContext.User);
}

[UseProjection]
[GraphQLType<OrgByIdGqlConfiguration>]
public async Task<Organization?> OrgById(LexBoxDbContext dbContext,
Expand Down
1 change: 1 addition & 0 deletions backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public static async Task GenerateGqlSchema(string[] args)
.AddScoped<LexBoxDbContext>()
.AddScoped<IPermissionService, PermissionService>()
.AddScoped<ProjectService>()
.AddScoped<UserService>()
.AddScoped<LexAuthService>()
.AddLexGraphQL(builder.Environment, true);
var host = builder.Build();
Expand Down
18 changes: 16 additions & 2 deletions backend/LexBoxApi/Services/UserService.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
using System.Net.Mail;
using LexBoxApi.Auth;
using LexBoxApi.Services.Email;
using LexCore.Auth;
using LexCore.Entities;
using LexCore.Exceptions;
using LexData;
using Microsoft.EntityFrameworkCore;

namespace LexBoxApi.Services;

public class UserService(LexBoxDbContext dbContext, IEmailService emailService, LexAuthService lexAuthService)
public class UserService(LexBoxDbContext dbContext, IEmailService emailService)
{
public async Task ForgotPassword(string email)
{
Expand Down Expand Up @@ -83,4 +84,17 @@ public static (string name, string? email, string? username) ExtractNameAndAddre
}
return (name, email, username);
}

public IQueryable<User> UserQueryForTypeahead(LexAuthUser user)
{
var myOrgIds = user.Orgs.Select(o => o.OrgId).ToList();
var myProjectIds = user.Projects.Select(p => p.ProjectId).ToList();
var myManagedProjectIds = user.Projects.Where(p => p.Role == ProjectRole.Manager).Select(p => p.ProjectId).ToList();
return dbContext.Users.Where(u =>
u.Id == user.Id ||
u.Organizations.Any(orgMember => myOrgIds.Contains(orgMember.OrgId)) ||
u.Projects.Any(projMember =>
myManagedProjectIds.Contains(projMember.ProjectId) ||
(projMember.Project != null && projMember.Project.IsConfidential != true && myProjectIds.Contains(projMember.ProjectId))));
}
}
98 changes: 98 additions & 0 deletions backend/Testing/ApiTests/UsersICanSeeQueryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using System.Text.Json.Nodes;
using Shouldly;

Check failure on line 2 in backend/Testing/ApiTests/UsersICanSeeQueryTests.cs

View workflow job for this annotation

GitHub Actions / Build API / publish-api

The type or namespace name 'Shouldly' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 2 in backend/Testing/ApiTests/UsersICanSeeQueryTests.cs

View workflow job for this annotation

GitHub Actions / Build API / publish-api

The type or namespace name 'Shouldly' could not be found (are you missing a using directive or an assembly reference?)
using Testing.Services;

namespace Testing.ApiTests;

[Trait("Category", "Integration")]
public class UsersICanSeeQueryTests : ApiTestBase
{
private async Task<JsonObject> QueryUsersICanSee(bool expectGqlError = false)
{
var json = await ExecuteGql(
$$"""
query {
usersICanSee(take: 10) {
totalCount
items {
id
name
}
}
}
""",
expectGqlError, expectSuccessCode: false);
return json;
}

private async Task AddUserToProject(Guid projectId, string username)
{
await ExecuteGql(
$$"""
mutation {
addProjectMember(input: {
projectId: "{{projectId}}",
usernameOrEmail: "{{username}}",
role: EDITOR,
canInvite: false
}) {
project {
id
}
errors {
__typename
... on Error {
message
}
}
}
}
""");
}

private JsonArray GetUsers(JsonObject json)
{
var users = json["data"]!["usersICanSee"]!["items"]!.AsArray();
users.ShouldNotBeNull();
return users;
}

private void MustHaveUser(JsonArray users, string userName)
{
users.ShouldNotBeNull().ShouldNotBeEmpty();
users.ShouldContain(node => node!["name"]!.GetValue<string>() == userName,
"user list " + users.ToJsonString());
}

private void MustNotHaveUser(JsonArray users, string userName)
{
users.ShouldNotBeNull().ShouldNotBeEmpty();
users.ShouldNotContain(node => node!["name"]!.GetValue<string>() == userName,
"user list " + users.ToJsonString());
}

[Fact]
public async Task ManagerCanSeeProjectMembersOfAllProjects()
{
await LoginAs("manager");
await using var project = await this.RegisterProjectInLexBox(Utils.GetNewProjectConfig(isConfidential: true));
//refresh jwt
await LoginAs("manager");
await AddUserToProject(project.Id, "[email protected]");
var json = GetUsers(await QueryUsersICanSee());
MustHaveUser(json, "Qa Admin");
}

[Fact]
public async Task MemberCanSeeNotProjectMembersOfConfidentialProjects()
{
await LoginAs("manager");
await using var project = await this.RegisterProjectInLexBox(Utils.GetNewProjectConfig(isConfidential: true));
//refresh jwt
await LoginAs("manager");
await AddUserToProject(project.Id, "[email protected]");
await LoginAs("editor");
var json = GetUsers(await QueryUsersICanSee());
MustNotHaveUser(json, "Qa Admin");
}
}
39 changes: 39 additions & 0 deletions backend/Testing/Fixtures/TempProjectWithoutRepo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using LexCore.Entities;
using LexData;
using Testing.Services;

namespace Testing.Fixtures;

public class TempProjectWithoutRepo(LexBoxDbContext dbContext, Project project) : IAsyncDisposable
{
public Project Project => project;
public static async Task<TempProjectWithoutRepo> Create(LexBoxDbContext dbContext, bool isConfidential = false, Guid? managerId = null)
{
var config = Utils.GetNewProjectConfig(isConfidential: isConfidential);
var project = new Project
{
Name = config.Name,
Code = config.Code,
IsConfidential = config.IsConfidential,
LastCommit = null,
Organizations = [],
Users = [],
RetentionPolicy = RetentionPolicy.Test,
Type = ProjectType.FLEx,
Id = config.Id,
};
if (managerId is Guid id)
{
project.Users.Add(new ProjectUsers { ProjectId = project.Id, UserId = id, Role = ProjectRole.Manager });
}
dbContext.Add(project);
await dbContext.SaveChangesAsync();
return new TempProjectWithoutRepo(dbContext, project);
}

public async ValueTask DisposeAsync()
{
dbContext.Remove(project);
await dbContext.SaveChangesAsync();
}
}
Loading

0 comments on commit 7eaf4f8

Please sign in to comment.