diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs b/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs index 694f6f400..b8965fee6 100644 --- a/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs +++ b/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs @@ -10,7 +10,6 @@ protected override void Configure(IObjectTypeDescriptor descriptor { descriptor.Field(o => o.CreatedDate).IsProjected(); descriptor.Field(o => o.Id).IsProjected(); // Needed for jwt refresh - descriptor.Field(o => o.Id).Use(); //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(); @@ -26,9 +25,11 @@ public class OrgByIdGqlConfiguration : ObjectType protected override void Configure(IObjectTypeDescriptor descriptor) { descriptor.Name("OrgById"); + descriptor.Field(o => o.Id).IsProjected(); // Needed for jwt refresh descriptor.Field(o => o.Members).Type(ListType(memberDescriptor => { memberDescriptor.Name("OrgByIdMember"); + memberDescriptor.Field(member => member.UserId).IsProjected(); // Needed for jwt refresh memberDescriptor.Field(member => member.User).Type(ObjectType(userDescriptor => { userDescriptor.Name("OrgByIdUser"); diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/OrgMembersGqlConfiguration.cs b/backend/LexBoxApi/GraphQL/CustomTypes/OrgMembersGqlConfiguration.cs index 65b81bede..4633fde26 100644 --- a/backend/LexBoxApi/GraphQL/CustomTypes/OrgMembersGqlConfiguration.cs +++ b/backend/LexBoxApi/GraphQL/CustomTypes/OrgMembersGqlConfiguration.cs @@ -9,5 +9,6 @@ protected override void Configure(IObjectTypeDescriptor descriptor) { descriptor.Field(f => f.User).Type>(); descriptor.Field(f => f.Organization).Type>(); + descriptor.Field(f => f.UserId).IsProjected(); // Needed for jwt refresh (not really, but that's a complicated detail) } } diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/ProjectGqlConfiguration.cs b/backend/LexBoxApi/GraphQL/CustomTypes/ProjectGqlConfiguration.cs index 20ab2566d..f6e086511 100644 --- a/backend/LexBoxApi/GraphQL/CustomTypes/ProjectGqlConfiguration.cs +++ b/backend/LexBoxApi/GraphQL/CustomTypes/ProjectGqlConfiguration.cs @@ -1,5 +1,4 @@ -using LexBoxApi.Auth.Attributes; -using LexCore.Entities; +using LexCore.Entities; namespace LexBoxApi.GraphQL.CustomTypes; @@ -10,8 +9,8 @@ protected override void Configure(IObjectTypeDescriptor descriptor) { descriptor.Field(p => p.Code).IsProjected(); descriptor.Field(p => p.CreatedDate).IsProjected(); - descriptor.Field(p => p.Id).Use(); - descriptor.Field(p => p.Users).Use().Use(); + descriptor.Field(p => p.Id).IsProjected(); // Needed for jwt refresh + descriptor.Field(p => p.Users).Use(); // descriptor.Field("userCount").Resolve(ctx => ctx.Parent().UserCount); } } diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/ProjectUsersGqlConfiguration.cs b/backend/LexBoxApi/GraphQL/CustomTypes/ProjectUsersGqlConfiguration.cs index 4ce39b50d..7efacb8eb 100644 --- a/backend/LexBoxApi/GraphQL/CustomTypes/ProjectUsersGqlConfiguration.cs +++ b/backend/LexBoxApi/GraphQL/CustomTypes/ProjectUsersGqlConfiguration.cs @@ -7,6 +7,7 @@ public class ProjectUsersGqlConfiguration : ObjectType { protected override void Configure(IObjectTypeDescriptor descriptor) { + descriptor.Field(f => f.UserId).IsProjected(); // Needed for jwt refresh descriptor.Field(f => f.User).Type>(); descriptor.Field(f => f.Project).Type>(); } diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/RefreshJwtOrgMembershipMiddleware.cs b/backend/LexBoxApi/GraphQL/CustomTypes/RefreshJwtOrgMembershipMiddleware.cs deleted file mode 100644 index b97fa968e..000000000 --- a/backend/LexBoxApi/GraphQL/CustomTypes/RefreshJwtOrgMembershipMiddleware.cs +++ /dev/null @@ -1,75 +0,0 @@ -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().MaybeUser; - if (user is null || user.Role == UserRole.admin) return; - - var orgId = context.Parent().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 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(); - context.ContextData[GraphQlSetupKernel.RefreshedJwtMembershipsKey] = true; - await lexAuthService.RefreshUser(userId, LexAuthConstants.OrgsClaimType); - } - - private static bool UserAlreadyRefreshed(IMiddlewareContext context) - { - return context.ContextData.ContainsKey(GraphQlSetupKernel.RefreshedJwtMembershipsKey); - } -} diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/RefreshJwtProjectMembershipMiddleware.cs b/backend/LexBoxApi/GraphQL/CustomTypes/RefreshJwtProjectMembershipMiddleware.cs deleted file mode 100644 index 2142f2150..000000000 --- a/backend/LexBoxApi/GraphQL/CustomTypes/RefreshJwtProjectMembershipMiddleware.cs +++ /dev/null @@ -1,75 +0,0 @@ -using HotChocolate.Resolvers; -using LexBoxApi.Auth; -using LexCore.Auth; -using LexCore.Entities; - -namespace LexBoxApi.GraphQL.CustomTypes; - -public class RefreshJwtProjectMembershipMiddleware(FieldDelegate next) -{ - public async Task InvokeAsync(IMiddlewareContext context) - { - await next(context); - if (UserAlreadyRefreshed(context)) - { - return; - } - - var user = context.Service().MaybeUser; - if (user is null || user.Role == UserRole.admin) return; - - var projectId = context.Parent().Id; - if (projectId == default) - { - if (context.Result is not Guid projectGuid) return; - if (projectGuid == default) return; - projectId = projectGuid; - } // we know we have a valid project-ID - - var currUserMembershipJwt = user.Projects.FirstOrDefault(projects => projects.ProjectId == projectId); - - if (currUserMembershipJwt is null) - { - // The user was probably added to the project and it's not in the token yet - await RefreshUser(context, user.Id); - return; - } - - if (context.Result is not IEnumerable projectUsers) return; - - var sampleProjectUser = projectUsers.FirstOrDefault(); - if (sampleProjectUser is not null && sampleProjectUser.UserId == default && (sampleProjectUser.User == null || sampleProjectUser.User.Id == default)) - { - // User IDs don't seem to have been loaded from the DB, so we can't do anything - return; - } - - var currUserMembershipDb = projectUsers.FirstOrDefault(projectUser => user.Id == projectUser.UserId || user.Id == projectUser.User?.Id); - if (currUserMembershipDb is null) - { - // The user was probably removed from the project 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(); - context.ContextData[GraphQlSetupKernel.RefreshedJwtMembershipsKey] = true; - await lexAuthService.RefreshUser(userId, LexAuthConstants.ProjectsClaimType); - } - - private static bool UserAlreadyRefreshed(IMiddlewareContext context) - { - return context.ContextData.ContainsKey(GraphQlSetupKernel.RefreshedJwtMembershipsKey); - } -} diff --git a/backend/LexBoxApi/GraphQL/LexAuthUserOutOfSyncExtensions.cs b/backend/LexBoxApi/GraphQL/LexAuthUserOutOfSyncExtensions.cs new file mode 100644 index 000000000..d9ac255d1 --- /dev/null +++ b/backend/LexBoxApi/GraphQL/LexAuthUserOutOfSyncExtensions.cs @@ -0,0 +1,68 @@ +using LexCore.Auth; +using LexCore.Entities; + +namespace LexBoxApi.GraphQL; + +public static class LexAuthUserOutOfSyncExtensions +{ + public static bool IsOutOfSyncWithMyProjects(this LexAuthUser user, List myProjects) + { + if (user.IsAdmin) return false; // admins don't have projects in their token + if (user.Projects.Length != myProjects.Count) return true; // different number of projects + return myProjects.Any(proj => user.IsOutOfSyncWithProject(proj, isMyProject: true)); + } + + public static bool IsOutOfSyncWithMyOrgs(this LexAuthUser user, List myOrgs) + { + if (user.IsAdmin) return false; // admins don't have orgs in their token + if (user.Orgs.Length != myOrgs.Count) return true; // different number of orgs + return myOrgs.Any(org => user.IsOutOfSyncWithOrg(org, isMyOrg: true)); + } + + public static bool IsOutOfSyncWithProject(this LexAuthUser user, Project project, bool isMyProject = false) + { + if (user.IsAdmin) return false; // admins don't have projects in their token + + var tokenMembership = user.Projects.SingleOrDefault(p => p.ProjectId == project.Id); + + if (project.Users is null) + { + if (tokenMembership is null && isMyProject) return true; // we know we're supposed to be a member + return false; // otherwise, we can't detect differences without users available + } + + var dbMembership = project.Users.SingleOrDefault(u => u.UserId == user.Id); + + if (tokenMembership is null && dbMembership is null) return false; // both null: they're the same + if (tokenMembership is null || dbMembership is null) return true; // only 1 is null: they're different + + var projectRolesAvailable = project.Users.Any(u => u.Role is not ProjectRole.Unknown); + if (!projectRolesAvailable) return false; // we can't detect differences without roles available + + return tokenMembership.Role != dbMembership.Role; + } + + public static bool IsOutOfSyncWithOrg(this LexAuthUser user, Organization org, bool isMyOrg = false) + { + if (user.IsAdmin) return false; // admins don't have orgs in their token + if (org.Projects?.Any(project => user.IsOutOfSyncWithProject(project)) ?? false) return true; + + var tokenMembership = user.Orgs.SingleOrDefault(o => o.OrgId == org.Id); + + if (org.Members is null) + { + if (tokenMembership is null && isMyOrg) return true; // we know we're supposed to be a member + return false; // otherwise, we can't detect differences without members available + } + + var dbMembership = org.Members.SingleOrDefault(m => m.UserId == user.Id); + + if (tokenMembership is null && dbMembership is null) return false; // both null: they're the same + if (tokenMembership is null || dbMembership is null) return true; // only 1 is null: they're different + + var orgRolesAvailable = org.Members.Any(u => u.Role is not OrgRole.Unknown); + if (!orgRolesAvailable) return false; // we can't detect differences without roles available + + return tokenMembership.Role != dbMembership.Role; + } +} diff --git a/backend/LexBoxApi/GraphQL/LexQueries.cs b/backend/LexBoxApi/GraphQL/LexQueries.cs index 062869d44..3a3f0b10d 100644 --- a/backend/LexBoxApi/GraphQL/LexQueries.cs +++ b/backend/LexBoxApi/GraphQL/LexQueries.cs @@ -16,10 +16,22 @@ public class LexQueries [UseProjection] [UseSorting] [UseFiltering] - public IQueryable MyProjects(LoggedInContext loggedInContext, LexBoxDbContext context) + public async Task> MyProjects( + LexAuthService lexAuthService, + LoggedInContext loggedInContext, + LexBoxDbContext dbContext, + IResolverContext context) { var userId = loggedInContext.User.Id; - return context.Projects.Where(p => p.Users.Select(u => u.UserId).Contains(userId)); + var myProjects = await dbContext.Projects.Where(p => p.Users.Select(u => u.UserId).Contains(userId)) + .AsNoTracking().Project(context).ToListAsync(); + + if (loggedInContext.User.IsOutOfSyncWithMyProjects(myProjects)) + { + await lexAuthService.RefreshUser(userId, LexAuthConstants.ProjectsClaimType); + } + + return myProjects.AsQueryable(); } [UseProjection] @@ -107,18 +119,31 @@ public async Task> ProjectById(LexBoxDbContext context, IPer return context.Projects.Where(p => p.Id == projectId); } - [UseSingleOrDefault] [UseProjection] - public async Task> ProjectByCode(LexBoxDbContext context, IPermissionService permissionService, string code) + public async Task ProjectByCode( + LexBoxDbContext dbContext, + IPermissionService permissionService, + LexAuthService lexAuthService, + LoggedInContext loggedInContext, + IResolverContext context, + string code) { - await permissionService.AssertCanViewProject(code); - return context.Projects.Where(p => p.Code == code); + var project = await dbContext.Projects.Where(p => p.Code == code).AsNoTracking().Project(context).SingleOrDefaultAsync(); + + if (project is null) return project; + + var updatedUser = loggedInContext.User.IsOutOfSyncWithProject(project) + ? await lexAuthService.RefreshUser(loggedInContext.User.Id, LexAuthConstants.ProjectsClaimType) + : null; + + await permissionService.AssertCanViewProject(code, updatedUser); + return project; } [UseSingleOrDefault] [UseProjection] [AdminRequired] - public IQueryable DraftProjectByCode(LexBoxDbContext context, IPermissionService permissionService, string code) + public IQueryable DraftProjectByCode(LexBoxDbContext context, string code) { return context.DraftProjects.Where(p => p.Code == code); } @@ -134,10 +159,21 @@ public IQueryable Orgs(LexBoxDbContext context) [UseProjection] [UseFiltering] [UseSorting] - public IQueryable MyOrgs(LexBoxDbContext context, LoggedInContext loggedInContext) + public async Task> MyOrgs( + LexBoxDbContext dbContext, + LexAuthService lexAuthService, + LoggedInContext loggedInContext, + IResolverContext context) { var userId = loggedInContext.User.Id; - return context.Orgs.Where(o => o.Members.Any(m => m.UserId == userId)); + var myOrgs = await dbContext.Orgs.Where(o => o.Members.Any(m => m.UserId == userId)) + .AsNoTracking().Project(context).ToListAsync(); + + if (loggedInContext.User.IsOutOfSyncWithMyOrgs(myOrgs)) + { + await lexAuthService.RefreshUser(userId, LexAuthConstants.OrgsClaimType); + } + return myOrgs.AsQueryable(); } [UseOffsetPaging] @@ -152,15 +188,22 @@ public IQueryable UsersInMyOrg(LexBoxDbContext context, LoggedInContext lo [UseProjection] [GraphQLType] - public async Task OrgById(LexBoxDbContext dbContext, Guid orgId, IPermissionService permissionService, IResolverContext context) + public async Task OrgById(LexBoxDbContext dbContext, + Guid orgId, + IPermissionService permissionService, + LexAuthService lexAuthService, + LoggedInContext loggedInContext, + IResolverContext context) { - return await QueryOrgById(dbContext, orgId, permissionService, context); + return await QueryOrgById(dbContext, orgId, permissionService, lexAuthService, loggedInContext, context); } [GraphQLIgnore] internal static async Task QueryOrgById(LexBoxDbContext dbContext, Guid orgId, IPermissionService permissionService, + LexAuthService lexAuthService, + LoggedInContext loggedInContext, IResolverContext context) { //todo remove this workaround once the issue is fixed @@ -169,8 +212,13 @@ public IQueryable UsersInMyOrg(LexBoxDbContext context, LoggedInContext lo context; var org = await dbContext.Orgs.Where(o => o.Id == orgId).AsNoTracking().Project(projectContext).SingleOrDefaultAsync(); if (org is null) return org; + + var updatedUser = loggedInContext.User.IsOutOfSyncWithOrg(org) + ? await lexAuthService.RefreshUser(loggedInContext.User.Id, LexAuthConstants.OrgsClaimType) + : null; + // Site admins and org admins can see everything - if (permissionService.CanEditOrg(orgId)) return org; + if (permissionService.CanEditOrg(orgId, updatedUser)) return org; // Non-admins cannot see email addresses or usernames org.Members?.ForEach(m => { @@ -182,7 +230,7 @@ public IQueryable UsersInMyOrg(LexBoxDbContext context, LoggedInContext lo }); // 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)) + if (!permissionService.IsOrgMember(orgId, updatedUser)) { // Non-members also cannot see membership, only org admins org.Members = org.Members?.Where(m => m.Role == OrgRole.Admin).ToList() ?? []; diff --git a/backend/LexBoxApi/GraphQL/OrgMutations.cs b/backend/LexBoxApi/GraphQL/OrgMutations.cs index 54c71f6ce..38ae19e47 100644 --- a/backend/LexBoxApi/GraphQL/OrgMutations.cs +++ b/backend/LexBoxApi/GraphQL/OrgMutations.cs @@ -100,13 +100,19 @@ public async Task> AddProjectToOrg( public async Task AddProjectsToOrg( LexBoxDbContext dbContext, IPermissionService permissionService, + LexAuthService lexAuthService, + LoggedInContext loggedInContext, ProjectService projectService, IResolverContext resolverContext, Guid orgId, Guid[] projectIds) { // Bail out immediately, not even checking permissions, if no projects added at all - if (projectIds == null || projectIds.Length == 0) return await LexQueries.QueryOrgById(dbContext, orgId, permissionService, resolverContext); + if (projectIds == null || projectIds.Length == 0) + { + return await LexQueries.QueryOrgById(dbContext, orgId, permissionService, + lexAuthService, loggedInContext, resolverContext); + } var org = await dbContext.Orgs.Include(o => o.Members).Include(o => o.Projects).SingleOrDefaultAsync(o => o.Id == orgId); NotFoundException.ThrowIfNull(org); @@ -128,7 +134,7 @@ public async Task> AddProjectToOrg( projectService.InvalidateProjectOrgIdsCache(projectId); } await dbContext.SaveChangesAsync(); - return await LexQueries.QueryOrgById(dbContext, orgId, permissionService, resolverContext); + return await LexQueries.QueryOrgById(dbContext, orgId, permissionService, lexAuthService, loggedInContext, resolverContext); } [Error] diff --git a/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs b/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs index ec291a45a..a40a93c3f 100644 --- a/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs +++ b/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs @@ -29,10 +29,11 @@ public static async Task GenerateGqlSchema(string[] args) .AddScoped() .AddScoped() .AddScoped() - .AddScoped((services) => new LoggedInContext(null!, null!)) - .AddScoped((services) => new LexBoxDbContext(null!, null!)) + .AddScoped() + .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddLexGraphQL(builder.Environment, true); var host = builder.Build(); await host.StartAsync(); diff --git a/backend/LexBoxApi/Services/PermissionService.cs b/backend/LexBoxApi/Services/PermissionService.cs index 140b783a4..64e9bf3e2 100644 --- a/backend/LexBoxApi/Services/PermissionService.cs +++ b/backend/LexBoxApi/Services/PermissionService.cs @@ -12,12 +12,13 @@ public class PermissionService( { private LexAuthUser? User => loggedInContext.MaybeUser; - private async ValueTask ManagesOrgThatOwnsProject(Guid projectId) + private async ValueTask ManagesOrgThatOwnsProject(Guid projectId, LexAuthUser? overrideUser = null) { - if (User is not null && User.Orgs.Any(o => o.Role == OrgRole.Admin)) + var user = overrideUser ?? User; + if (user is not null && user.Orgs.Any(o => o.Role == OrgRole.Admin)) { // Org admins can view, edit, and sync all projects, even confidential ones - var managedOrgIds = User.Orgs.Where(o => o.Role == OrgRole.Admin).Select(o => o.OrgId).ToHashSet(); + var managedOrgIds = user.Orgs.Where(o => o.Role == OrgRole.Admin).Select(o => o.OrgId).ToHashSet(); var projectOrgIds = await projectService.LookupProjectOrgIds(projectId); if (projectOrgIds.Any(oId => managedOrgIds.Contains(oId))) return true; } @@ -47,7 +48,7 @@ public bool CanSyncProject(Guid projectId) if (User is null) return false; if (User.Role == UserRole.admin) return true; if (User.Projects is null) return false; - return User.Projects.Any(p => p.ProjectId == projectId); + return User.IsProjectMember(projectId); } public async ValueTask CanSyncProjectAsync(Guid projectId) @@ -67,12 +68,13 @@ public async ValueTask AssertCanSyncProject(Guid projectId) if (!await CanSyncProjectAsync(projectId)) throw new UnauthorizedAccessException(); } - public async ValueTask CanViewProject(Guid projectId) + public async ValueTask CanViewProject(Guid projectId, LexAuthUser? overrideUser = null) { - if (User is not null && User.Role == UserRole.admin) return true; - if (User is not null && User.Projects.Any(p => p.ProjectId == projectId)) return true; + var user = overrideUser ?? User; + if (user is not null && user.Role == UserRole.admin) return true; + if (user is not null && user.IsProjectMember(projectId)) return true; // Org admins can view all projects, even confidential ones - if (await ManagesOrgThatOwnsProject(projectId)) return true; + if (await ManagesOrgThatOwnsProject(projectId, overrideUser)) return true; var isConfidential = await projectService.LookupProjectConfidentiality(projectId); if (isConfidential is null) return false; // Private by default return isConfidential == false; // Explicitly set to public @@ -83,15 +85,16 @@ public async ValueTask AssertCanViewProject(Guid projectId) if (!await CanViewProject(projectId)) throw new UnauthorizedAccessException(); } - public async ValueTask CanViewProject(string projectCode) + public async ValueTask CanViewProject(string projectCode, LexAuthUser? overrideUser = null) { - if (User is not null && User.Role == UserRole.admin) return true; - return await CanViewProject(await projectService.LookupProjectId(projectCode)); + var user = overrideUser ?? User; + if (user is not null && user.Role == UserRole.admin) return true; + return await CanViewProject(await projectService.LookupProjectId(projectCode), overrideUser); } - public async ValueTask AssertCanViewProject(string projectCode) + public async ValueTask AssertCanViewProject(string projectCode, LexAuthUser? overrideUser = null) { - if (!await CanViewProject(projectCode)) throw new UnauthorizedAccessException(); + if (!await CanViewProject(projectCode, overrideUser)) throw new UnauthorizedAccessException(); } public async ValueTask CanViewProjectMembers(Guid projectId) @@ -99,8 +102,8 @@ public async ValueTask CanViewProjectMembers(Guid projectId) if (User is not null && User.Role == UserRole.admin) return true; // Project managers can view members of their own projects, even confidential ones if (await CanManageProject(projectId)) return true; - if (User is null || !User.Projects.Any(p => p.ProjectId == projectId)) return false; - + // non members can't view project members + if (User?.IsProjectMember(projectId) != true) return false; var isConfidential = await projectService.LookupProjectConfidentiality(projectId); // In this specific case (only), we assume public unless explicitly set to private return !(isConfidential ?? false); @@ -110,7 +113,7 @@ public async ValueTask CanManageProject(Guid projectId) { if (User is null) return false; if (User.Role == UserRole.admin) return true; - if (User.Projects.Any(p => p.ProjectId == projectId && p.Role == ProjectRole.Manager)) return true; + if (User.IsProjectMember(projectId, ProjectRole.Manager)) return true; return await ManagesOrgThatOwnsProject(projectId); } @@ -209,18 +212,20 @@ public void AssertCanCreateOrg() if (!CanCreateOrg()) throw new UnauthorizedAccessException(); } - public bool IsOrgMember(Guid orgId) + public bool IsOrgMember(Guid orgId, LexAuthUser? overrideUser = null) { - if (User is null) return false; - if (User.Orgs.Any(o => o.OrgId == orgId)) return true; + var user = overrideUser ?? User; + if (user is null) return false; + if (user.Orgs.Any(o => o.OrgId == orgId)) return true; return false; } - public bool CanEditOrg(Guid orgId) + public bool CanEditOrg(Guid orgId, LexAuthUser? overrideUser = null) { - if (User is null) return false; - if (User.Role == UserRole.admin) return true; - if (User.Orgs.Any(o => o.OrgId == orgId && o.Role == OrgRole.Admin)) return true; + var user = overrideUser ?? User; + if (user is null) return false; + if (user.Role == UserRole.admin) return true; + if (user.Orgs.Any(o => o.OrgId == orgId && o.Role == OrgRole.Admin)) return true; return false; } diff --git a/backend/LexCore/Auth/LexAuthUser.cs b/backend/LexCore/Auth/LexAuthUser.cs index eb119f64b..85b94925f 100644 --- a/backend/LexCore/Auth/LexAuthUser.cs +++ b/backend/LexCore/Auth/LexAuthUser.cs @@ -234,6 +234,15 @@ public ClaimsPrincipal GetPrincipal(string authenticationType) LexAuthConstants.EmailClaimType, LexAuthConstants.RoleClaimType)); } + + public bool IsProjectMember(Guid projectId, ProjectRole? role = null) + { + if (role is not null) + { + return Projects.Any(p => p.ProjectId == projectId && p.Role == role); + } + return Projects.Any(p => p.ProjectId == projectId); + } } public record AuthUserProject(ProjectRole Role, Guid ProjectId); diff --git a/backend/LexCore/Entities/Project.cs b/backend/LexCore/Entities/Project.cs index 0d6114d7b..43875005e 100644 --- a/backend/LexCore/Entities/Project.cs +++ b/backend/LexCore/Entities/Project.cs @@ -24,7 +24,7 @@ public class Project : EntityBase public ResetStatus ResetStatus { get; set; } = ResetStatus.None; //historical reference for if this project originated here (migrated), or came from redmine, public or private - public required ProjectMigrationStatus ProjectOrigin { get; set; } = ProjectMigrationStatus.Migrated; + public ProjectMigrationStatus ProjectOrigin { get; set; } = ProjectMigrationStatus.Migrated; public DateTimeOffset? MigratedDate { get; set; } = null; [NotMapped] diff --git a/backend/LexCore/ServiceInterfaces/IPermissionService.cs b/backend/LexCore/ServiceInterfaces/IPermissionService.cs index 13e488fc1..aea787a1f 100644 --- a/backend/LexCore/ServiceInterfaces/IPermissionService.cs +++ b/backend/LexCore/ServiceInterfaces/IPermissionService.cs @@ -1,4 +1,5 @@ -using LexCore.Entities; +using LexCore.Auth; +using LexCore.Entities; namespace LexCore.ServiceInterfaces; @@ -16,10 +17,10 @@ public interface IPermissionService ValueTask CanSyncProjectAsync(Guid projectId); ValueTask AssertCanSyncProject(string projectCode); ValueTask AssertCanSyncProject(Guid projectId); - ValueTask CanViewProject(Guid projectId); + ValueTask CanViewProject(Guid projectId, LexAuthUser? overrideUser = null); ValueTask AssertCanViewProject(Guid projectId); - ValueTask CanViewProject(string projectCode); - ValueTask AssertCanViewProject(string projectCode); + ValueTask CanViewProject(string projectCode, LexAuthUser? overrideUser = null); + ValueTask AssertCanViewProject(string projectCode, LexAuthUser? overrideUser = null); ValueTask CanViewProjectMembers(Guid projectId); ValueTask CanManageProject(Guid projectId); ValueTask CanManageProject(string projectCode); @@ -38,8 +39,8 @@ public interface IPermissionService void AssertHasProjectRequestPermission(); void AssertCanLockOrUnlockUser(Guid userId); void AssertCanCreateOrg(); - bool IsOrgMember(Guid orgId); - bool CanEditOrg(Guid orgId); + bool IsOrgMember(Guid orgId, LexAuthUser? overrideUser = null); + bool CanEditOrg(Guid orgId, LexAuthUser? overrideUser = null); void AssertCanEditOrg(Organization org); void AssertCanEditOrg(Guid orgId); void AssertCanAddProjectToOrg(Organization org); diff --git a/backend/Testing/ApiTests/ApiTestBase.cs b/backend/Testing/ApiTests/ApiTestBase.cs index 25803a0c7..1f9901865 100644 --- a/backend/Testing/ApiTests/ApiTestBase.cs +++ b/backend/Testing/ApiTests/ApiTestBase.cs @@ -60,9 +60,12 @@ public void ClearCookies() JwtHelper.ClearCookies(_httpClientHandler); } - public async Task ExecuteGql([StringSyntax("graphql")] string gql, bool expectGqlError = false, bool expectSuccessCode = true) + public async Task ExecuteGql([StringSyntax("graphql")] string gql, bool expectGqlError = false, bool expectSuccessCode = true, + string? overrideJwt = null) { - var response = await HttpClient.PostAsJsonAsync($"{BaseUrl}/api/graphql", new { query = gql }); + var jwtParam = overrideJwt is not null ? $"?jwt={overrideJwt}" : ""; + var response = await HttpClient.PostAsJsonAsync($"{BaseUrl}/api/graphql{jwtParam}", new { query = gql }); + if (JwtHelper.TryGetJwtFromLoginResponse(response, out var jwt)) CurrJwt = jwt; var jsonResponse = await response.Content.ReadFromJsonAsync(); jsonResponse.ShouldNotBeNull($"for query {gql} ({(int)response.StatusCode} ({response.ReasonPhrase}))"); GqlUtils.ValidateGqlErrors(jsonResponse, expectGqlError); diff --git a/backend/Testing/ApiTests/GqlMiddlewareTests.cs b/backend/Testing/ApiTests/GqlMiddlewareTests.cs index 766595775..d6c0ed834 100644 --- a/backend/Testing/ApiTests/GqlMiddlewareTests.cs +++ b/backend/Testing/ApiTests/GqlMiddlewareTests.cs @@ -7,15 +7,30 @@ namespace Testing.ApiTests; [Trait("Category", "Integration")] -public class GqlMiddlewareTests : IClassFixture +public class GqlMiddlewareTests : IClassFixture, IAsyncLifetime { private readonly IntegrationFixture _fixture; private readonly ApiTestBase _adminApiTester; + private string _adminJwt; public GqlMiddlewareTests(IntegrationFixture fixture) { _fixture = fixture; _adminApiTester = _fixture.AdminApiTester; + _adminJwt = _fixture.AdminJwt; + } + + public async Task InitializeAsync() + { + if (_adminJwt != _adminApiTester.CurrJwt) + { + _adminJwt = await _adminApiTester.LoginAs("admin"); + } + } + + public Task DisposeAsync() + { + return Task.CompletedTask; } private async Task QueryMyProjectsWithMembers() @@ -71,4 +86,49 @@ await Task.WhenAll( projects.Select(p => p.Id).ShouldBeSubsetOf(ids); } + + [Fact] + public async Task CanGetProjectThatWasJustAddedToUser() + { + var config = GetNewProjectConfig(isConfidential: true); + await using var project = await RegisterProjectInLexBox(config, _adminApiTester); + + await _adminApiTester.LoginAs("editor"); + var editorJwt = _adminApiTester.CurrJwt; + + await _adminApiTester.ExecuteGql($$""" + query { + projectByCode(code: "{{config.Code}}") { + id + name + } + } + """, expectGqlError: true); // we're not a member yet + _adminApiTester.CurrJwt.ShouldBe(editorJwt); // token wasn't updated + + await AddMemberToProject(config, _adminApiTester, "editor", ProjectRole.Editor, _adminJwt); + + await _adminApiTester.ExecuteGql($$""" + query { + projectByCode(code: "{{config.Code}}") { + id + name + } + } + """, expectGqlError: true); // we're a member, but didn't query for users, so... + _adminApiTester.CurrJwt.ShouldBe(editorJwt); // token wasn't updated + + var response = await _adminApiTester.ExecuteGql($$""" + query { + projectByCode(code: "{{config.Code}}") { + id + name + users { + id + } + } + } + """, expectGqlError: false); // we queried for users, so... + _adminApiTester.CurrJwt.ShouldNotBe(editorJwt); // token was updated + } } diff --git a/backend/Testing/Fixtures/IntegrationFixture.cs b/backend/Testing/Fixtures/IntegrationFixture.cs index f24c0a3ef..df951bb8d 100644 --- a/backend/Testing/Fixtures/IntegrationFixture.cs +++ b/backend/Testing/Fixtures/IntegrationFixture.cs @@ -15,6 +15,8 @@ public class IntegrationFixture : IAsyncLifetime public static readonly FileInfo TemplateRepoZip = new(TemplateRepoZipName); public static readonly DirectoryInfo TemplateRepo = new(Path.Join(BasePath, "_template-repo_")); public ApiTestBase AdminApiTester { get; private set; } = new(); + private string? _adminJwt = null; + public string AdminJwt => _adminJwt.ShouldNotBeNull(); static IntegrationFixture() { @@ -31,7 +33,7 @@ public async Task InitializeAsync() { Directory.CreateDirectory(BasePath); InitTemplateRepo(); - await AdminApiTester.LoginAs(AdminAuth.Username, AdminAuth.Password); + _adminJwt = await AdminApiTester.LoginAs(AdminAuth.Username, AdminAuth.Password); } public Task DisposeAsync() diff --git a/backend/Testing/GraphQL/LexAuthUserOutOfSyncExtensionsTests.cs b/backend/Testing/GraphQL/LexAuthUserOutOfSyncExtensionsTests.cs new file mode 100644 index 000000000..b8996ea79 --- /dev/null +++ b/backend/Testing/GraphQL/LexAuthUserOutOfSyncExtensionsTests.cs @@ -0,0 +1,217 @@ +using LexBoxApi.GraphQL; +using LexCore.Auth; +using LexCore.Entities; +using Shouldly; + +namespace Testing.GraphQL; + +public class LexAuthUserOutOfSyncExtensionsTests +{ + private static readonly LexAuthUser user = new() + { + Id = Guid.NewGuid(), + Name = "Test User", + Role = UserRole.user, + Projects = [], + Locale = "en", + }; + + [Fact] + public void DetectsUserAddedToProject() + { + var project = NewProject(); + user.IsOutOfSyncWithProject(project).ShouldBeFalse(); + + project.Users.Add(new() { UserId = user.Id, Role = ProjectRole.Editor }); + user.IsOutOfSyncWithProject(project).ShouldBeTrue(); + } + + [Fact] + public void DetectsUserRemovedFromProject() + { + var project = NewProject(); + project.Users.Add(new() { UserId = user.Id, Role = ProjectRole.Editor }); + var editorUser = user with { Projects = [new AuthUserProject(ProjectRole.Editor, project.Id)] }; + editorUser.IsOutOfSyncWithProject(project).ShouldBeFalse(); + + project.Users.Clear(); + editorUser.IsOutOfSyncWithProject(project).ShouldBeTrue(); + } + + [Fact] + public void DetectsUserProjectRoleChanged() + { + var project = NewProject(); + var projectUser = new ProjectUsers { UserId = user.Id, Role = ProjectRole.Editor }; + project.Users.Add(projectUser); + var editorUser = user with { Projects = [new AuthUserProject(ProjectRole.Editor, project.Id)] }; + editorUser.IsOutOfSyncWithProject(project).ShouldBeFalse(); + + projectUser.Role = ProjectRole.Manager; + editorUser.IsOutOfSyncWithProject(project).ShouldBeTrue(); + } + + [Fact] + public void DoesNotDetectsUserProjectRoleChangedIfRolesNotAvailable() + { + var project = NewProject(); + var projectUser = new ProjectUsers { UserId = user.Id, Role = ProjectRole.Unknown }; + project.Users.Add(projectUser); + var editorUser = user with { Projects = [new AuthUserProject(ProjectRole.Editor, project.Id)] }; + editorUser.IsOutOfSyncWithProject(project).ShouldBeFalse(); // might be out of sync, but we can't tell + } + + [Fact] + public void DoesNotDetectChangesWithoutProjectUsersIfNotMyProject() + { + var project = NewProject(); + // simulate Users not projected in GQL query + project.Users = null!; + + var editorUser = user with { Projects = [new AuthUserProject(ProjectRole.Editor, project.Id)] }; + editorUser.IsOutOfSyncWithProject(project).ShouldBeFalse(); // might be out of sync, but we can't tell + } + + [Fact] + public void DetectsAddedToMyProjectWithoutProjectUsers() + { + var project = NewProject(); + // simulate Users not projected in GQL query + project.Users = null!; + + user.IsOutOfSyncWithProject(project).ShouldBeFalse(); // might be out of sync, but we can't tell + user.IsOutOfSyncWithProject(project, isMyProject: true).ShouldBeTrue(); + user.IsOutOfSyncWithMyProjects([project]).ShouldBeTrue(); + } + + [Fact] + public void DetectsRemovedFromMyProjectWithoutProjectUsers() + { + var project = NewProject(); + // simulate Users not projected in GQL query + project.Users = null!; + + var editorUser = user with { Projects = [new AuthUserProject(ProjectRole.Editor, project.Id)] }; + editorUser.IsOutOfSyncWithProject(project).ShouldBeFalse(); // might be out of sync, but we can't tell + editorUser.IsOutOfSyncWithMyProjects([]).ShouldBeTrue(); + } + + [Fact] + public void DetectsUserAddedToOrg() + { + var org = NewOrg(); + user.IsOutOfSyncWithOrg(org).ShouldBeFalse(); + + org.Members.Add(new() { UserId = user.Id, Role = OrgRole.User }); + user.IsOutOfSyncWithOrg(org).ShouldBeTrue(); + } + + [Fact] + public void DetectsUserRemovedFromOrg() + { + var org = NewOrg(); + org.Members.Add(new() { UserId = user.Id, Role = OrgRole.User }); + var editorUser = user with { Orgs = [new AuthUserOrg(OrgRole.User, org.Id)] }; + editorUser.IsOutOfSyncWithOrg(org).ShouldBeFalse(); + + org.Members.Clear(); + editorUser.IsOutOfSyncWithOrg(org).ShouldBeTrue(); + } + + [Fact] + public void DetectsUserOrgRoleChanged() + { + var org = NewOrg(); + var orgUser = new OrgMember { UserId = user.Id, Role = OrgRole.User }; + org.Members.Add(orgUser); + var editorUser = user with { Orgs = [new AuthUserOrg(OrgRole.User, org.Id)] }; + editorUser.IsOutOfSyncWithOrg(org).ShouldBeFalse(); + + orgUser.Role = OrgRole.Admin; + editorUser.IsOutOfSyncWithOrg(org).ShouldBeTrue(); + } + + [Fact] + public void DoesNotDetectsUserOrgRoleChangedIfRolesNotAvailable() + { + var org = NewOrg(); + var orgUser = new OrgMember { UserId = user.Id, Role = OrgRole.Unknown }; + org.Members.Add(orgUser); + var editorUser = user with { Orgs = [new AuthUserOrg(OrgRole.User, org.Id)] }; + editorUser.IsOutOfSyncWithOrg(org).ShouldBeFalse(); // might be out of sync, but we can't tell + } + + [Fact] + public void DetectsChangesWithOrgProjects() + { + var org = NewOrg(); + var project = NewProject(); + org.Projects = [project]; + user.IsOutOfSyncWithOrg(org).ShouldBeFalse(); + + project.Users.Add(new() { UserId = user.Id, Role = ProjectRole.Editor }); + user.IsOutOfSyncWithOrg(org).ShouldBeTrue(); + } + + [Fact] + public void DoesNotDetectChangesWithoutOrgMembersIfNotMyOrg() + { + var org = NewOrg(); + // simulate Members not projected in GQL query + org.Members = null!; + + var editorUser = user with { Orgs = [new AuthUserOrg(OrgRole.User, org.Id)] }; + editorUser.IsOutOfSyncWithOrg(org).ShouldBeFalse(); // might be out of sync, but we can't tell + } + + [Fact] + public void DetectsAddedToMyOrgWithoutOrgMembers() + { + var org = NewOrg(); + // simulate Members not projected in GQL query + org.Members = null!; + + user.IsOutOfSyncWithOrg(org).ShouldBeFalse(); // might be out of sync, but we can't tell + user.IsOutOfSyncWithOrg(org, isMyOrg: true).ShouldBeTrue(); + user.IsOutOfSyncWithMyOrgs([org]).ShouldBeTrue(); + } + + [Fact] + public void DetectsRemovedFromMyOrgWithoutOrgMembers() + { + var org = NewOrg(); + // simulate Members not projected in GQL query + org.Members = null!; + + var editorUser = user with { Orgs = [new AuthUserOrg(OrgRole.User, org.Id)] }; + editorUser.IsOutOfSyncWithOrg(org).ShouldBeFalse(); // might be out of sync, but we can't tell + editorUser.IsOutOfSyncWithMyOrgs([]).ShouldBeTrue(); + } + + private static Project NewProject() + { + return new() + { + Id = Guid.NewGuid(), + Name = "Project 1", + Code = "project1", + Type = ProjectType.FLEx, + Users = [], + Organizations = [], + IsConfidential = false, + LastCommit = DateTimeOffset.UtcNow, + RetentionPolicy = RetentionPolicy.Dev, + }; + } + + private static Organization NewOrg() + { + return new() + { + Id = Guid.NewGuid(), + Name = "Organization 1", + Members = [], + Projects = [], + }; + } +} diff --git a/backend/Testing/LexCore/Utils/GqlUtils.cs b/backend/Testing/LexCore/Utils/GqlUtils.cs index 4d82ee82c..b67c4597a 100644 --- a/backend/Testing/LexCore/Utils/GqlUtils.cs +++ b/backend/Testing/LexCore/Utils/GqlUtils.cs @@ -19,5 +19,25 @@ public static void ValidateGqlErrors(JsonObject json, bool expectError = false) } } } + else + { + var foundError = json["errors"] is JsonArray errors && errors.Count > 0; + if (!foundError) + { + if (json["data"] is JsonObject data) + { + foreach (var (_, resultValue) in data) + { + if (resultValue is JsonObject resultObject) + { + foundError = resultObject["errors"] is JsonArray resultErrors && resultErrors.Count > 0; + if (foundError) + break; + } + } + } + } + foundError.ShouldBeTrue(); + } } } diff --git a/backend/Testing/Services/JwtHelper.cs b/backend/Testing/Services/JwtHelper.cs index 63b4d157b..af6a8172a 100644 --- a/backend/Testing/Services/JwtHelper.cs +++ b/backend/Testing/Services/JwtHelper.cs @@ -57,18 +57,26 @@ public static async Task ExecuteLogin(SendReceiveAuth auth, public static string GetJwtFromLoginResponse(HttpResponseMessage response) { - response.EnsureSuccessStatusCode(); - var cookies = response.Headers.GetValues("Set-Cookie"); - var cookieContainer = new CookieContainer(); - cookieContainer.SetCookies(response.RequestMessage!.RequestUri!, cookies.Single()); - var authCookie = cookieContainer.GetAllCookies() - .FirstOrDefault(c => c.Name == AuthKernel.AuthCookieName); - authCookie.ShouldNotBeNull(); - var jwt = authCookie.Value; + TryGetJwtFromLoginResponse(response, out var jwt); jwt.ShouldNotBeNullOrEmpty(); return jwt; } + public static bool TryGetJwtFromLoginResponse(HttpResponseMessage response, out string? jwt) + { + jwt = null; + response.EnsureSuccessStatusCode(); + if (response.Headers.TryGetValues("Set-Cookie", out var cookies)) + { + var cookieContainer = new CookieContainer(); + cookieContainer.SetCookies(response.RequestMessage!.RequestUri!, cookies.Single()); + var authCookie = cookieContainer.GetAllCookies() + .FirstOrDefault(c => c.Name == AuthKernel.AuthCookieName); + jwt = authCookie?.Value; + } + return jwt is not null; + } + public static void ClearCookies(SocketsHttpHandler httpClientHandler) { foreach (Cookie cookie in httpClientHandler.CookieContainer.GetAllCookies()) diff --git a/backend/Testing/Services/Utils.cs b/backend/Testing/Services/Utils.cs index eda0b41a5..dfea6d1bd 100644 --- a/backend/Testing/Services/Utils.cs +++ b/backend/Testing/Services/Utils.cs @@ -80,7 +80,8 @@ public static async Task AddMemberToProject( ProjectConfig config, ApiTestBase apiTester, string usernameOrEmail, - ProjectRole role + ProjectRole role, + string? overrideJwt = null ) { await apiTester.ExecuteGql($$""" @@ -105,7 +106,7 @@ ... on InvalidEmailError { } } } - """); + """, overrideJwt: overrideJwt); } public static void ValidateSendReceiveOutput(string srOutput) diff --git a/backend/Testing/Taskfile.yml b/backend/Testing/Taskfile.yml index 7e3f20b81..0f36ef689 100644 --- a/backend/Testing/Taskfile.yml +++ b/backend/Testing/Taskfile.yml @@ -6,24 +6,28 @@ vars: tasks: unit: + interactive: true vars: FILTER: '{{default "." .CLI_ARGS}}' cmds: - dotnet test --filter="Category!=Integration&Category!=FlakyIntegration&{{.FILTER}}" integration: + interactive: true vars: FILTER: '{{default "." .CLI_ARGS}}' cmds: - dotnet test --filter="Category=Integration&FullyQualifiedName!~Testing.Browser&{{.FILTER}}" --results-directory ./test-results --logger trx flaky-integration: + interactive: true vars: FILTER: '{{default "." .CLI_ARGS}}' cmds: - dotnet test --filter="Category=FlakyIntegration&FullyQualifiedName!~Testing.Browser&{{.FILTER}}" --results-directory ./test-results --logger trx integration-env: + interactive: true dotenv: [ local.env ] env: # TEST_DEFAULT_PASSWORD: *** @@ -35,6 +39,7 @@ tasks: - task integration -- {{.CLI_ARGS}} flaky-integration-env: + interactive: true dotenv: [ local.env ] env: # TEST_DEFAULT_PASSWORD: *** diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 66620c933..30d71fdc4 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -211,6 +211,7 @@ type LeaveProjectPayload { } type LexAuthUser { + isProjectMember(projectId: UUID! role: ProjectRole): Boolean! id: UUID! updatedDate: Long! audience: LexboxAudience! @@ -277,19 +278,19 @@ type NotFoundError implements Error { } type OrgById { + id: UUID! members: [OrgByIdMember!]! name: String! projects: [Project!]! memberCount: Int! projectCount: Int! - id: UUID! createdDate: DateTime! updatedDate: DateTime! } type OrgByIdMember { - user: OrgByIdUser! userId: UUID! + user: OrgByIdUser! orgId: UUID! role: OrgRole! organization: Organization @@ -409,9 +410,9 @@ type ProjectMembersMustBeVerifiedForRole implements Error { } type ProjectUsers { + userId: UUID! user: User! project: Project! - userId: UUID! projectId: UUID! role: ProjectRole! id: UUID! diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte index b10792ae9..7d8660a65 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte +++ b/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte @@ -64,7 +64,7 @@ $: isEmpty = project?.lastCommit == null; // TODO: Once we've stabilized the lastCommit issue with project reset, get rid of the next line $: if (! $changesetStore.fetching) isEmpty = $changesetStore.changesets.length === 0; - $: members = project.users?.sort((a, b) => { + $: members = project.users.sort((a, b) => { if (a.role !== b.role) { return a.role === ProjectRole.Manager ? -1 : 1; } @@ -118,12 +118,12 @@ $: orgRoles = project.organizations ?.map((o) => user.orgs?.find((org) => org.orgId === o.id)?.role) .filter(r => !!r) ?? []; - $: projectRole = project?.users?.find((u) => u.user.id == user.id)?.role; + $: projectRole = project?.users.find((u) => u.user.id == user.id)?.role; // Mirrors PermissionService.CanViewProjectMembers() in C# - $: canViewProjectMembers = user.isAdmin + $: canViewOtherMembers = user.isAdmin || projectRole == ProjectRole.Manager - || projectRole == ProjectRole.Editor && !project.isConfidential + || projectRole && !project.isConfidential // public by default for members (non-members shouldn't even be here) || orgRoles.some(role => role === OrgRole.Admin); let resetProjectModal: ResetProjectModal; @@ -178,7 +178,7 @@ $: userId = user.id; $: orgsManagedByUser = user.orgs.filter(o => o.role === OrgRole.Admin).map(o => o.orgId); - $: canManage = user.isAdmin || project?.users?.find((u) => u.user.id == userId)?.role == ProjectRole.Manager || !!project?.organizations?.find((o) => orgsManagedByUser.includes(o.id)); + $: canManage = user.isAdmin || project?.users.find((u) => u.user.id == userId)?.role == ProjectRole.Manager || !!project?.organizations?.find((o) => orgsManagedByUser.includes(o.id)); const projectNameValidation = z.string().trim().min(1, $t('project_page.project_name_empty_error')); @@ -486,37 +486,35 @@ {$t('project_page.confirm_remove_org', {orgName: orgToRemove})} - {#if members} - canManage && (member.user?.id !== userId || user.isAdmin)} - canManageList={canManage} - canViewMembers={canViewProjectMembers} - on:openUserModal={(event) => userModal.open(event.detail.user)} - on:deleteProjectUser={(event) => deleteProjectUser(event.detail)} + canManage && (member.user?.id !== userId || user.isAdmin)} + canManageList={canManage} + {canViewOtherMembers} + on:openUserModal={(event) => userModal.open(event.detail.user)} + on:deleteProjectUser={(event) => deleteProjectUser(event.detail)} + > + + addProjectMember.openModal(undefined, undefined)}> + {$t('project_page.add_user.add_button')} + + + + + + + + - - addProjectMember.openModal(undefined, undefined)}> - {$t('project_page.add_user.add_button')} - - - - - - - - - {$t('project_page.confirm_remove', { - userName: userToDelete?.user.name ?? '', - })} - - - {/if} + {$t('project_page.confirm_remove', { + userName: userToDelete?.user.name ?? '', + })} + +

diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts b/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts index cde428c3c..e245a86f8 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts +++ b/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts @@ -25,12 +25,12 @@ import type { UpdateProjectLanguageListMutation, UpdateProjectLexEntryCountMutation, } from '$lib/gql/types'; -import { getClient, graphql } from '$lib/gql'; +import {getClient, graphql} from '$lib/gql'; -import type { PageLoadEvent } from './$types'; -import { derived } from 'svelte/store'; -import { error } from '@sveltejs/kit'; -import { tryMakeNonNullable } from '$lib/util/store'; +import type {PageLoadEvent} from './$types'; +import {derived} from 'svelte/store'; +import {error} from '@sveltejs/kit'; +import {tryMakeNonNullable} from '$lib/util/store'; export type Project = NonNullable; export type ProjectUser = NonNullable[number]; @@ -41,13 +41,10 @@ export async function load(event: PageLoadEvent) { const client = getClient(); const user = (await event.parent()).user; const projectCode = event.params.project_code; - const projectId = event.url.searchParams.get('id') ?? ''; - //projectId is not required, so if it's not there we assume the user is a member, if we're wrong there will be an error - const userIsMember = projectId === '' ? true : (user.isAdmin || user.projects.some(p => p.projectId === projectId)); const projectResult = await client .awaitedQueryStore(event.fetch, graphql(` - query projectPage($projectCode: String!, $userIsAdmin: Boolean!, $userIsMember: Boolean!) { + query projectPage($projectCode: String!, $userIsAdmin: Boolean!) { projectByCode(code: $projectCode) { id name @@ -63,28 +60,26 @@ export async function load(event: PageLoadEvent) { organizations { id } - ... on Project @include(if: $userIsMember) { - users { + users { + id + role + user { id - role - user { - id - name - ... on User @include(if: $userIsAdmin) { - locked - username - createdDate - updatedDate - email - localizationCode - lastActive - canCreateProjects - isAdmin - emailVerified - createdBy { - id - name - } + name + ... on User @include(if: $userIsAdmin) { + locked + username + createdDate + updatedDate + email + localizationCode + lastActive + canCreateProjects + isAdmin + emailVerified + createdBy { + id + name } } } @@ -112,7 +107,7 @@ export async function load(event: PageLoadEvent) { } } `), - { projectCode, userIsAdmin: user.isAdmin, userIsMember } + { projectCode, userIsAdmin: user.isAdmin } ); const changesetResultStore = client .queryStore(event.fetch, diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/MembersList.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/MembersList.svelte index 40e6b3e57..3ff873bb1 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/MembersList.svelte +++ b/frontend/src/routes/(authenticated)/project/[project_code]/MembersList.svelte @@ -27,7 +27,7 @@ export let canManageMember: (member: Member) => boolean; export let canManageList: boolean; export let projectId: string; - export let canViewMembers: boolean; + export let canViewOtherMembers: boolean; const dispatch = createEventDispatcher<{ openUserModal: Member; @@ -81,7 +81,7 @@

{$t('project_page.members.title')} - {#if !canViewMembers} + {#if !canViewOtherMembers}