Skip to content

Commit

Permalink
Org page improvements (#888)
Browse files Browse the repository at this point in the history
* Org page now shows "Leave org" button only to members
* Org page now shows Settings tab only to members or site admins
* Members list on org page hides email/username column if you're not an
  org admin (or site admin)
* Members list on org page only shows org admins if a non-member is
  viewing it
* Projects list on org page only shows public projects, or projects you
  yourself are a member of
    * This rule applies to both members and non-members equally
* Org admins can still see all projects (and site admins too)
* Org admins can click on any org member to see all his details
    * Works like admin dashboard, but can only view org members

---------

Co-authored-by: Tim Haasdyk <[email protected]>
Co-authored-by: Kevin Hahn <[email protected]>
  • Loading branch information
3 people authored Jul 4, 2024
1 parent c39a189 commit a928b89
Show file tree
Hide file tree
Showing 28 changed files with 734 additions and 86 deletions.
2 changes: 2 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@ tasks:
platforms: [ windows ]
cmds:
- powershell -File download.ps1 sena-3 'https://drive.google.com/uc?export=download&id=1I-hwc0RHoQqW774gbS5qR-GHa1E7BlsS' 'BEC5131799DB07BF8D84D8FC1F3169FB2574F2A1F4C37F6898EAB563A4AE95B8'
- powershell -File download.ps1 empty 'https://drive.google.com/uc?export=download&id=1p73u-AGdSwNkg_5KEv9-4iLRuN-1V-LD' 'F4EB48D2C7B3294DCA93965F14F058E56D797F38D562B86CF0372F774E1B486B'
- powershell -File download.ps1 elawa 'https://drive.usercontent.google.com/download?export=download&id=1Jk-eSDho8ATBMS-Kmfatwi-MWQth26ro&confirm=t' "E3608F1E3188CE5FDB166FBF9D5AAD06558DB68EFA079FB453881572B50CB8E3"
setup-unix:
platforms: [ linux, darwin ]
cmds:
- wget -c -O {{.DATA_DIR}}/sena-3.zip 'https://drive.google.com/uc?export=download&id=1I-hwc0RHoQqW774gbS5qR-GHa1E7BlsS'
- wget -c -O {{.DATA_DIR}}/empty.zip 'https://drive.google.com/uc?export=download&id=1p73u-AGdSwNkg_5KEv9-4iLRuN-1V-LD'
- wget -c -O {{.DATA_DIR}}/elawa.zip 'https://drive.usercontent.google.com/download?export=download&id=1Jk-eSDho8ATBMS-Kmfatwi-MWQth26ro&confirm=t'

# k8s
Expand Down
3 changes: 3 additions & 0 deletions backend/LexBoxApi/Auth/LexAuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ public async Task<bool> CanUserLogin(Guid id)
var dbUser = await _lexBoxDbContext.Users
.Include(u => u.Projects)
.ThenInclude(p => p.Project)
.Include(u => u.Organizations)
.ThenInclude(o => o.Organization)
.FirstOrDefaultAsync(user => user.Id == userId);
if (dbUser is null)
{
Expand Down Expand Up @@ -130,6 +132,7 @@ await context.SignInAsync(jwtUser.GetPrincipal("Refresh"),
var user = await _lexBoxDbContext.Users
.Where(predicate)
.Include(u => u.Projects).ThenInclude(p => p.Project)
.Include(u => u.Organizations)
.FirstOrDefaultAsync();
return (user == null ? null : new LexAuthUser(user), user);
}
Expand Down
2 changes: 1 addition & 1 deletion backend/LexBoxApi/Controllers/IntegrationController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public class IntegrationController(
[ProducesResponseType(StatusCodes.Status302Found)]
public async Task<ActionResult> OpenWithFlex(Guid projectId)
{
if (!permissionService.CanAccessProject(projectId)) return Unauthorized();
if (!permissionService.CanSyncProject(projectId)) return Unauthorized();
var project = await lexBoxDbContext.Projects.FirstOrDefaultAsync(p => p.Id == projectId);
if (project is null) return NotFound();
var repoId = await hgService.GetRepositoryIdentifier(project);
Expand Down
41 changes: 41 additions & 0 deletions backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using LexBoxApi.Auth.Attributes;
using LexCore.Entities;

namespace LexBoxApi.GraphQL.CustomTypes;
Expand All @@ -8,5 +9,45 @@ public class OrgGqlConfiguration : ObjectType<Organization>
protected override void Configure(IObjectTypeDescriptor<Organization> descriptor)
{
descriptor.Field(o => o.CreatedDate).IsProjected();
descriptor.Field(o => o.Id).IsProjected(); // Needed for jwt refresh
descriptor.Field(o => o.Id).Use<RefreshJwtOrgMembershipMiddleware>();
//only admins can query members list and projects, custom logic is used for getById
descriptor.Field(o => o.Members).AdminRequired();
descriptor.Field(o => o.Projects).AdminRequired();
}
}

/// <summary>
/// used to override some configuration for only the OrgById query
/// </summary>
[ObjectType]
public class OrgByIdGqlConfiguration : ObjectType<Organization>
{
protected override void Configure(IObjectTypeDescriptor<Organization> descriptor)
{
descriptor.Name("OrgById");
descriptor.Field(o => o.Members).Type(ListType<OrgMember>(memberDescriptor =>
{
memberDescriptor.Name("OrgByIdMember");
memberDescriptor.Field(member => member.User).Type(ObjectType<User>(userDescriptor =>
{
userDescriptor.Name("OrgByIdUser");
userDescriptor.BindFieldsExplicitly();
userDescriptor.Field(u => u.Id);
userDescriptor.Field(u => u.Name);
userDescriptor.Field(u => u.Username);
userDescriptor.Field(u => u.Email);
}));
}));
}

private static IOutputType ObjectType<T>(Action<IObjectTypeDescriptor<T>> configure)
{
return new NonNullType(new ObjectType<T>(configure));
}

private static IOutputType ListType<T>(Action<IObjectTypeDescriptor<T>> configure)
{
return new NonNullType(new ListType(ObjectType(configure)));
}
}
24 changes: 24 additions & 0 deletions backend/LexBoxApi/GraphQL/CustomTypes/OrgMemberDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace LexBoxApi.GraphQL.CustomTypes;

public class OrgMemberDto
{
public required Guid Id { get; set; }
public DateTimeOffset CreatedDate { get; set; }
public DateTimeOffset UpdatedDate { get; set; }
public DateTimeOffset LastActive { get; set; }
public required string Name { get; set; }
public required string? Email { get; set; }
public required string? Username { get; set; }
public required string LocalizationCode { get; set; }
public required bool EmailVerified { get; set; }
public required bool IsAdmin { get; set; }
public required bool Locked { get; set; }
public required bool CanCreateProjects { get; set; }
public required OrgMemberDtoCreatedBy? CreatedBy { get; set; }
}

public class OrgMemberDtoCreatedBy
{
public required Guid Id { get; set; }
public required string Name { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using HotChocolate.Resolvers;
using LexBoxApi.Auth;
using LexCore.Auth;
using LexCore.Entities;

namespace LexBoxApi.GraphQL.CustomTypes;

public class RefreshJwtOrgMembershipMiddleware(FieldDelegate next)
{
public async Task InvokeAsync(IMiddlewareContext context)
{
await next(context);
if (UserAlreadyRefreshed(context))
{
return;
}

var user = context.Service<LoggedInContext>().MaybeUser;
if (user is null || user.Role == UserRole.admin) return;

var orgId = context.Parent<Organization>().Id;
if (orgId == default)
{
if (context.Result is not Guid orgGuid) return;
if (orgGuid == default) return;
orgId = orgGuid;
} // we know we have a valid org-ID

var currUserMembershipJwt = user.Orgs.FirstOrDefault(orgs => orgs.OrgId == orgId);

if (currUserMembershipJwt is null)
{
// The user was probably added to the org and it's not in the token yet
await RefreshUser(context, user.Id);
return;
}

if (context.Result is not IEnumerable<OrgMember> orgMembers) return;

var sampleOrgUser = orgMembers.FirstOrDefault();
if (sampleOrgUser is not null && sampleOrgUser.UserId == default && (sampleOrgUser.User == null || sampleOrgUser.User.Id == default))
{
// User IDs don't seem to have been loaded from the DB, so we can't do anything
return;
}

var currUserMembershipDb = orgMembers.FirstOrDefault(orgUser => user.Id == orgUser.UserId || user.Id == orgUser.User?.Id);
if (currUserMembershipDb is null)
{
// The user was probably removed from the org and it's still in the token
await RefreshUser(context, user.Id);
}
else if (currUserMembershipDb.Role == default)
{
return; // Either the role wasn't loaded by the query (so we can't do anything) or the role is actually Unknown which means it definitely has never been changed
}
else if (currUserMembershipDb.Role != currUserMembershipJwt.Role)
{
// The user's role was changed
await RefreshUser(context, user.Id);
}
}

private static async Task RefreshUser(IMiddlewareContext context, Guid userId)
{
var lexAuthService = context.Service<LexAuthService>();
context.ContextData[GraphQlSetupKernel.RefreshedJwtMembershipsKey] = true;
await lexAuthService.RefreshUser(userId, LexAuthConstants.OrgsClaimType);
}

private static bool UserAlreadyRefreshed(IMiddlewareContext context)
{
return context.ContextData.ContainsKey(GraphQlSetupKernel.RefreshedJwtMembershipsKey);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ namespace LexBoxApi.GraphQL.CustomTypes;

public class RefreshJwtProjectMembershipMiddleware(FieldDelegate next)
{
private const string REFRESHED_USER_KEY = "RefreshedJwtProjectMembership";

public async Task InvokeAsync(IMiddlewareContext context)
{
await next(context);
Expand Down Expand Up @@ -66,12 +64,12 @@ public async Task InvokeAsync(IMiddlewareContext context)
private static async Task RefreshUser(IMiddlewareContext context, Guid userId)
{
var lexAuthService = context.Service<LexAuthService>();
context.ContextData[REFRESHED_USER_KEY] = true;
context.ContextData[GraphQlSetupKernel.RefreshedJwtMembershipsKey] = true;
await lexAuthService.RefreshUser(userId, LexAuthConstants.ProjectsClaimType);
}

private static bool UserAlreadyRefreshed(IMiddlewareContext context)
{
return context.ContextData.ContainsKey(REFRESHED_USER_KEY);
return context.ContextData.ContainsKey(GraphQlSetupKernel.RefreshedJwtMembershipsKey);
}
}
1 change: 1 addition & 0 deletions backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ namespace LexBoxApi.GraphQL;
public static class GraphQlSetupKernel
{
public const string LexBoxSchemaName = "LexBox";
public const string RefreshedJwtMembershipsKey = "RefreshedJwtMemberships";
public static void AddLexGraphQL(this IServiceCollection services, IHostEnvironment env, bool forceGenerateSchema = false)
{
if (forceGenerateSchema || env.IsDevelopment())
Expand Down
62 changes: 56 additions & 6 deletions backend/LexBoxApi/GraphQL/LexQueries.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using HotChocolate.Resolvers;
using LexBoxApi.Auth;
using LexBoxApi.Auth.Attributes;
using LexBoxApi.GraphQL.CustomTypes;
Expand Down Expand Up @@ -55,17 +56,17 @@ public IQueryable<DraftProject> DraftProjects(LexBoxDbContext context)

[UseSingleOrDefault]
[UseProjection]
public IQueryable<Project> ProjectById(LexBoxDbContext context, IPermissionService permissionService, Guid projectId)
public async Task<IQueryable<Project>> ProjectById(LexBoxDbContext context, IPermissionService permissionService, Guid projectId)
{
permissionService.AssertCanAccessProject(projectId);
await permissionService.AssertCanViewProject(projectId);
return context.Projects.Where(p => p.Id == projectId);
}

[UseSingleOrDefault]
[UseProjection]
public async Task<IQueryable<Project>> ProjectByCode(LexBoxDbContext context, IPermissionService permissionService, string code)
{
await permissionService.AssertCanAccessProject(code);
await permissionService.AssertCanViewProject(code);
return context.Projects.Where(p => p.Code == code);
}

Expand Down Expand Up @@ -94,11 +95,31 @@ public IQueryable<Organization> MyOrgs(LexBoxDbContext context, LoggedInContext
return context.Orgs.Where(o => o.Members.Any(m => m.UserId == userId));
}

[UseSingleOrDefault]
[UseProjection]
public IQueryable<Organization> OrgById(LexBoxDbContext context, Guid orgId)
[GraphQLType<OrgByIdGqlConfiguration>]
public async Task<Organization?> OrgById(LexBoxDbContext dbContext, Guid orgId, IPermissionService permissionService, IResolverContext context)
{
return context.Orgs.Where(o => o.Id == orgId);
var org = await dbContext.Orgs.Where(o => o.Id == orgId).AsNoTracking().Project(context).SingleOrDefaultAsync();
if (org is null) return org;
// Site admins and org admins can see everything
if (permissionService.CanEditOrg(orgId)) return org;
// Non-admins cannot see email addresses or usernames
org.Members?.ForEach(m =>
{
if (m.User is not null)
{
m.User.Email = null;
m.User.Username = null;
}
});
// Members and non-members alike can see all public projects plus their own
org.Projects = org.Projects?.Where(p => p.IsConfidential == false || permissionService.CanSyncProject(p.Id))?.ToList() ?? [];
if (!permissionService.IsOrgMember(orgId))
{
// Non-members also cannot see membership, only org admins
org.Members = org.Members?.Where(m => m.Role == OrgRole.Admin).ToList() ?? [];
}
return org;
}

[UseOffsetPaging]
Expand Down Expand Up @@ -126,6 +147,35 @@ public IQueryable<User> Users(LexBoxDbContext context)
};
}

public async Task<OrgMemberDto?> OrgMemberById(LexBoxDbContext context, IPermissionService permissionService, Guid orgId, Guid userId)
{
// Only site admins and org admins are allowed to run this query
if (!permissionService.CanEditOrg(orgId)) return null;

var user = await context.Users.Include(u => u.Organizations).Include(u => u.CreatedBy).Where(u => u.Id == userId).FirstOrDefaultAsync();
if (user is null) return null;

var userInOrg = user.Organizations.Any(om => om.OrgId == orgId);
if (!userInOrg) return null;

return new OrgMemberDto
{
Id = user.Id,
CreatedDate = user.CreatedDate,
UpdatedDate = user.UpdatedDate,
LastActive = user.LastActive,
Name = user.Name,
Email = user.Email,
Username = user.Username,
LocalizationCode = user.LocalizationCode,
EmailVerified = user.EmailVerified,
IsAdmin = user.IsAdmin,
Locked = user.Locked,
CanCreateProjects = user.CanCreateProjects,
CreatedBy = user.CreatedBy is null ? null : new OrgMemberDtoCreatedBy { Id = user.CreatedBy.Id, Name = user.CreatedBy.Name },
};
}

public LexAuthUser MeAuth(LoggedInContext loggedInContext)
{
return loggedInContext.User;
Expand Down
Loading

0 comments on commit a928b89

Please sign in to comment.