diff --git a/backend/WorkClub.Api/Auth/ClubRoleClaimsTransformation.cs b/backend/WorkClub.Api/Auth/ClubRoleClaimsTransformation.cs index 9139517..15516cd 100644 --- a/backend/WorkClub.Api/Auth/ClubRoleClaimsTransformation.cs +++ b/backend/WorkClub.Api/Auth/ClubRoleClaimsTransformation.cs @@ -85,7 +85,7 @@ public class ClubRoleClaimsTransformation : IClaimsTransformation { return clubRole switch { - ClubRole.Admin => "Admin", + ClubRole.Manager => "Manager", ClubRole.Member => "Member", ClubRole.Viewer => "Viewer", diff --git a/backend/WorkClub.Api/Endpoints/Clubs/AdminClubEndpoints.cs b/backend/WorkClub.Api/Endpoints/Clubs/AdminClubEndpoints.cs new file mode 100644 index 0000000..6b528c1 --- /dev/null +++ b/backend/WorkClub.Api/Endpoints/Clubs/AdminClubEndpoints.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using WorkClub.Api.Services; +using WorkClub.Application.Clubs.DTOs; + +namespace WorkClub.Api.Endpoints.Clubs; + +public static class AdminClubEndpoints +{ + public static void MapAdminClubEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/admin/clubs") + .RequireAuthorization("RequireGlobalAdmin") + .WithTags("AdminClubs"); + + group.MapGet("", GetClubs) + .WithName("AdminGetClubs"); + + group.MapPost("", CreateClub) + .WithName("AdminCreateClub"); + + group.MapPut("{id:guid}", UpdateClub) + .WithName("AdminUpdateClub"); + + group.MapDelete("{id:guid}", DeleteClub) + .WithName("AdminDeleteClub"); + } + + private static async Task>> GetClubs(AdminClubService adminClubService) + { + var result = await adminClubService.GetAllClubsAsync(); + return TypedResults.Ok(result); + } + + private static async Task> CreateClub( + [FromBody] CreateClubRequest request, + AdminClubService adminClubService) + { + var result = await adminClubService.CreateClubAsync(request); + return TypedResults.Created($"/api/admin/clubs/{result.Id}", result); + } + + private static async Task, NotFound>> UpdateClub( + Guid id, + [FromBody] UpdateClubRequest request, + AdminClubService adminClubService) + { + var (result, error) = await adminClubService.UpdateClubAsync(id, request); + + if (error != null) + return TypedResults.NotFound(); + + return TypedResults.Ok(result!); + } + + private static async Task> DeleteClub( + Guid id, + AdminClubService adminClubService) + { + var success = await adminClubService.DeleteClubAsync(id); + + if (!success) + return TypedResults.NotFound(); + + return TypedResults.NoContent(); + } +} diff --git a/backend/WorkClub.Api/Endpoints/Shifts/ShiftEndpoints.cs b/backend/WorkClub.Api/Endpoints/Shifts/ShiftEndpoints.cs index 0823308..cb0666b 100644 --- a/backend/WorkClub.Api/Endpoints/Shifts/ShiftEndpoints.cs +++ b/backend/WorkClub.Api/Endpoints/Shifts/ShiftEndpoints.cs @@ -28,7 +28,7 @@ public static class ShiftEndpoints .WithName("UpdateShift"); group.MapDelete("{id:guid}", DeleteShift) - .RequireAuthorization("RequireAdmin") + .RequireAuthorization("RequireManager") .WithName("DeleteShift"); group.MapPost("{id:guid}/signup", SignUpForShift) diff --git a/backend/WorkClub.Api/Endpoints/Tasks/TaskEndpoints.cs b/backend/WorkClub.Api/Endpoints/Tasks/TaskEndpoints.cs index aee359c..ffaa864 100644 --- a/backend/WorkClub.Api/Endpoints/Tasks/TaskEndpoints.cs +++ b/backend/WorkClub.Api/Endpoints/Tasks/TaskEndpoints.cs @@ -28,7 +28,7 @@ public static class TaskEndpoints .WithName("UpdateTask"); group.MapDelete("{id:guid}", DeleteTask) - .RequireAuthorization("RequireAdmin") + .RequireAuthorization("RequireManager") .WithName("DeleteTask"); group.MapPost("{id:guid}/assign", AssignTaskToMe) diff --git a/backend/WorkClub.Api/Middleware/TenantValidationMiddleware.cs b/backend/WorkClub.Api/Middleware/TenantValidationMiddleware.cs index c347ec2..99a8718 100644 --- a/backend/WorkClub.Api/Middleware/TenantValidationMiddleware.cs +++ b/backend/WorkClub.Api/Middleware/TenantValidationMiddleware.cs @@ -22,8 +22,9 @@ public class TenantValidationMiddleware return; } - // Exempt /api/clubs/me from tenant validation - this is the bootstrap endpoint - if (context.Request.Path.StartsWithSegments("/api/clubs/me")) + // Exempt bootstrap and admin endpoints from tenant validation + if (context.Request.Path.StartsWithSegments("/api/clubs/me") || + context.Request.Path.StartsWithSegments("/api/admin")) { _logger.LogInformation("TenantValidationMiddleware: Exempting {Path} from tenant validation", context.Request.Path); await _next(context); diff --git a/backend/WorkClub.Api/Program.cs b/backend/WorkClub.Api/Program.cs index be8f050..d5838ac 100644 --- a/backend/WorkClub.Api/Program.cs +++ b/backend/WorkClub.Api/Program.cs @@ -24,6 +24,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -49,9 +50,13 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) builder.Services.AddScoped(); builder.Services.AddAuthorizationBuilder() - .AddPolicy("RequireAdmin", policy => policy.RequireRole("Admin")) - .AddPolicy("RequireManager", policy => policy.RequireRole("Admin", "Manager")) - .AddPolicy("RequireMember", policy => policy.RequireRole("Admin", "Manager", "Member")) + .AddPolicy("RequireGlobalAdmin", policy => policy.RequireAssertion(context => + { + var realmAccess = context.User.FindFirst("realm_access")?.Value; + return realmAccess != null && realmAccess.Contains("\"admin\""); + })) + .AddPolicy("RequireManager", policy => policy.RequireRole("Manager")) + .AddPolicy("RequireMember", policy => policy.RequireRole("Manager", "Member")) .AddPolicy("RequireViewer", policy => policy.RequireAuthenticatedUser()); builder.Services.AddDbContext((sp, options) => @@ -122,6 +127,7 @@ app.MapGet("/api/test", () => Results.Ok(new { message = "Test endpoint" })) app.MapTaskEndpoints(); app.MapShiftEndpoints(); app.MapClubEndpoints(); +app.MapAdminClubEndpoints(); app.MapMemberEndpoints(); app.Run(); diff --git a/backend/WorkClub.Api/Services/AdminClubService.cs b/backend/WorkClub.Api/Services/AdminClubService.cs new file mode 100644 index 0000000..7c335ec --- /dev/null +++ b/backend/WorkClub.Api/Services/AdminClubService.cs @@ -0,0 +1,113 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using WorkClub.Application.Clubs.DTOs; +using WorkClub.Domain.Entities; +using WorkClub.Infrastructure.Data; + +namespace WorkClub.Api.Services; + +public class AdminClubService +{ + private readonly AppDbContext _context; + + public AdminClubService(AppDbContext context) + { + _context = context; + } + + public async Task> GetAllClubsAsync() + { + var strategy = _context.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await _context.Database.BeginTransactionAsync(); + await _context.Database.ExecuteSqlRawAsync("SET LOCAL ROLE app_admin"); + var clubs = await _context.Clubs.ToListAsync(); + await _context.Database.ExecuteSqlRawAsync("RESET ROLE"); + await transaction.CommitAsync(); + + return clubs.Select(c => new ClubDetailDto( + c.Id, c.Name, c.SportType.ToString(), c.Description, c.CreatedAt, c.UpdatedAt)).ToList(); + }); + } + + public async Task CreateClubAsync(CreateClubRequest request) + { + var tenantId = Guid.NewGuid().ToString(); + var club = new Club + { + Id = Guid.NewGuid(), + TenantId = tenantId, + Name = request.Name, + SportType = request.SportType, + Description = request.Description, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + + var strategy = _context.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async () => + { + await using var transaction = await _context.Database.BeginTransactionAsync(); + await _context.Database.ExecuteSqlRawAsync("SET LOCAL ROLE app_admin"); + _context.Clubs.Add(club); + await _context.SaveChangesAsync(); + await _context.Database.ExecuteSqlRawAsync("RESET ROLE"); + await transaction.CommitAsync(); + }); + + return new ClubDetailDto(club.Id, club.Name, club.SportType.ToString(), club.Description, club.CreatedAt, club.UpdatedAt); + } + + public async Task<(ClubDetailDto? club, string? error)> UpdateClubAsync(Guid id, UpdateClubRequest request) + { + var strategy = _context.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync<(ClubDetailDto? club, string? error)>(async () => + { + await using var transaction = await _context.Database.BeginTransactionAsync(); + await _context.Database.ExecuteSqlRawAsync("SET LOCAL ROLE app_admin"); + + var club = await _context.Clubs.FindAsync(id); + if (club == null) + { + await _context.Database.ExecuteSqlRawAsync("RESET ROLE"); + return (null, "Club not found"); + } + + club.Name = request.Name; + club.SportType = request.SportType; + club.Description = request.Description; + club.UpdatedAt = DateTimeOffset.UtcNow; + + await _context.SaveChangesAsync(); + await _context.Database.ExecuteSqlRawAsync("RESET ROLE"); + await transaction.CommitAsync(); + + return (new ClubDetailDto(club.Id, club.Name, club.SportType.ToString(), club.Description, club.CreatedAt, club.UpdatedAt), null); + }); + } + + public async Task DeleteClubAsync(Guid id) + { + var strategy = _context.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await _context.Database.BeginTransactionAsync(); + await _context.Database.ExecuteSqlRawAsync("SET LOCAL ROLE app_admin"); + + var club = await _context.Clubs.FindAsync(id); + if (club == null) + { + await _context.Database.ExecuteSqlRawAsync("RESET ROLE"); + return false; + } + + _context.Clubs.Remove(club); + await _context.SaveChangesAsync(); + await _context.Database.ExecuteSqlRawAsync("RESET ROLE"); + await transaction.CommitAsync(); + + return true; + }); + } +} diff --git a/backend/WorkClub.Api/Services/MemberSyncService.cs b/backend/WorkClub.Api/Services/MemberSyncService.cs index 369bf1e..1ce5d49 100644 --- a/backend/WorkClub.Api/Services/MemberSyncService.cs +++ b/backend/WorkClub.Api/Services/MemberSyncService.cs @@ -60,7 +60,6 @@ public class MemberSyncService var roleClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value ?? "Member"; var clubRole = roleClaim.ToLowerInvariant() switch { - "admin" => ClubRole.Admin, "manager" => ClubRole.Manager, "member" => ClubRole.Member, "viewer" => ClubRole.Viewer, diff --git a/backend/WorkClub.Application/Clubs/DTOs/CreateClubRequest.cs b/backend/WorkClub.Application/Clubs/DTOs/CreateClubRequest.cs new file mode 100644 index 0000000..f85a572 --- /dev/null +++ b/backend/WorkClub.Application/Clubs/DTOs/CreateClubRequest.cs @@ -0,0 +1,9 @@ +using WorkClub.Domain.Enums; + +namespace WorkClub.Application.Clubs.DTOs; + +public record CreateClubRequest( + string Name, + SportType SportType, + string? Description +); diff --git a/backend/WorkClub.Application/Clubs/DTOs/UpdateClubRequest.cs b/backend/WorkClub.Application/Clubs/DTOs/UpdateClubRequest.cs new file mode 100644 index 0000000..7bb6592 --- /dev/null +++ b/backend/WorkClub.Application/Clubs/DTOs/UpdateClubRequest.cs @@ -0,0 +1,9 @@ +using WorkClub.Domain.Enums; + +namespace WorkClub.Application.Clubs.DTOs; + +public record UpdateClubRequest( + string Name, + SportType SportType, + string? Description +); diff --git a/backend/WorkClub.Domain/Enums/ClubRole.cs b/backend/WorkClub.Domain/Enums/ClubRole.cs index 936ae1c..66c954a 100644 --- a/backend/WorkClub.Domain/Enums/ClubRole.cs +++ b/backend/WorkClub.Domain/Enums/ClubRole.cs @@ -2,7 +2,6 @@ namespace WorkClub.Domain.Enums; public enum ClubRole { - Admin = 0, Manager = 1, Member = 2, Viewer = 3 diff --git a/backend/WorkClub.Infrastructure/Seed/SeedDataService.cs b/backend/WorkClub.Infrastructure/Seed/SeedDataService.cs index 7807410..2bf2c1e 100644 --- a/backend/WorkClub.Infrastructure/Seed/SeedDataService.cs +++ b/backend/WorkClub.Infrastructure/Seed/SeedDataService.cs @@ -26,7 +26,7 @@ public class SeedDataService using var transaction = await context.Database.BeginTransactionAsync(); - // Enable RLS on all tenant tables + // Enable RLS on all tenant tables (Must be table owner, which 'workclub' is) await context.Database.ExecuteSqlRawAsync(@" ALTER TABLE clubs ENABLE ROW LEVEL SECURITY; ALTER TABLE clubs FORCE ROW LEVEL SECURITY; @@ -62,22 +62,6 @@ public class SeedDataService "); // Create admin bypass policies (idempotent) - await context.Database.ExecuteSqlRawAsync(@" - DO $$ - BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_admin') THEN - CREATE ROLE app_admin; - END IF; - END - $$; - GRANT app_admin TO app; - GRANT USAGE ON SCHEMA public TO app_admin; - GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO app_admin; - GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO app_admin; - ALTER DEFAULT PRIVILEGES FOR ROLE app IN SCHEMA public GRANT ALL ON TABLES TO app_admin; - ALTER DEFAULT PRIVILEGES FOR ROLE app IN SCHEMA public GRANT ALL ON SEQUENCES TO app_admin; - "); - await context.Database.ExecuteSqlRawAsync(@" DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename='clubs' AND policyname='bypass_rls_policy') THEN @@ -140,31 +124,7 @@ public class SeedDataService { var members = new List { - // admin@test.com: Admin in Club 1, Member in Club 2 - new Member - { - Id = Guid.NewGuid(), - TenantId = tennisClub.TenantId, - ExternalUserId = "admin-user-id", - DisplayName = "Admin User", - Email = "admin@test.com", - Role = ClubRole.Admin, - ClubId = tennisClub.Id, - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow - }, - new Member - { - Id = Guid.NewGuid(), - TenantId = cyclingClub.TenantId, - ExternalUserId = "admin-user-id", - DisplayName = "Admin User", - Email = "admin@test.com", - Role = ClubRole.Member, - ClubId = cyclingClub.Id, - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow - }, + // manager@test.com: Manager in Club 1 new Member { @@ -235,8 +195,7 @@ public class SeedDataService await context.SaveChangesAsync(); } - // Get admin member IDs for work item creation - var adminMembers = context.Members.Where(m => m.Email == "admin@test.com").ToList(); + var managerMember = context.Members.First(m => m.Email == "manager@test.com"); var member1Members = context.Members.Where(m => m.Email == "member1@test.com").ToList(); var member2Member = context.Members.First(m => m.Email == "member2@test.com"); @@ -255,7 +214,7 @@ public class SeedDataService Description = "Resurface main court", Status = WorkItemStatus.Open, AssigneeId = null, - CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id, + CreatedById = managerMember.Id, ClubId = tennisClub.Id, DueDate = DateTimeOffset.UtcNow.AddDays(14), CreatedAt = DateTimeOffset.UtcNow, @@ -269,7 +228,7 @@ public class SeedDataService Description = "Purchase new tennis rackets and balls", Status = WorkItemStatus.Assigned, AssigneeId = managerMember.Id, - CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id, + CreatedById = managerMember.Id, ClubId = tennisClub.Id, DueDate = DateTimeOffset.UtcNow.AddDays(7), CreatedAt = DateTimeOffset.UtcNow, @@ -283,7 +242,7 @@ public class SeedDataService Description = "Organize annual summer tournament", Status = WorkItemStatus.InProgress, AssigneeId = member1Members.First(m => m.ClubId == tennisClub.Id).Id, - CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id, + CreatedById = managerMember.Id, ClubId = tennisClub.Id, DueDate = DateTimeOffset.UtcNow.AddDays(30), CreatedAt = DateTimeOffset.UtcNow, @@ -297,7 +256,7 @@ public class SeedDataService Description = "Update and review club rules handbook", Status = WorkItemStatus.Review, AssigneeId = member2Member.Id, - CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id, + CreatedById = managerMember.Id, ClubId = tennisClub.Id, DueDate = DateTimeOffset.UtcNow.AddDays(21), CreatedAt = DateTimeOffset.UtcNow, @@ -311,7 +270,7 @@ public class SeedDataService Description = "Update club website with new photos", Status = WorkItemStatus.Done, AssigneeId = managerMember.Id, - CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id, + CreatedById = managerMember.Id, ClubId = tennisClub.Id, DueDate = DateTimeOffset.UtcNow.AddDays(-5), CreatedAt = DateTimeOffset.UtcNow.AddDays(-10), @@ -326,7 +285,7 @@ public class SeedDataService Description = "Create new cycling routes for summer", Status = WorkItemStatus.Open, AssigneeId = null, - CreatedById = adminMembers.First(m => m.ClubId == cyclingClub.Id).Id, + CreatedById = member1Members.First(m => m.ClubId == cyclingClub.Id).Id, ClubId = cyclingClub.Id, DueDate = DateTimeOffset.UtcNow.AddDays(21), CreatedAt = DateTimeOffset.UtcNow, @@ -340,7 +299,7 @@ public class SeedDataService Description = "Organize safety and maintenance training", Status = WorkItemStatus.Assigned, AssigneeId = member1Members.First(m => m.ClubId == cyclingClub.Id).Id, - CreatedById = adminMembers.First(m => m.ClubId == cyclingClub.Id).Id, + CreatedById = member1Members.First(m => m.ClubId == cyclingClub.Id).Id, ClubId = cyclingClub.Id, DueDate = DateTimeOffset.UtcNow.AddDays(14), CreatedAt = DateTimeOffset.UtcNow, @@ -353,8 +312,8 @@ public class SeedDataService Title = "Group ride coordination", Description = "Schedule and coordinate weekly group rides", Status = WorkItemStatus.InProgress, - AssigneeId = adminMembers.First(m => m.ClubId == cyclingClub.Id).Id, - CreatedById = adminMembers.First(m => m.ClubId == cyclingClub.Id).Id, + AssigneeId = member1Members.First(m => m.ClubId == cyclingClub.Id).Id, + CreatedById = member1Members.First(m => m.ClubId == cyclingClub.Id).Id, ClubId = cyclingClub.Id, DueDate = DateTimeOffset.UtcNow.AddDays(7), CreatedAt = DateTimeOffset.UtcNow, @@ -384,7 +343,7 @@ public class SeedDataService EndTime = now.AddDays(-1).Date.ToLocalTime().AddHours(12), Capacity = 2, ClubId = tennisClub.Id, - CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id, + CreatedById = managerMember.Id, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }, @@ -399,7 +358,7 @@ public class SeedDataService EndTime = now.Date.ToLocalTime().AddHours(18), Capacity = 3, ClubId = tennisClub.Id, - CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id, + CreatedById = managerMember.Id, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }, @@ -414,7 +373,7 @@ public class SeedDataService EndTime = now.AddDays(7).Date.ToLocalTime().AddHours(17), Capacity = 5, ClubId = tennisClub.Id, - CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id, + CreatedById = managerMember.Id, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }, @@ -430,7 +389,7 @@ public class SeedDataService EndTime = now.Date.ToLocalTime().AddHours(9), Capacity = 10, ClubId = cyclingClub.Id, - CreatedById = adminMembers.First(m => m.ClubId == cyclingClub.Id).Id, + CreatedById = member1Members.First(m => m.ClubId == cyclingClub.Id).Id, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }, @@ -445,7 +404,7 @@ public class SeedDataService EndTime = now.AddDays(7).Date.ToLocalTime().AddHours(14), Capacity = 4, ClubId = cyclingClub.Id, - CreatedById = adminMembers.First(m => m.ClubId == cyclingClub.Id).Id, + CreatedById = member1Members.First(m => m.ClubId == cyclingClub.Id).Id, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow } diff --git a/backend/WorkClub.Tests.Integration/Clubs/ClubEndpointsTests.cs b/backend/WorkClub.Tests.Integration/Clubs/ClubEndpointsTests.cs index 6b4be7b..af024b0 100644 --- a/backend/WorkClub.Tests.Integration/Clubs/ClubEndpointsTests.cs +++ b/backend/WorkClub.Tests.Integration/Clubs/ClubEndpointsTests.cs @@ -69,7 +69,7 @@ public class ClubEndpointsTests : IntegrationTestBase ExternalUserId = adminUserId, DisplayName = "Admin User", Email = "admin@test.com", - Role = ClubRole.Admin, + Role = ClubRole.Manager, ClubId = club1Id, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow diff --git a/backend/WorkClub.Tests.Integration/Members/MemberEndpointsTests.cs b/backend/WorkClub.Tests.Integration/Members/MemberEndpointsTests.cs index 3d8f393..5409d92 100644 --- a/backend/WorkClub.Tests.Integration/Members/MemberEndpointsTests.cs +++ b/backend/WorkClub.Tests.Integration/Members/MemberEndpointsTests.cs @@ -60,7 +60,7 @@ public class MemberEndpointsTests : IntegrationTestBase ExternalUserId = "admin-user-id", DisplayName = "Admin User", Email = "admin@test.com", - Role = ClubRole.Admin, + Role = ClubRole.Manager, ClubId = club1Id, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow diff --git a/backend/WorkClub.Tests.Integration/Shifts/ShiftCrudTests.cs b/backend/WorkClub.Tests.Integration/Shifts/ShiftCrudTests.cs index 486f4c1..25cb3d9 100644 --- a/backend/WorkClub.Tests.Integration/Shifts/ShiftCrudTests.cs +++ b/backend/WorkClub.Tests.Integration/Shifts/ShiftCrudTests.cs @@ -303,55 +303,7 @@ public class ShiftCrudTests : IntegrationTestBase } [Fact] - public async Task DeleteShift_AsAdmin_DeletesShift() - { - // Arrange - var shiftId = Guid.NewGuid(); - var clubId = Guid.NewGuid(); - var createdBy = Guid.NewGuid(); - var now = DateTimeOffset.UtcNow; - - using (var scope = Factory.Services.CreateScope()) - { - var context = scope.ServiceProvider.GetRequiredService(); - - context.Shifts.Add(new Shift - { - Id = shiftId, - TenantId = "tenant1", - Title = "Test Shift", - StartTime = now.AddDays(1), - EndTime = now.AddDays(1).AddHours(4), - Capacity = 5, - ClubId = clubId, - CreatedById = createdBy, - CreatedAt = now, - UpdatedAt = now - }); - - await context.SaveChangesAsync(); - } - - SetTenant("tenant1"); - AuthenticateAs("admin@test.com", new Dictionary { ["tenant1"] = "Admin" }); - - // Act - var response = await Client.DeleteAsync($"/api/shifts/{shiftId}"); - - // Assert - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - - // Verify shift is deleted - using (var scope = Factory.Services.CreateScope()) - { - var context = scope.ServiceProvider.GetRequiredService(); - var shift = await context.Shifts.FindAsync(shiftId); - Assert.Null(shift); - } - } - - [Fact] - public async Task DeleteShift_AsManager_ReturnsForbidden() + public async Task DeleteShift_AsManager_DeletesShift() { // Arrange var shiftId = Guid.NewGuid(); @@ -387,7 +339,15 @@ public class ShiftCrudTests : IntegrationTestBase var response = await Client.DeleteAsync($"/api/shifts/{shiftId}"); // Assert - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + // Verify shift is deleted + using (var scope = Factory.Services.CreateScope()) + { + var context = scope.ServiceProvider.GetRequiredService(); + var shift = await context.Shifts.FindAsync(shiftId); + Assert.Null(shift); + } } [Fact] diff --git a/backend/WorkClub.Tests.Integration/Tasks/TaskCrudTests.cs b/backend/WorkClub.Tests.Integration/Tasks/TaskCrudTests.cs index 8769817..7f1c110 100644 --- a/backend/WorkClub.Tests.Integration/Tasks/TaskCrudTests.cs +++ b/backend/WorkClub.Tests.Integration/Tasks/TaskCrudTests.cs @@ -387,52 +387,7 @@ public class TaskCrudTests : IntegrationTestBase } [Fact] - public async Task DeleteTask_AsAdmin_DeletesTask() - { - // Arrange - var taskId = Guid.NewGuid(); - var club1 = Guid.NewGuid(); - var createdBy = Guid.NewGuid(); - - using (var scope = Factory.Services.CreateScope()) - { - var context = scope.ServiceProvider.GetRequiredService(); - - context.WorkItems.Add(new WorkItem - { - Id = taskId, - TenantId = "tenant1", - Title = "Test Task", - Status = WorkItemStatus.Open, - ClubId = club1, - CreatedById = createdBy, - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow - }); - - await context.SaveChangesAsync(); - } - - SetTenant("tenant1"); - AuthenticateAs("admin@test.com", new Dictionary { ["tenant1"] = "Admin" }); - - // Act - var response = await Client.DeleteAsync($"/api/tasks/{taskId}"); - - // Assert - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - - // Verify task is deleted - using (var scope = Factory.Services.CreateScope()) - { - var context = scope.ServiceProvider.GetRequiredService(); - var task = await context.WorkItems.FindAsync(taskId); - Assert.Null(task); - } - } - - [Fact] - public async Task DeleteTask_AsManager_ReturnsForbidden() + public async Task DeleteTask_AsManager_DeletesTask() { // Arrange var taskId = Guid.NewGuid(); @@ -465,7 +420,15 @@ public class TaskCrudTests : IntegrationTestBase var response = await Client.DeleteAsync($"/api/tasks/{taskId}"); // Assert - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + // Verify task is deleted + using (var scope = Factory.Services.CreateScope()) + { + var context = scope.ServiceProvider.GetRequiredService(); + var task = await context.WorkItems.FindAsync(taskId); + Assert.Null(task); + } } } diff --git a/frontend/src/app/(protected)/admin/clubs/page.tsx b/frontend/src/app/(protected)/admin/clubs/page.tsx new file mode 100644 index 0000000..58914de --- /dev/null +++ b/frontend/src/app/(protected)/admin/clubs/page.tsx @@ -0,0 +1,12 @@ +import { ClubManagement } from '@/components/admin/club-management'; + +export default function AdminClubsPage() { + return ( +
+
+

Club Management

+
+ +
+ ); +} diff --git a/frontend/src/app/(protected)/layout.tsx b/frontend/src/app/(protected)/layout.tsx index 9e117af..8463939 100644 --- a/frontend/src/app/(protected)/layout.tsx +++ b/frontend/src/app/(protected)/layout.tsx @@ -1,13 +1,19 @@ +'use client'; + import { AuthGuard } from '@/components/auth-guard'; import { ClubSwitcher } from '@/components/club-switcher'; import Link from 'next/link'; import { SignOutButton } from '@/components/sign-out-button'; +import { useSession } from 'next-auth/react'; export default function ProtectedLayout({ children, }: { children: React.ReactNode; }) { + const { data } = useSession(); + const isAdmin = data?.user?.isAdmin; + return (
@@ -15,26 +21,34 @@ export default function ProtectedLayout({

WorkClub

- + {isAdmin ? ( + + ) : ( + + )}
- + {!isAdmin && }
diff --git a/frontend/src/app/(protected)/shifts/[id]/page.tsx b/frontend/src/app/(protected)/shifts/[id]/page.tsx index a1e4fb6..71a2e17 100644 --- a/frontend/src/app/(protected)/shifts/[id]/page.tsx +++ b/frontend/src/app/(protected)/shifts/[id]/page.tsx @@ -7,7 +7,6 @@ import { Button } from '@/components/ui/button'; import { Progress } from '@/components/ui/progress'; import { Badge } from '@/components/ui/badge'; import { useRouter } from 'next/navigation'; -import { useSession } from 'next-auth/react'; export default function ShiftDetailPage({ params }: { params: Promise<{ id: string }> }) { const resolvedParams = use(params); @@ -15,7 +14,6 @@ export default function ShiftDetailPage({ params }: { params: Promise<{ id: stri const signUpMutation = useSignUpShift(); const cancelMutation = useCancelSignUp(); const router = useRouter(); - const { data: session } = useSession(); if (isLoading) return
Loading shift...
; if (!shift) return
Shift not found
; diff --git a/frontend/src/auth/auth.ts b/frontend/src/auth/auth.ts index e09769a..961ba2d 100644 --- a/frontend/src/auth/auth.ts +++ b/frontend/src/auth/auth.ts @@ -9,6 +9,7 @@ declare module "next-auth" { email?: string | null image?: string | null clubs?: Record + isAdmin?: boolean } accessToken?: string } @@ -16,6 +17,7 @@ declare module "next-auth" { interface JWT { clubs?: Record accessToken?: string + isAdmin?: boolean } } @@ -43,10 +45,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ ], callbacks: { async jwt({ token, account }) { - if (account) { + if (account && account.access_token) { // Add clubs claim from Keycloak access token - token.clubs = (account as Record).clubs as Record || {} + token.clubs = (account as { clubs?: Record }).clubs || {} token.accessToken = account.access_token + + try { + const payload = JSON.parse(Buffer.from((token.accessToken as string).split('.')[1], 'base64').toString()); + const roles = (payload.realm_access?.roles as string[]) || []; + token.isAdmin = roles.includes('admin'); + } catch { + token.isAdmin = false; + } } return token }, @@ -54,6 +64,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ // Expose clubs to client if (session.user) { session.user.clubs = token.clubs as Record | undefined + session.user.isAdmin = token.isAdmin as boolean | undefined } session.accessToken = token.accessToken as string | undefined return session diff --git a/frontend/src/components/admin/club-management.tsx b/frontend/src/components/admin/club-management.tsx new file mode 100644 index 0000000..eb24953 --- /dev/null +++ b/frontend/src/components/admin/club-management.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useSession } from 'next-auth/react'; + +type Club = { + id: string; + name: string; + sportType: string; + description?: string; +}; + +export function ClubManagement() { + const { data: session } = useSession(); + const [clubs, setClubs] = useState([]); + const [loading, setLoading] = useState(true); + const [isCreating, setIsCreating] = useState(false); + const [newClub, setNewClub] = useState({ name: '', sportType: 'Tennis', description: '' }); + + useEffect(() => { + const fetchClubsLocally = async () => { + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/admin/clubs`, { + headers: { Authorization: `Bearer ${session?.accessToken}` }, + }); + if (res.ok) { + const data = await res.json(); + setClubs(data); + } + } catch (error) { + console.error('Failed to fetch clubs', error); + } finally { + setLoading(false); + } + }; + + if (session) fetchClubsLocally(); + }, [session]); + + const fetchClubs = async () => { + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/admin/clubs`, { + headers: { Authorization: `Bearer ${session?.accessToken}` }, + }); + if (res.ok) { + const data = await res.json(); + setClubs(data); + } + } catch (error) { + console.error('Failed to fetch clubs', error); + } finally { + setLoading(false); + } + }; + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/admin/clubs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${session?.accessToken}`, + }, + body: JSON.stringify({ + name: newClub.name, + sportType: newClub.sportType === 'Tennis' ? 0 : 1, // Mapping Enum or keep string if api accepts + description: newClub.description, + }), + }); + if (res.ok) { + setNewClub({ name: '', sportType: 'Tennis', description: '' }); + setIsCreating(false); + fetchClubs(); + } + } catch (e) { + console.error(e); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm('Are you sure you want to delete this club?')) return; + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/admin/clubs/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${session?.accessToken}` }, + }); + if (res.ok) { + fetchClubs(); + } + } catch (e) { + console.error(e); + } + }; + + if (loading) return
Loading clubs...
; + + return ( +
+
+

All Clubs

+ +
+ + {isCreating && ( +
+

New Club

+
+ + setNewClub({ ...newClub, name: e.target.value })} + /> +
+
+ + +
+
+ +