From a928b89697878a3cd614e077563c5a5c3137defe Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 4 Jul 2024 13:39:04 +0700 Subject: [PATCH] Org page improvements (#888) * 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 Co-authored-by: Kevin Hahn --- Taskfile.yml | 2 + backend/LexBoxApi/Auth/LexAuthService.cs | 3 + .../Controllers/IntegrationController.cs | 2 +- .../CustomTypes/OrgGqlConfiguration.cs | 41 +++ .../GraphQL/CustomTypes/OrgMemberDto.cs | 24 ++ .../RefreshJwtOrgMembershipMiddleware.cs | 75 +++++ .../RefreshJwtProjectMembershipMiddleware.cs | 6 +- .../LexBoxApi/GraphQL/GraphQlSetupKernel.cs | 1 + backend/LexBoxApi/GraphQL/LexQueries.cs | 62 +++- .../LexBoxApi/Services/PermissionService.cs | 63 ++++- backend/LexCore/Auth/LexAuthConstants.cs | 1 + backend/LexCore/Auth/LexAuthUser.cs | 8 + .../ServiceInterfaces/IPermissionService.cs | 14 +- backend/LexData/DataKernel.cs | 3 + backend/LexData/LexBoxDbContext.cs | 1 + backend/LexData/SeedingData.cs | 38 ++- ...serHasAccessToProjectRequirementHandler.cs | 2 +- backend/Testing/ApiTests/ApiTestBase.cs | 9 +- .../Testing/ApiTests/OrgPermissionTests.cs | 267 ++++++++++++++++++ backend/Testing/LexCore/Utils/GqlUtils.cs | 3 +- .../init-repos/hg-deployment-patch.yaml | 9 + frontend/schema.graphql | 67 ++++- frontend/src/lib/user.ts | 5 +- .../src/routes/(authenticated)/admin/+page.ts | 4 + .../(authenticated)/org/[org_id]/+page.svelte | 28 +- .../(authenticated)/org/[org_id]/+page.ts | 59 ++-- .../org/[org_id]/OrgMemberTable.svelte | 19 +- .../org/[org_id]/OrgTabs.svelte | 4 +- 28 files changed, 734 insertions(+), 86 deletions(-) create mode 100644 backend/LexBoxApi/GraphQL/CustomTypes/OrgMemberDto.cs create mode 100644 backend/LexBoxApi/GraphQL/CustomTypes/RefreshJwtOrgMembershipMiddleware.cs create mode 100644 backend/Testing/ApiTests/OrgPermissionTests.cs diff --git a/Taskfile.yml b/Taskfile.yml index 609f18d6b..399b9f31f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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 diff --git a/backend/LexBoxApi/Auth/LexAuthService.cs b/backend/LexBoxApi/Auth/LexAuthService.cs index 84b830f78..0604476cb 100644 --- a/backend/LexBoxApi/Auth/LexAuthService.cs +++ b/backend/LexBoxApi/Auth/LexAuthService.cs @@ -90,6 +90,8 @@ public async Task 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) { @@ -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); } diff --git a/backend/LexBoxApi/Controllers/IntegrationController.cs b/backend/LexBoxApi/Controllers/IntegrationController.cs index cf3d77b3e..c4f35e477 100644 --- a/backend/LexBoxApi/Controllers/IntegrationController.cs +++ b/backend/LexBoxApi/Controllers/IntegrationController.cs @@ -35,7 +35,7 @@ public class IntegrationController( [ProducesResponseType(StatusCodes.Status302Found)] public async Task 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); diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs b/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs index 1d77fe834..694f6f400 100644 --- a/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs +++ b/backend/LexBoxApi/GraphQL/CustomTypes/OrgGqlConfiguration.cs @@ -1,3 +1,4 @@ +using LexBoxApi.Auth.Attributes; using LexCore.Entities; namespace LexBoxApi.GraphQL.CustomTypes; @@ -8,5 +9,45 @@ public class OrgGqlConfiguration : ObjectType 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(); + } +} + +/// +/// used to override some configuration for only the OrgById query +/// +[ObjectType] +public class OrgByIdGqlConfiguration : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name("OrgById"); + descriptor.Field(o => o.Members).Type(ListType(memberDescriptor => + { + memberDescriptor.Name("OrgByIdMember"); + memberDescriptor.Field(member => member.User).Type(ObjectType(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(Action> configure) + { + return new NonNullType(new ObjectType(configure)); + } + + private static IOutputType ListType(Action> configure) + { + return new NonNullType(new ListType(ObjectType(configure))); } } diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/OrgMemberDto.cs b/backend/LexBoxApi/GraphQL/CustomTypes/OrgMemberDto.cs new file mode 100644 index 000000000..5aedeb0b9 --- /dev/null +++ b/backend/LexBoxApi/GraphQL/CustomTypes/OrgMemberDto.cs @@ -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; } +} diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/RefreshJwtOrgMembershipMiddleware.cs b/backend/LexBoxApi/GraphQL/CustomTypes/RefreshJwtOrgMembershipMiddleware.cs new file mode 100644 index 000000000..b97fa968e --- /dev/null +++ b/backend/LexBoxApi/GraphQL/CustomTypes/RefreshJwtOrgMembershipMiddleware.cs @@ -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().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 index 6699a85c6..2142f2150 100644 --- a/backend/LexBoxApi/GraphQL/CustomTypes/RefreshJwtProjectMembershipMiddleware.cs +++ b/backend/LexBoxApi/GraphQL/CustomTypes/RefreshJwtProjectMembershipMiddleware.cs @@ -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); @@ -66,12 +64,12 @@ public async Task InvokeAsync(IMiddlewareContext context) private static async Task RefreshUser(IMiddlewareContext context, Guid userId) { var lexAuthService = context.Service(); - 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); } } diff --git a/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs b/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs index 1f55fc6ea..6340ad788 100644 --- a/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs +++ b/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs @@ -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()) diff --git a/backend/LexBoxApi/GraphQL/LexQueries.cs b/backend/LexBoxApi/GraphQL/LexQueries.cs index 8607d0524..3687cd4e4 100644 --- a/backend/LexBoxApi/GraphQL/LexQueries.cs +++ b/backend/LexBoxApi/GraphQL/LexQueries.cs @@ -1,3 +1,4 @@ +using HotChocolate.Resolvers; using LexBoxApi.Auth; using LexBoxApi.Auth.Attributes; using LexBoxApi.GraphQL.CustomTypes; @@ -55,9 +56,9 @@ public IQueryable DraftProjects(LexBoxDbContext context) [UseSingleOrDefault] [UseProjection] - public IQueryable ProjectById(LexBoxDbContext context, IPermissionService permissionService, Guid projectId) + public async Task> ProjectById(LexBoxDbContext context, IPermissionService permissionService, Guid projectId) { - permissionService.AssertCanAccessProject(projectId); + await permissionService.AssertCanViewProject(projectId); return context.Projects.Where(p => p.Id == projectId); } @@ -65,7 +66,7 @@ public IQueryable ProjectById(LexBoxDbContext context, IPermissionServi [UseProjection] public async Task> ProjectByCode(LexBoxDbContext context, IPermissionService permissionService, string code) { - await permissionService.AssertCanAccessProject(code); + await permissionService.AssertCanViewProject(code); return context.Projects.Where(p => p.Code == code); } @@ -94,11 +95,31 @@ public IQueryable MyOrgs(LexBoxDbContext context, LoggedInContext return context.Orgs.Where(o => o.Members.Any(m => m.UserId == userId)); } - [UseSingleOrDefault] [UseProjection] - public IQueryable OrgById(LexBoxDbContext context, Guid orgId) + [GraphQLType] + public async Task 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] @@ -126,6 +147,35 @@ public IQueryable Users(LexBoxDbContext context) }; } + public async Task 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; diff --git a/backend/LexBoxApi/Services/PermissionService.cs b/backend/LexBoxApi/Services/PermissionService.cs index 9f0994f12..05d5ec8a7 100644 --- a/backend/LexBoxApi/Services/PermissionService.cs +++ b/backend/LexBoxApi/Services/PermissionService.cs @@ -2,38 +2,67 @@ using LexCore.Auth; using LexCore.Entities; using LexCore.ServiceInterfaces; +using LexData; namespace LexBoxApi.Services; public class PermissionService( LoggedInContext loggedInContext, + LexBoxDbContext dbContext, ProjectService projectService) : IPermissionService { private LexAuthUser? User => loggedInContext.MaybeUser; - public async ValueTask CanAccessProject(string projectCode) + public async ValueTask CanSyncProject(string projectCode) { if (User is null) return false; if (User.Role == UserRole.admin) return true; - return CanAccessProject(await projectService.LookupProjectId(projectCode)); + return CanSyncProject(await projectService.LookupProjectId(projectCode)); } - public bool CanAccessProject(Guid projectId) + 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); } - public async ValueTask AssertCanAccessProject(string projectCode) + public async ValueTask AssertCanSyncProject(string projectCode) { - if (!await CanAccessProject(projectCode)) throw new UnauthorizedAccessException(); + if (!await CanSyncProject(projectCode)) throw new UnauthorizedAccessException(); } - public void AssertCanAccessProject(Guid projectId) + public void AssertCanSyncProject(Guid projectId) { - if (!CanAccessProject(projectId)) throw new UnauthorizedAccessException(); + if (!CanSyncProject(projectId)) throw new UnauthorizedAccessException(); + } + + public async ValueTask CanViewProject(Guid projectId) + { + 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 project = await dbContext.Projects.FindAsync(projectId); + if (project is null) return false; + if (project.IsConfidential is null) return false; // Private by default + return project.IsConfidential == false; // Explicitly set to public + } + + public async ValueTask AssertCanViewProject(Guid projectId) + { + if (!await CanViewProject(projectId)) throw new UnauthorizedAccessException(); + } + + public async ValueTask CanViewProject(string projectCode) + { + if (User is not null && User.Role == UserRole.admin) return true; + return await CanViewProject(await projectService.LookupProjectId(projectCode)); + } + + public async ValueTask AssertCanViewProject(string projectCode) + { + if (!await CanViewProject(projectCode)) throw new UnauthorizedAccessException(); } public bool CanManageProject(Guid projectId) @@ -101,12 +130,24 @@ public void AssertCanCreateOrg() if (!HasProjectCreatePermission()) throw new UnauthorizedAccessException(); } + public bool IsOrgMember(Guid orgId) + { + if (User is null) return false; + if (User.Orgs.Any(o => o.OrgId == orgId)) return true; + return false; + } + + public bool CanEditOrg(Guid orgId) + { + 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; + } + public void AssertCanEditOrg(Organization org) { - if (User is null) throw new UnauthorizedAccessException(); - if (User.Role == UserRole.admin) return; - if (org.Members.Any(m => m.UserId == User.Id && m.Role == OrgRole.Admin)) return; - throw new UnauthorizedAccessException(); + if (!CanEditOrg(org.Id)) throw new UnauthorizedAccessException(); } public void AssertCanAddProjectToOrg(Organization org) diff --git a/backend/LexCore/Auth/LexAuthConstants.cs b/backend/LexCore/Auth/LexAuthConstants.cs index 6f6e15c6e..c5c4e398a 100644 --- a/backend/LexCore/Auth/LexAuthConstants.cs +++ b/backend/LexCore/Auth/LexAuthConstants.cs @@ -9,6 +9,7 @@ public static class LexAuthConstants public const string IdClaimType = "sub"; public const string AudienceClaimType = "aud"; public const string ProjectsClaimType = "proj"; + public const string OrgsClaimType = "orgs"; public const string IsLockedClaimType = "lock"; public const string EmailUnverifiedClaimType = "unver"; public const string CanCreateProjectClaimType = "mkproj"; diff --git a/backend/LexCore/Auth/LexAuthUser.cs b/backend/LexCore/Auth/LexAuthUser.cs index 31e400223..eb119f64b 100644 --- a/backend/LexCore/Auth/LexAuthUser.cs +++ b/backend/LexCore/Auth/LexAuthUser.cs @@ -95,6 +95,9 @@ public LexAuthUser(User user) Projects = user.IsAdmin ? Array.Empty() // admins have access to all projects, so we don't include them to prevent going over the jwt limit : user.Projects.Select(p => new AuthUserProject(p.Role, p.ProjectId)).ToArray(); + Orgs = user.IsAdmin + ? Array.Empty() // likewise, admins have access to all orgs, so we don't include them + : user.Organizations.Select(p => new AuthUserOrg(p.Role, p.OrgId)).ToArray(); EmailVerificationRequired = user.EmailVerified ? null : true; CanCreateProjects = user.CanCreateProjects ? true : null; CreatedByAdmin = user.CreatedById == null ? null : true; @@ -128,6 +131,9 @@ public LexAuthUser(User user) [JsonIgnore] public AuthUserProject[] Projects { get; set; } = Array.Empty(); + [JsonPropertyName(LexAuthConstants.OrgsClaimType)] + public AuthUserOrg[] Orgs { get; set; } = Array.Empty(); + [JsonPropertyName(LexAuthConstants.ProjectsClaimType)] public string ProjectsJson { @@ -232,6 +238,8 @@ public ClaimsPrincipal GetPrincipal(string authenticationType) public record AuthUserProject(ProjectRole Role, Guid ProjectId); +public record AuthUserOrg(OrgRole Role, Guid OrgId); + [JsonConverter(typeof(JsonStringEnumConverter))] public enum UserRole { diff --git a/backend/LexCore/ServiceInterfaces/IPermissionService.cs b/backend/LexCore/ServiceInterfaces/IPermissionService.cs index ffe8c136c..36c7320cb 100644 --- a/backend/LexCore/ServiceInterfaces/IPermissionService.cs +++ b/backend/LexCore/ServiceInterfaces/IPermissionService.cs @@ -4,10 +4,14 @@ namespace LexCore.ServiceInterfaces; public interface IPermissionService { - ValueTask CanAccessProject(string projectCode); - bool CanAccessProject(Guid projectId); - ValueTask AssertCanAccessProject(string projectCode); - void AssertCanAccessProject(Guid projectId); + ValueTask CanSyncProject(string projectCode); + bool CanSyncProject(Guid projectId); + ValueTask AssertCanSyncProject(string projectCode); + void AssertCanSyncProject(Guid projectId); + ValueTask CanViewProject(Guid projectId); + ValueTask AssertCanViewProject(Guid projectId); + ValueTask CanViewProject(string projectCode); + ValueTask AssertCanViewProject(string projectCode); bool CanManageProject(Guid projectId); void AssertCanManageProject(Guid projectId); void AssertCanManageProjectMemberRole(Guid projectId, Guid userId); @@ -19,6 +23,8 @@ public interface IPermissionService void AssertHasProjectRequestPermission(); void AssertCanLockOrUnlockUser(Guid userId); void AssertCanCreateOrg(); + bool IsOrgMember(Guid orgId); + bool CanEditOrg(Guid orgId); void AssertCanEditOrg(Organization org); void AssertCanAddProjectToOrg(Organization org); } diff --git a/backend/LexData/DataKernel.cs b/backend/LexData/DataKernel.cs index 90a9474d8..861252845 100644 --- a/backend/LexData/DataKernel.cs +++ b/backend/LexData/DataKernel.cs @@ -18,6 +18,9 @@ public static void AddLexData(this IServiceCollection services, options.UseNpgsql(serviceProvider.GetRequiredService>().Value.LexBoxConnectionString); options.UseProjectables(); options.UseOpenIddict(); +#if DEBUG + options.EnableSensitiveDataLogging(); +#endif }, dbContextLifeTime); services.AddLogging(); services.AddHealthChecks() diff --git a/backend/LexData/LexBoxDbContext.cs b/backend/LexData/LexBoxDbContext.cs index 27489c257..4a06c4a99 100644 --- a/backend/LexData/LexBoxDbContext.cs +++ b/backend/LexData/LexBoxDbContext.cs @@ -29,6 +29,7 @@ protected override void ConfigureConventions(ModelConfigurationBuilder builder) public DbSet ProjectUsers => Set(); public DbSet DraftProjects => Set(); public DbSet Orgs => Set(); + public DbSet OrgMembers => Set(); public DbSet OrgProjects => Set(); public async Task HeathCheck(CancellationToken cancellationToken) diff --git a/backend/LexData/SeedingData.cs b/backend/LexData/SeedingData.cs index abea3ece3..af8dbeb23 100644 --- a/backend/LexData/SeedingData.cs +++ b/backend/LexData/SeedingData.cs @@ -21,10 +21,12 @@ public class SeedingData( public static readonly Guid TestAdminId = new("cf430ec9-e721-450a-b6a1-9a853212590b"); public static readonly Guid QaAdminId = new("99b00c58-0dc7-4fe4-b6f2-c27b828811e0"); private static readonly Guid MangerId = new Guid("703701a8-005c-4747-91f2-ac7650455118"); - private static readonly Guid EditorId = new Guid("6dc9965b-4021-4606-92df-133fcce75fcb"); - private static readonly Guid TestOrgId = new Guid("292c80e6-a815-4cd1-9ea2-34bd01274de6"); + public static readonly Guid EditorId = new Guid("6dc9965b-4021-4606-92df-133fcce75fcb"); + public static readonly Guid TestOrgId = new Guid("292c80e6-a815-4cd1-9ea2-34bd01274de6"); private static readonly Guid SecondTestOrgId = new Guid("a748bd8b-6348-4980-8dee-6de8b63e4a39"); - private static readonly Guid Sena3ProjId = new Guid("0ebc5976-058d-4447-aaa7-297f8569f968"); + public static readonly Guid Sena3ProjId = new Guid("0ebc5976-058d-4447-aaa7-297f8569f968"); + public static readonly Guid ElawaProjId = new Guid("9e972940-8a8e-4b29-a609-bdc2f93b3507"); + public static readonly Guid EmptyProjId = new Guid("762b50e8-2e09-4ed4-a48d-775e1ada78e8"); public async Task SeedIfNoUsers(CancellationToken cancellationToken = default) { @@ -149,7 +151,7 @@ private async Task SeedUserData(CancellationToken cancellationToken = default) }); lexBoxDbContext.Attach(new Project { - Id = new Guid("9e972940-8a8e-4b29-a609-bdc2f93b3507"), + Id = ElawaProjId, Name = "Elawa", Description = "Eastern Lawa project", Code = "elawa-dev-flex", @@ -161,6 +163,20 @@ private async Task SeedUserData(CancellationToken cancellationToken = default) Organizations = [], Users = [], }); + lexBoxDbContext.Attach(new Project + { + Id = EmptyProjId, + Name = "Empty", + Description = "Empty project", + Code = "empty-dev-flex", + Type = ProjectType.FLEx, + ProjectOrigin = ProjectMigrationStatus.Migrated, + LastCommit = DateTimeOffset.UtcNow, + RetentionPolicy = RetentionPolicy.Dev, + IsConfidential = true, + Organizations = [], + Users = [], + }); lexBoxDbContext.Attach(new Organization { @@ -209,6 +225,20 @@ private async Task SeedUserData(CancellationToken cancellationToken = default) ProjectId = Sena3ProjId, }); + lexBoxDbContext.Attach(new OrgProjects + { + Id = new Guid("9b642e86-9f72-46db-baa6-0984beb5b815"), + OrgId = TestOrgId, + ProjectId = ElawaProjId, + }); + + lexBoxDbContext.Attach(new OrgProjects + { + Id = new Guid("65ea538d-9692-4456-a82e-04ab89fb6aff"), + OrgId = TestOrgId, + ProjectId = EmptyProjId, + }); + foreach (var entry in lexBoxDbContext.ChangeTracker.Entries()) { var exists = await entry.GetDatabaseValuesAsync(cancellationToken) is not null; diff --git a/backend/SyncReverseProxy/Auth/UserHasAccessToProjectRequirementHandler.cs b/backend/SyncReverseProxy/Auth/UserHasAccessToProjectRequirementHandler.cs index f919864df..fe43f1bd2 100644 --- a/backend/SyncReverseProxy/Auth/UserHasAccessToProjectRequirementHandler.cs +++ b/backend/SyncReverseProxy/Auth/UserHasAccessToProjectRequirementHandler.cs @@ -33,7 +33,7 @@ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext } var permissionService = _httpContextAccessor.HttpContext.RequestServices.GetRequiredService(); - if (!await permissionService.CanAccessProject(projectCode)) + if (!await permissionService.CanSyncProject(projectCode)) { context.Fail(new AuthorizationFailureReason(this, $"User does not have access to project {projectCode}")); return; diff --git a/backend/Testing/ApiTests/ApiTestBase.cs b/backend/Testing/ApiTests/ApiTestBase.cs index 89db65d10..a2f385509 100644 --- a/backend/Testing/ApiTests/ApiTestBase.cs +++ b/backend/Testing/ApiTests/ApiTestBase.cs @@ -21,8 +21,10 @@ public ApiTestBase() }; } - public virtual async Task LoginAs(string user, string password) + // This needs to be virtual so it can be mocked in IntegrationFixtureTests + public virtual async Task LoginAs(string user, string? password = null) { + password ??= TestingEnvironmentVariables.DefaultPassword; var response = await JwtHelper.ExecuteLogin(new SendReceiveAuth(user, password), HttpClient); return JwtHelper.GetJwtFromLoginResponse(response); } @@ -32,13 +34,14 @@ public void ClearCookies() JwtHelper.ClearCookies(_httpClientHandler); } - public async Task ExecuteGql([StringSyntax("graphql")] string gql, bool expectGqlError = false) + public async Task ExecuteGql([StringSyntax("graphql")] string gql, bool expectGqlError = false, bool expectSuccessCode = true) { var response = await HttpClient.PostAsJsonAsync($"{BaseUrl}/api/graphql", new { query = gql }); var jsonResponse = await response.Content.ReadFromJsonAsync(); jsonResponse.ShouldNotBeNull($"for query {gql} ({(int)response.StatusCode} ({response.ReasonPhrase}))"); GqlUtils.ValidateGqlErrors(jsonResponse, expectGqlError); - response.IsSuccessStatusCode.ShouldBeTrue($"code was {(int)response.StatusCode} ({response.ReasonPhrase})"); + if (expectSuccessCode) + response.IsSuccessStatusCode.ShouldBeTrue($"code was {(int)response.StatusCode} ({response.ReasonPhrase})"); return jsonResponse; } diff --git a/backend/Testing/ApiTests/OrgPermissionTests.cs b/backend/Testing/ApiTests/OrgPermissionTests.cs new file mode 100644 index 000000000..fabeab52a --- /dev/null +++ b/backend/Testing/ApiTests/OrgPermissionTests.cs @@ -0,0 +1,267 @@ +using System.Text.Json.Nodes; +using LexData; +using Shouldly; + +namespace Testing.ApiTests; + +[Trait("Category", "Integration")] +public class OrgPermissionTests : ApiTestBase +{ + private async Task QueryOrg(Guid orgId) + { + var json = await ExecuteGql( + $$""" + query { + orgById(orgId: "{{orgId}}") { + name + members { + id + role + user { + id + name + username + email + } + } + projects { + id + isConfidential + } + } + } + """); + return json; + } + + private static JsonObject GetOrg(JsonObject json) + { + var org = json["data"]?["orgById"]?.AsObject(); + org.ShouldNotBeNull(); + return org; + } + + private void MustHaveOneMemberWithEmail(JsonNode org) + { + org["members"]!.AsArray().Where(m => m?["user"]?["email"]?.GetValue() is { Length: > 0 }) + .ShouldNotBeEmpty(); + } + private void MustNotHaveMemberWithEmail(JsonNode org) + { + org["members"]!.AsArray().Where(m => m?["user"]?["email"]?.GetValue() is { Length: > 0 }) + .ShouldBeEmpty(); + } + + private void MustHaveOneMemberWithUsername(JsonNode org) + { + org["members"]!.AsArray().Where(m => m?["user"]?["username"]?.GetValue() is { Length: > 0 }) + .ShouldNotBeEmpty(); + } + private void MustNotHaveMemberWithUsername(JsonNode org) + { + org["members"]!.AsArray().Where(m => m?["user"]?["username"]?.GetValue() is { Length: > 0 }) + .ShouldBeEmpty(); + } + + private void MustHaveUserNames(JsonNode org) + { + org["members"]!.AsArray() + .Where(m => m?["user"]?["name"]?.GetValue() is { Length: > 0 }) + .ShouldNotBeEmpty(); + } + + private void MustContainUser(JsonNode org, Guid id) + { + org["members"]!.AsArray().ShouldContain( + m => m!["user"]!["id"]!.GetValue() == id, + $"org: '{org["name"]}' members were: {org["members"]!.ToJsonString()}"); + } + + private void MustHaveOnlyManagers(JsonNode org) + { + org["members"]!.AsArray() + .Where(m => m?["role"]?.GetValue() is not "ADMIN") + .ShouldBeEmpty(); + } + + private void MustHaveNonManagers(JsonNode org) + { + org["members"]!.AsArray() + .Where(m => m?["role"]?.GetValue() is not "ADMIN") + .ShouldNotBeEmpty(); + } + + [Fact] + public async Task CanNotListOrgsAndListOrgUsers() + { + await LoginAs("manager"); + var json = await ExecuteGql( + """ + query { + orgs { + name + members { + id + role + user { + id + name + } + } + } + } + """, + true, false); + var error = json["errors"]?.AsArray().First()?.AsObject(); + error.ShouldNotBeNull(); + error["extensions"]?["code"]?.GetValue().ShouldBe("AUTH_NOT_AUTHORIZED"); + } + + [Fact] + public async Task CanNotListOrgsAndListOrgProjects() + { + await LoginAs("manager"); + var json = await ExecuteGql( + """ + query { + orgs { + name + projects { + id + } + } + } + """, + true, false); + var error = json["errors"]?.AsArray().First()?.AsObject(); + error.ShouldNotBeNull(); + error["extensions"]?["code"]?.GetValue().ShouldBe("AUTH_NOT_AUTHORIZED"); + } + + [Fact] + public async Task AdminCanQueryOrgMembers() + { + await LoginAs("admin"); + var org = GetOrg(await QueryOrg(SeedingData.TestOrgId)); + MustHaveOneMemberWithEmail(org); + } + + [Fact] + public async Task ManagerCanSeeMemberEmails() + { + await LoginAs("manager"); + var org = GetOrg(await QueryOrg(SeedingData.TestOrgId)); + MustHaveOneMemberWithEmail(org); + } + + [Fact] + public async Task ManagerCanSeeMemberUsernames() + { + await LoginAs("manager"); + var org = GetOrg(await QueryOrg(SeedingData.TestOrgId)); + MustHaveOneMemberWithUsername(org); + } + + [Fact] + public async Task OrgMemberCanSeeThemselvesInOrg() + { + await LoginAs("editor"); + var org = GetOrg(await QueryOrg(SeedingData.TestOrgId)); + org.ShouldNotBeNull(); + MustContainUser(org, SeedingData.EditorId); + } + + [Fact] + public async Task OrgMemberCanNotSeeMemberEmails() + { + await LoginAs("editor"); + var org = GetOrg(await QueryOrg(SeedingData.TestOrgId)); + org.ShouldNotBeNull(); + MustHaveUserNames(org); + MustNotHaveMemberWithEmail(org); + } + + [Fact] + public async Task OrgMemberCanNotSeeMemberUsernames() + { + await LoginAs("editor"); + var org = GetOrg(await QueryOrg(SeedingData.TestOrgId)); + org.ShouldNotBeNull(); + MustHaveUserNames(org); + MustNotHaveMemberWithUsername(org); + } + + [Fact] + public async Task NonMemberCanOnlyQueryManagers() + { + await LoginAs("user"); + var org = GetOrg(await QueryOrg(SeedingData.TestOrgId)); + MustHaveOnlyManagers(org); + MustNotHaveMemberWithEmail(org); + } + + private void MustNotShowConfidentialProjects(JsonNode org) + { + var projects = org["projects"]!.AsArray(); + projects.ShouldNotBeEmpty(); + projects + .Where(p => p?["isConfidential"]?.GetValue() != false) + .ShouldBeEmpty(); + } + + private void MustContainProject(JsonNode org, Guid projectId) + { + var projects = org["projects"]!.AsArray(); + projects.ShouldNotBeEmpty(); + projects.ShouldContain(p => p!["id"]!.GetValue() == projectId, $"project id '{projectId}' should exist in: {projects.ToJsonString()}"); + } + + private void MustNotContainProject(JsonNode org, Guid projectId) + { + var projects = org["projects"]!.AsArray(); + if ((projects?.Count ?? 0) == 0) return; + projects!.ShouldNotContain(p => p!["id"]!.GetValue() == projectId, $"project id '{projectId}' should not exist in: {projects!.ToJsonString()}"); + } + + [Fact] + public async Task NonMembersOnlySeePublicProjects() + { + await LoginAs("user"); + var org = GetOrg(await QueryOrg(SeedingData.TestOrgId)); + MustNotShowConfidentialProjects(org); + } + + [Fact] + public async Task MembersSeePublicAndTheirProjects() + { + await LoginAs("editor"); + var org = GetOrg(await QueryOrg(SeedingData.TestOrgId)); + MustContainProject(org, SeedingData.Sena3ProjId); + MustContainProject(org, SeedingData.ElawaProjId); + MustNotContainProject(org, SeedingData.EmptyProjId); + } + + [Fact] + public async Task ManagersSeeAllProjects() + { + await LoginAs("manager"); + var org = GetOrg(await QueryOrg(SeedingData.TestOrgId)); + MustContainProject(org, SeedingData.Sena3ProjId); + MustContainProject(org, SeedingData.ElawaProjId); + MustContainProject(org, SeedingData.EmptyProjId); + } + + [Fact] + public async Task NonMemberCanJustQueryOrgWithoutMembersOrProjects() + { + await LoginAs("user"); + await ExecuteGql( + $$""" + query { + orgById(orgId: "{{SeedingData.TestOrgId}}") { + name + } + } + """); + } +} diff --git a/backend/Testing/LexCore/Utils/GqlUtils.cs b/backend/Testing/LexCore/Utils/GqlUtils.cs index 9f8b4e2cb..4d82ee82c 100644 --- a/backend/Testing/LexCore/Utils/GqlUtils.cs +++ b/backend/Testing/LexCore/Utils/GqlUtils.cs @@ -14,7 +14,8 @@ public static void ValidateGqlErrors(JsonObject json, bool expectError = false) { foreach (var (_, resultValue) in data) { - resultValue?["errors"].ShouldBeNull(); + if (resultValue is JsonObject resultObject) + resultObject["errors"].ShouldBeNull(); } } } diff --git a/deployment/init-repos/hg-deployment-patch.yaml b/deployment/init-repos/hg-deployment-patch.yaml index aff50e41d..8257b0371 100644 --- a/deployment/init-repos/hg-deployment-patch.yaml +++ b/deployment/init-repos/hg-deployment-patch.yaml @@ -34,6 +34,15 @@ spec: unzip -q /tmp/elawa.zip -d /repos/e/ fi fi + if [ ! -d /repos/e/empty-dev-flex ] && [ ! -d /repos/empty-dev-flex ]; then + if [ -f /init-repos/empty.zip ]; then + unzip -q /init-repos/empty.zip -d /repos/e/ + else + wget -O /tmp/empty.zip 'https://drive.google.com/uc?export=download&id=1p73u-AGdSwNkg_5KEv9-4iLRuN-1V-LD' + unzip -q /tmp/empty.zip -d /repos/e/ + exit 1 + fi + fi volumeMounts: - name: repos mountPath: /repos diff --git a/frontend/schema.graphql b/frontend/schema.graphql index ba45f077b..017234124 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -21,6 +21,11 @@ type AlreadyExistsError implements Error { message: String! } +type AuthUserOrg { + role: OrgRole! + orgId: UUID! +} + type AuthUserProject { role: ProjectRole! projectId: UUID! @@ -179,6 +184,7 @@ type LexAuthUser { role: UserRole! isAdmin: Boolean! projects: [AuthUserProject!]! + orgs: [AuthUserOrg!]! projectsJson: String! locked: Boolean emailVerificationRequired: Boolean @@ -225,6 +231,35 @@ type NotFoundError implements Error { type: String! } +type OrgById { + members: [OrgByIdMember!]! + name: String! + projects: [Project!]! + memberCount: Int! + projectCount: Int! + id: UUID! + createdDate: DateTime! + updatedDate: DateTime! +} + +type OrgByIdMember { + user: OrgByIdUser! + userId: UUID! + orgId: UUID! + role: OrgRole! + organization: Organization + id: UUID! + createdDate: DateTime! + updatedDate: DateTime! +} + +type OrgByIdUser { + id: UUID! + name: String! + username: String + email: String +} + type OrgMember { user: User! organization: Project! @@ -236,6 +271,27 @@ type OrgMember { updatedDate: DateTime! } +type OrgMemberDto { + id: UUID! + createdDate: DateTime! + updatedDate: DateTime! + lastActive: DateTime! + name: String! + email: String + username: String + localizationCode: String! + emailVerified: Boolean! + isAdmin: Boolean! + locked: Boolean! + canCreateProjects: Boolean! + createdBy: OrgMemberDtoCreatedBy +} + +type OrgMemberDtoCreatedBy { + id: UUID! + name: String! +} + type OrgProjects { org: Organization! project: Project! @@ -248,12 +304,12 @@ type OrgProjects { type Organization { createdDate: DateTime! + id: UUID! + members: [OrgMember!]! @authorize(policy: "AdminRequiredPolicy") + projects: [Project!]! @authorize(policy: "AdminRequiredPolicy") name: String! - members: [OrgMember!]! - projects: [Project!]! memberCount: Int! projectCount: Int! - id: UUID! updatedDate: DateTime! } @@ -319,9 +375,10 @@ type Query { draftProjectByCode(code: String!): DraftProject @authorize(policy: "AdminRequiredPolicy") orgs(where: OrganizationFilterInput orderBy: [OrganizationSortInput!]): [Organization!]! myOrgs(where: OrganizationFilterInput orderBy: [OrganizationSortInput!]): [Organization!]! - orgById(orgId: UUID!): Organization + orgById(orgId: UUID!): OrgById users(skip: Int take: Int where: UserFilterInput orderBy: [UserSortInput!]): UsersCollectionSegment @authorize(policy: "AdminRequiredPolicy") me: MeDto + orgMemberById(orgId: UUID! userId: UUID!): OrgMemberDto meAuth: LexAuthUser! testingThrowsError: LexAuthUser! isAdmin: IsAdminResponse! @authorize(policy: "AdminRequiredPolicy") @@ -987,4 +1044,4 @@ scalar UUID scalar timestamptz @specifiedBy(url: "https:\/\/www.graphql-scalars.com\/date-time") -scalar uuid +scalar uuid \ No newline at end of file diff --git a/frontend/src/lib/user.ts b/frontend/src/lib/user.ts index c747938da..47aadf1e5 100644 --- a/frontend/src/lib/user.ts +++ b/frontend/src/lib/user.ts @@ -5,7 +5,7 @@ import { deleteCookie, getCookie } from './util/cookies' import {hash} from '$lib/util/hash'; import { ensureErrorIsTraced, errorSourceTag } from './otel' import zxcvbn from 'zxcvbn'; -import { type AuthUserProject, ProjectRole, UserRole, type CreateGuestUserByAdminInput } from './gql/types'; +import { type AuthUserProject, type AuthUserOrg, ProjectRole, UserRole, type CreateGuestUserByAdminInput } from './gql/types'; import { _createGuestUserByAdmin } from '../routes/(authenticated)/admin/+page'; type LoginError = 'BadCredentials' | 'Locked'; @@ -31,6 +31,7 @@ type JwtTokenUser = { user?: string role: 'admin' | 'user' proj?: string, + orgs?: AuthUserOrg[], lock: boolean | undefined, unver: boolean | undefined, mkproj: boolean | undefined, @@ -47,6 +48,7 @@ export type LexAuthUser = { role: UserRole isAdmin: boolean projects: AuthUserProject[] + orgs: AuthUserOrg[] locked: boolean emailVerified: boolean canCreateProjects: boolean @@ -185,6 +187,7 @@ function jwtToUser(user: JwtTokenUser): LexAuthUser { role, isAdmin: role === UserRole.Admin, projects: projectsStringToProjects(projectsString), + orgs: user.orgs ?? [], locked: user.lock === true, emailVerified: !user.unver, canCreateProjects: user.mkproj === true || role === UserRole.Admin, diff --git a/frontend/src/routes/(authenticated)/admin/+page.ts b/frontend/src/routes/(authenticated)/admin/+page.ts index ff78e8d7f..c73088436 100644 --- a/frontend/src/routes/(authenticated)/admin/+page.ts +++ b/frontend/src/routes/(authenticated)/admin/+page.ts @@ -192,6 +192,10 @@ export async function _createGuestUserByAdmin(input: CreateGuestUserByAdminInput projectId role } + orgs { + orgId + role + } } errors { __typename diff --git a/frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte b/frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte index 6e9b21f11..3de1d91f7 100644 --- a/frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte +++ b/frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte @@ -8,7 +8,7 @@ import type { PageData } from './$types'; import { OrgRole } from '$lib/gql/types'; import { useNotifications } from '$lib/notify'; - import { _changeOrgName, _deleteOrgUser, _deleteOrg, type OrgSearchParams, type User, type OrgUser } from './+page'; + import { _changeOrgName, _deleteOrgUser, _deleteOrg, _orgMemberById, type OrgSearchParams, type User, type OrgUser } from './+page'; import OrgTabs, { type OrgTabId } from './OrgTabs.svelte'; import { getSearchParams, queryParam } from '$lib/util/query-params'; import { Icon, TrashIcon } from '$lib/icons'; @@ -20,6 +20,7 @@ import UserModal from '$lib/components/Users/UserModal.svelte'; import OrgMemberTable from './OrgMemberTable.svelte'; import ProjectTable from '$lib/components/Projects/ProjectTable.svelte'; + import type { UUID } from 'crypto'; export let data: PageData; $: user = data.user; @@ -32,6 +33,8 @@ const { queryParamValues } = queryParams; $: canManage = user.isAdmin || !!org.members.find(m => m.user.id === user.id && m.role === OrgRole.Admin) + $: isMember = !!org.members.find(m => m.user.id === user.id) + $: canSeeSettings = user.isAdmin || isMember const { notifySuccess, notifyWarning } = useNotifications(); @@ -46,9 +49,9 @@ } let userModal: UserModal; - function openUserModal(user: User): Promise { - // Although we receive a TableUser, we know in practice it's a full User object - return userModal.open(user); + async function openUserModal(user: User): Promise { + const queryUser = await _orgMemberById(org.id as UUID, user.id as UUID); + return userModal.open(queryUser); } let addOrgMemberModal: AddOrgMemberModal; @@ -113,7 +116,7 @@
- +
{#if $queryParamValues.tab === 'projects'} @@ -124,6 +127,7 @@ {:else if $queryParamValues.tab === 'members'} openUserModal(event.detail)} on:removeMember={(event) => _deleteOrgUser(org.id, event.detail.id)} on:changeMemberRole={(event) => openChangeMemberRoleModal(event.detail)} @@ -134,12 +138,14 @@
{:else if $queryParamValues.tab === 'settings'} -
- -
+ {#if isMember} +
+ +
+ {/if}
diff --git a/frontend/src/routes/(authenticated)/org/[org_id]/+page.ts b/frontend/src/routes/(authenticated)/org/[org_id]/+page.ts index 74c76a2bd..2b453aa40 100644 --- a/frontend/src/routes/(authenticated)/org/[org_id]/+page.ts +++ b/frontend/src/routes/(authenticated)/org/[org_id]/+page.ts @@ -6,6 +6,7 @@ import type { ChangeOrgNameMutation, DeleteOrgMutation, DeleteOrgUserMutation, + OrgMemberDto, OrgPageQuery, OrgRole, } from '$lib/gql/types'; @@ -24,12 +25,11 @@ export type User = OrgUser['user']; export async function load(event: PageLoadEvent) { const client = getClient(); const user = (await event.parent()).user; - const userIsAdmin = user.isAdmin; const orgId = event.params.org_id as UUID; const orgResult = await client .awaitedQueryStore(event.fetch, graphql(` - query orgPage($orgId: UUID!, $userIsAdmin: Boolean!) { + query orgPage($orgId: UUID!) { orgById(orgId: $orgId) { id createdDate @@ -37,6 +37,7 @@ export async function load(event: PageLoadEvent) { name projects { id + isConfidential code name type @@ -48,28 +49,14 @@ export async function load(event: PageLoadEvent) { user { id name - ... on User @include(if: $userIsAdmin) { - locked - username - createdDate - updatedDate - email - localizationCode - lastActive - canCreateProjects - isAdmin - emailVerified - createdBy { - id - name - } - } + username + email } } } } `), - { orgId, userIsAdmin } + { orgId } ); const nonNullableOrg = tryMakeNonNullable(orgResult.orgById); @@ -171,6 +158,40 @@ export async function _addOrgMember(orgId: UUID, emailOrUsername: string, role: return result; } +export async function _orgMemberById(orgId: UUID, userId: UUID): Promise { + //language=GraphQL + const result = await getClient() + .query( + graphql(` + query OrgMemberById($orgId: UUID!, $userId: UUID!) { + orgMemberById(orgId: $orgId, userId: $userId) { + id + name + email + emailVerified + isAdmin + createdDate + username + locked + localizationCode + updatedDate + lastActive + canCreateProjects + createdBy { + id + name + } + } + } + `), + { orgId, userId }, + ); + + if (!result.data?.orgMemberById) error(404); + + return result.data.orgMemberById; +} + export async function _changeOrgMemberRole(orgId: string, userId: string, role: OrgRole): $OpResult { //language=GraphQL const result = await getClient() diff --git a/frontend/src/routes/(authenticated)/org/[org_id]/OrgMemberTable.svelte b/frontend/src/routes/(authenticated)/org/[org_id]/OrgMemberTable.svelte index 121afa7cf..1caa06758 100644 --- a/frontend/src/routes/(authenticated)/org/[org_id]/OrgMemberTable.svelte +++ b/frontend/src/routes/(authenticated)/org/[org_id]/OrgMemberTable.svelte @@ -8,6 +8,7 @@ import type { OrgUser, User } from './+page'; export let shownUsers: OrgUser[]; + export let showEmailColumn: boolean = true; const dispatch = createEventDispatcher<{ openUserModal: User, @@ -25,7 +26,9 @@ {$t('admin_dashboard.column_name')} + {#if showEmailColumn} {$t('admin_dashboard.column_email_or_login')} + {/if} {$t('admin_dashboard.column_role')} @@ -45,29 +48,17 @@ - {#if user.locked} - - - - {/if}
+ {#if showEmailColumn} {user.email ?? user.username} - {#if user.email && !user.emailVerified} - - - - {/if} + {/if} diff --git a/frontend/src/routes/(authenticated)/org/[org_id]/OrgTabs.svelte b/frontend/src/routes/(authenticated)/org/[org_id]/OrgTabs.svelte index b444f7075..fc8926d1f 100644 --- a/frontend/src/routes/(authenticated)/org/[org_id]/OrgTabs.svelte +++ b/frontend/src/routes/(authenticated)/org/[org_id]/OrgTabs.svelte @@ -20,6 +20,8 @@ clickTab: OrgTabId }>(); + export let hideSettingsTab: boolean = false; + $: visibleTabs = hideSettingsTab ? orgTabs.filter(t => t !== 'settings') : orgTabs; export let activeTab: OrgTabId = 'projects'; export let projectCount: number; export let memberCount: number; @@ -34,7 +36,7 @@
- {#each orgTabs as tab} + {#each visibleTabs as tab} {@const isActiveTab = activeTab === tab}