Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User typeahead enabled for non-admin project managers #1237

Merged
merged 19 commits into from
Nov 26, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions backend/LexBoxApi/GraphQL/LexQueries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using LexBoxApi.Auth;
using LexBoxApi.Auth.Attributes;
using LexBoxApi.GraphQL.CustomTypes;
using LexBoxApi.Services;
using LexCore.Auth;
using LexCore.Entities;
using LexCore.ServiceInterfaces;
Expand Down Expand Up @@ -186,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
17 changes: 15 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,16 @@ 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.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;
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);
myieye marked this conversation as resolved.
Show resolved Hide resolved
return json;
}

private async Task AddUserToProject(Guid projectId, string username)
myieye marked this conversation as resolved.
Show resolved Hide resolved
{
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");
myieye marked this conversation as resolved.
Show resolved Hide resolved
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();
}
}
71 changes: 71 additions & 0 deletions backend/Testing/LexCore/Services/UserServiceTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using LexBoxApi.Auth;
using LexBoxApi.Services;
using LexBoxApi.Services.Email;
using LexCore.Auth;
using LexCore.Entities;
using LexCore.ServiceInterfaces;
using LexData;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Npgsql;
using Shouldly;
using Testing.Fixtures;

namespace Testing.LexCore.Services;

[Collection(nameof(TestingServicesFixture))]
public class UserServiceTest
{
private readonly UserService _userService;

private readonly LexBoxDbContext _lexBoxDbContext;

public UserServiceTest(TestingServicesFixture testing)
{
var serviceProvider = testing.ConfigureServices(s =>
{
s.AddScoped<IEmailService>(_ => Mock.Of<IEmailService>());
s.AddScoped<UserService>();
});
_userService = serviceProvider.GetRequiredService<UserService>();
_lexBoxDbContext = serviceProvider.GetRequiredService<LexBoxDbContext>();
}

[Fact]
public async Task ManagerCanSeeAllUsersEvenInConfidentialProjects()
{
var manager = await _lexBoxDbContext.Users.Include(u => u.Organizations).Include(u => u.Projects).FirstOrDefaultAsync(user => user.Email == "[email protected]");
manager.ShouldNotBeNull();
await using var privateProject = await TempProjectWithoutRepo.Create(_lexBoxDbContext, true, manager.Id);
privateProject.Project.ShouldNotBeNull();
var qaAdmin = await _lexBoxDbContext.Users.Include(u => u.Organizations).Include(u => u.Projects).FirstOrDefaultAsync(user => user.Email == "[email protected]");
qaAdmin.ShouldNotBeNull();
privateProject.Project.Users.Add(new ProjectUsers() { UserId = qaAdmin.Id, Role = ProjectRole.Editor });
await _lexBoxDbContext.SaveChangesAsync();
var authUser = new LexAuthUser(manager);
var users = await _userService.UserQueryForTypeahead(authUser).ToArrayAsync();
users.ShouldNotBeEmpty();
users.ShouldContain(u => u.Id == qaAdmin.Id);
}

[Fact]
public async Task NonManagerCanNotSeeUsersInConfidentialProjects()
{
var manager = await _lexBoxDbContext.Users.Include(u => u.Organizations).Include(u => u.Projects).FirstOrDefaultAsync(user => user.Email == "[email protected]");
manager.ShouldNotBeNull();
var editor = await _lexBoxDbContext.Users.Include(u => u.Organizations).Include(u => u.Projects).FirstOrDefaultAsync(user => user.Email == "[email protected]");
editor.ShouldNotBeNull();
await using var privateProject = await TempProjectWithoutRepo.Create(_lexBoxDbContext, true, manager.Id);
hahn-kev marked this conversation as resolved.
Show resolved Hide resolved
privateProject.Project.ShouldNotBeNull();
var qaAdmin = await _lexBoxDbContext.Users.Include(u => u.Organizations).Include(u => u.Projects).FirstOrDefaultAsync(user => user.Email == "[email protected]");
qaAdmin.ShouldNotBeNull();
privateProject.Project.Users.Add(new ProjectUsers() { UserId = qaAdmin.Id, Role = ProjectRole.Editor });
await _lexBoxDbContext.SaveChangesAsync();
var authUser = new LexAuthUser(editor);
var users = await _userService.UserQueryForTypeahead(authUser).ToArrayAsync();
users.ShouldNotContain(u => u.Id == qaAdmin.Id);
}
}
16 changes: 15 additions & 1 deletion frontend/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ type InvalidEmailError implements Error {
address: String!
}

type InvalidOperationError implements Error {
message: String!
}

type IsAdminResponse {
value: Boolean!
}
Expand Down Expand Up @@ -439,6 +443,7 @@ type Query {
orgs(where: OrganizationFilterInput @cost(weight: "10") orderBy: [OrganizationSortInput!] @cost(weight: "10")): [Organization!]! @cost(weight: "10")
myOrgs(where: OrganizationFilterInput @cost(weight: "10") orderBy: [OrganizationSortInput!] @cost(weight: "10")): [Organization!]! @cost(weight: "10")
usersInMyOrg(skip: Int take: Int where: UserFilterInput @cost(weight: "10") orderBy: [UserSortInput!] @cost(weight: "10")): UsersInMyOrgCollectionSegment @listSize(assumedSize: 1000, slicingArguments: [ "take" ], sizedFields: [ "items" ]) @cost(weight: "10")
usersICanSee(skip: Int take: Int where: UserFilterInput @cost(weight: "10") orderBy: [UserSortInput!] @cost(weight: "10")): UsersICanSeeCollectionSegment @listSize(assumedSize: 1000, slicingArguments: [ "take" ], sizedFields: [ "items" ]) @cost(weight: "10")
orgById(orgId: UUID!): OrgById @cost(weight: "10")
users(skip: Int take: Int where: UserFilterInput @cost(weight: "10") orderBy: [UserSortInput!] @cost(weight: "10")): UsersCollectionSegment @authorize(policy: "AdminRequiredPolicy") @listSize(assumedSize: 1000, slicingArguments: [ "take" ], sizedFields: [ "items" ]) @cost(weight: "10")
me: MeDto @cost(weight: "10")
Expand Down Expand Up @@ -555,6 +560,15 @@ type UsersCollectionSegment {
totalCount: Int! @cost(weight: "10")
}

"A segment of a collection."
type UsersICanSeeCollectionSegment {
"Information to aid in pagination."
pageInfo: CollectionSegmentInfo!
"A flattened list of the items."
items: [User!]
totalCount: Int! @cost(weight: "10")
}

"A segment of a collection."
type UsersInMyOrgCollectionSegment {
"Information to aid in pagination."
Expand Down Expand Up @@ -608,7 +622,7 @@ union LeaveProjectError = NotFoundError | LastMemberCantLeaveError

union RemoveProjectFromOrgError = DbError | NotFoundError

union SendNewVerificationEmailByAdminError = NotFoundError | DbError | UniqueValueError
union SendNewVerificationEmailByAdminError = NotFoundError | DbError | InvalidOperationError

union SetOrgMemberRoleError = DbError | NotFoundError | OrgMemberInvitedByEmail

Expand Down
17 changes: 10 additions & 7 deletions frontend/src/lib/forms/UserTypeahead.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import { FormField, PlainInput, randomFormId } from '$lib/forms';
import { _userTypeaheadSearch, _orgMemberTypeaheadSearch, type SingleUserTypeaheadResult, type SingleUserInMyOrgTypeaheadResult } from '$lib/gql/typeahead-queries';
import { _userTypeaheadSearch, _usersTypeaheadSearch, type SingleUserTypeaheadResult, type SingleUserICanSeeTypeaheadResult } from '$lib/gql/typeahead-queries';
import { overlay } from '$lib/overlay';
import { deriveAsync } from '$lib/util/time';
import { derived, writable } from 'svelte/store';
Expand All @@ -14,34 +14,37 @@
export let value: string;
export let debounceMs = 200;
export let isAdmin: boolean = false;
export let exclude: string[] = [];

let input = writable('');
$: $input = value;
let typeaheadResults = deriveAsync(
input,
isAdmin ? _userTypeaheadSearch : _orgMemberTypeaheadSearch,
isAdmin ? _userTypeaheadSearch : _usersTypeaheadSearch,
[],
debounceMs);

$: filteredResults = $typeaheadResults.filter(user => !exclude.includes(user.id));
myieye marked this conversation as resolved.
Show resolved Hide resolved

const dispatch = createEventDispatcher<{
selectedUserId: string | null;
selectedUser: SingleUserTypeaheadResult | SingleUserInMyOrgTypeaheadResult | null;
selectedUser: SingleUserTypeaheadResult | SingleUserICanSeeTypeaheadResult | null;
}>();

let selectedUser = writable<SingleUserTypeaheadResult | SingleUserInMyOrgTypeaheadResult | null>(null);
let selectedUser = writable<SingleUserTypeaheadResult | SingleUserICanSeeTypeaheadResult | null>(null);
let selectedUserId = derived(selectedUser, user => user?.id ?? null);
$: dispatch('selectedUserId', $selectedUserId);
$: dispatch('selectedUser', $selectedUser);

function formatResult(user: SingleUserTypeaheadResult | SingleUserInMyOrgTypeaheadResult): string {
function formatResult(user: SingleUserTypeaheadResult | SingleUserICanSeeTypeaheadResult): string {
const extra = 'username' in user && user.username && 'email' in user && user.email ? ` (${user.username}, ${user.email})`
: 'username' in user && user.username ? ` (${user.username})`
: 'email' in user && user.email ? ` (${user.email})`
: '';
return `${user.name}${extra}`;
}

function getInputValue(user: SingleUserTypeaheadResult | SingleUserInMyOrgTypeaheadResult): string {
function getInputValue(user: SingleUserTypeaheadResult | SingleUserICanSeeTypeaheadResult): string {
if ('email' in user && user.email) return user.email;
if ('username' in user && user.username) return user.username;
if ('name' in user && user.name) return user.name;
Expand All @@ -62,7 +65,7 @@
/>
<div class="overlay-content">
<ul class="menu p-0">
{#each $typeaheadResults as user}
{#each filteredResults as user}
<li class="p-0"><button class="whitespace-nowrap" on:click={() => {
setTimeout(() => {
if ('id' in user && user.id) {
Expand Down
Loading
Loading