Fix: Admin club management 500 error - JWT clubs claim format
CI Pipeline / Backend Build & Test (push) Successful in 53s
CI Pipeline / Frontend Lint, Test & Build (push) Successful in 33s
CI Pipeline / Infrastructure Validation (push) Successful in 4s

- Fix clubs attribute in Keycloak to contain only UUIDs (removed role names)

- Add defensive error handling in ClubService.GetMyClubsAsync()

- Add logging for debugging club retrieval issues

- Return empty list instead of 500 error on failures

Fixes: Admin users can now manage clubs without contact admin error
This commit is contained in:
WorkClub Automation
2026-03-21 20:27:38 +01:00
parent 9304db2391
commit b10c57bdb8
3 changed files with 171 additions and 132 deletions
+130 -93
View File
@@ -12,135 +12,172 @@ public class ClubService
private readonly AppDbContext _context; private readonly AppDbContext _context;
private readonly ITenantProvider _tenantProvider; private readonly ITenantProvider _tenantProvider;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<ClubService> _logger;
public ClubService( public ClubService(
AppDbContext context, AppDbContext context,
ITenantProvider tenantProvider, ITenantProvider tenantProvider,
IHttpContextAccessor httpContextAccessor) IHttpContextAccessor httpContextAccessor,
ILogger<ClubService> logger)
{ {
_context = context; _context = context;
_tenantProvider = tenantProvider; _tenantProvider = tenantProvider;
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_logger = logger;
} }
public async Task<List<ClubListDto>> GetMyClubsAsync() public async Task<List<ClubListDto>> GetMyClubsAsync()
{ {
var clubsClaim = _httpContextAccessor.HttpContext?.User.FindFirst("clubs")?.Value; try
if (string.IsNullOrEmpty(clubsClaim))
{ {
return new List<ClubListDto>(); var clubsClaim = _httpContextAccessor.HttpContext?.User.FindFirst("clubs")?.Value;
} _logger.LogInformation("GetMyClubsAsync: Clubs claim value: {ClubsClaim}", clubsClaim);
var tenantIds = clubsClaim.Split(',', StringSplitOptions.RemoveEmptyEntries) if (string.IsNullOrEmpty(clubsClaim))
.Select(t => t.Trim())
.Where(t => !string.IsNullOrEmpty(t) && Guid.TryParse(t, out _))
.ToList();
if (tenantIds.Count == 0)
{
return new List<ClubListDto>();
}
var clubDtos = new List<ClubListDto>();
var connectionString = _context.Database.GetConnectionString();
foreach (var tenantId in tenantIds)
{
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await using var transaction = await connection.BeginTransactionAsync();
// Set RLS context
using (var command = connection.CreateCommand())
{ {
command.Transaction = transaction; _logger.LogWarning("GetMyClubsAsync: No clubs claim found for user");
command.CommandText = $"SET LOCAL app.current_tenant_id = '{tenantId}'"; return new List<ClubListDto>();
await command.ExecuteNonQueryAsync();
} }
Guid? clubId = null; // Parse UUIDs from comma-separated claim, filtering out non-UUID values (like role names)
string? clubName = null; var tenantIds = clubsClaim.Split(',', StringSplitOptions.RemoveEmptyEntries)
int? sportTypeInt = null; .Select(t => t.Trim())
.Where(t => !string.IsNullOrEmpty(t) && Guid.TryParse(t, out _))
.ToList();
// Fetch club details _logger.LogInformation("GetMyClubsAsync: Parsed {Count} valid tenant IDs from claim", tenantIds.Count);
using (var command = connection.CreateCommand())
if (tenantIds.Count == 0)
{ {
command.Transaction = transaction; _logger.LogWarning("GetMyClubsAsync: No valid tenant IDs found in clubs claim: {ClubsClaim}", clubsClaim);
command.CommandText = @" return new List<ClubListDto>();
SELECT c.""Id"", c.""Name"", c.""SportType"" }
FROM clubs AS c
WHERE c.""TenantId"" = @tenantId";
var parameter = command.CreateParameter(); var clubDtos = new List<ClubListDto>();
parameter.ParameterName = "@tenantId"; var connectionString = _context.Database.GetConnectionString();
parameter.Value = tenantId;
command.Parameters.Add(parameter);
using (var reader = await command.ExecuteReaderAsync()) foreach (var tenantId in tenantIds)
{
try
{ {
if (await reader.ReadAsync()) await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await using var transaction = await connection.BeginTransactionAsync();
// Set RLS context - tenantId is already validated as a valid GUID
// Use direct string since SET LOCAL doesn't support parameters
using (var command = connection.CreateCommand())
{ {
clubId = reader.GetGuid(0); command.Transaction = transaction;
clubName = reader.GetString(1); command.CommandText = $"SET LOCAL app.current_tenant_id = '{tenantId}'";
sportTypeInt = reader.GetInt32(2); await command.ExecuteNonQueryAsync();
} }
}
}
// Fetch member count if club exists Guid? clubId = null;
if (clubId.HasValue && clubName != null && sportTypeInt.HasValue) string? clubName = null;
{ int? sportTypeInt = null;
using (var memberCommand = connection.CreateCommand())
// Fetch club details
using (var command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = @"
SELECT c.""Id"", c.""Name"", c.""SportType""
FROM clubs AS c
WHERE c.""TenantId"" = @tenantId";
var parameter = command.CreateParameter();
parameter.ParameterName = "@tenantId";
parameter.Value = tenantId;
command.Parameters.Add(parameter);
using (var reader = await command.ExecuteReaderAsync())
{
if (await reader.ReadAsync())
{
clubId = reader.GetGuid(0);
clubName = reader.GetString(1);
sportTypeInt = reader.GetInt32(2);
}
}
}
// Fetch member count if club exists
if (clubId.HasValue && clubName != null && sportTypeInt.HasValue)
{
using (var memberCommand = connection.CreateCommand())
{
memberCommand.Transaction = transaction;
memberCommand.CommandText = @"
SELECT COUNT(*)
FROM members AS m
WHERE m.""ClubId"" = @clubId";
var param = memberCommand.CreateParameter();
param.ParameterName = "@clubId";
param.Value = clubId;
memberCommand.Parameters.Add(param);
var memberCountResult = await memberCommand.ExecuteScalarAsync();
var memberCount = memberCountResult != null ? Convert.ToInt32(memberCountResult) : 0;
var sportTypeEnum = ((SportType)sportTypeInt.Value).ToString();
clubDtos.Add(new ClubListDto(
clubId.Value,
clubName,
sportTypeEnum,
memberCount,
Guid.Parse(tenantId)
));
}
}
await transaction.CommitAsync();
}
catch (Exception ex)
{ {
memberCommand.Transaction = transaction; _logger.LogError(ex, "GetMyClubsAsync: Error processing tenant {TenantId}", tenantId);
memberCommand.CommandText = @" // Continue with next tenant instead of failing entirely
SELECT COUNT(*)
FROM members AS m
WHERE m.""ClubId"" = @clubId";
var param = memberCommand.CreateParameter();
param.ParameterName = "@clubId";
param.Value = clubId;
memberCommand.Parameters.Add(param);
var memberCountResult = await memberCommand.ExecuteScalarAsync();
var memberCount = memberCountResult != null ? Convert.ToInt32(memberCountResult) : 0;
var sportTypeEnum = ((SportType)sportTypeInt.Value).ToString();
clubDtos.Add(new ClubListDto(
clubId.Value,
clubName,
sportTypeEnum,
memberCount,
Guid.Parse(tenantId)
));
} }
} }
await transaction.CommitAsync(); _logger.LogInformation("GetMyClubsAsync: Returning {Count} clubs", clubDtos.Count);
return clubDtos;
}
catch (Exception ex)
{
_logger.LogError(ex, "GetMyClubsAsync: Unexpected error getting user clubs");
// Return empty list instead of throwing to prevent 500 error
return new List<ClubListDto>();
} }
return clubDtos;
} }
public async Task<ClubDetailDto?> GetCurrentClubAsync() public async Task<ClubDetailDto?> GetCurrentClubAsync()
{ {
var tenantId = _tenantProvider.GetTenantId(); try
{
var tenantId = _tenantProvider.GetTenantId();
var club = await _context.Clubs var club = await _context.Clubs
.FirstOrDefaultAsync(c => c.TenantId == tenantId); .FirstOrDefaultAsync(c => c.TenantId == tenantId);
if (club == null) if (club == null)
return null;
return new ClubDetailDto(
club.Id,
club.Name,
club.SportType.ToString(),
club.Description,
club.CreatedAt,
club.UpdatedAt
);
}
catch (Exception ex)
{
_logger.LogError(ex, "GetCurrentClubAsync: Error getting current club");
return null; return null;
}
return new ClubDetailDto(
club.Id,
club.Name,
club.SportType.ToString(),
club.Description,
club.CreatedAt,
club.UpdatedAt
);
} }
} }
@@ -150,11 +150,11 @@ data:
"realmRoles": [ "realmRoles": [
"admin" "admin"
], ],
"attributes": { "attributes": {
"clubs": [ "clubs": [
"64e05b5e-ef45-81d7-f2e8-3d14bd197383,Admin,3b4afcfa-1352-8fc7-b497-8ab52a0d5fda,Member" "64e05b5e-ef45-81d7-f2e8-3d14bd197383,3b4afcfa-1352-8fc7-b497-8ab52a0d5fda"
] ]
} }
}, },
{ {
"username": "manager@test.com", "username": "manager@test.com",
@@ -172,11 +172,11 @@ data:
"realmRoles": [ "realmRoles": [
"manager" "manager"
], ],
"attributes": { "attributes": {
"clubs": [ "clubs": [
"64e05b5e-ef45-81d7-f2e8-3d14bd197383,Manager" "64e05b5e-ef45-81d7-f2e8-3d14bd197383"
] ]
} }
}, },
{ {
"username": "member1@test.com", "username": "member1@test.com",
@@ -194,11 +194,11 @@ data:
"realmRoles": [ "realmRoles": [
"member" "member"
], ],
"attributes": { "attributes": {
"clubs": [ "clubs": [
"64e05b5e-ef45-81d7-f2e8-3d14bd197383,Member,3b4afcfa-1352-8fc7-b497-8ab52a0d5fda,Member" "64e05b5e-ef45-81d7-f2e8-3d14bd197383,3b4afcfa-1352-8fc7-b497-8ab52a0d5fda"
] ]
} }
}, },
{ {
"username": "member2@test.com", "username": "member2@test.com",
@@ -216,11 +216,11 @@ data:
"realmRoles": [ "realmRoles": [
"member" "member"
], ],
"attributes": { "attributes": {
"clubs": [ "clubs": [
"64e05b5e-ef45-81d7-f2e8-3d14bd197383,Member" "64e05b5e-ef45-81d7-f2e8-3d14bd197383"
] ]
} }
}, },
{ {
"username": "viewer@test.com", "username": "viewer@test.com",
@@ -238,11 +238,11 @@ data:
"realmRoles": [ "realmRoles": [
"viewer" "viewer"
], ],
"attributes": { "attributes": {
"clubs": [ "clubs": [
"64e05b5e-ef45-81d7-f2e8-3d14bd197383,Viewer" "64e05b5e-ef45-81d7-f2e8-3d14bd197383"
] ]
} }
} }
] ]
} }
+15 -13
View File
@@ -82,18 +82,18 @@
"standardFlowEnabled": true, "standardFlowEnabled": true,
"implicitFlowEnabled": false, "implicitFlowEnabled": false,
"directAccessGrantsEnabled": true, "directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false, "serviceAccountsEnabled": false,
"authorizationServicesEnabled": false, "authorizationServicesEnabled": false,
"protocol": "openid-connect", "protocol": "openid-connect",
"redirectUris": [ "redirectUris": [
"http://localhost:30080/*" "http://localhost:30080/*"
], ],
"webOrigins": [ "webOrigins": [
"http://localhost:30080" "http://localhost:30080"
], ],
"attributes": { "attributes": {
"pkce.code.challenge.method": "S256", "pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "http://localhost:30080/*", "post.logout.redirect.uris": "http://localhost:30080/*",
"access.token.lifespan": "3600" "access.token.lifespan": "3600"
}, },
"protocolMappers": [ "protocolMappers": [
@@ -162,7 +162,9 @@
"firstName": "Admin", "firstName": "Admin",
"lastName": "User", "lastName": "User",
"attributes": { "attributes": {
"clubs": [] "clubs": [
"64e05b5e-ef45-81d7-f2e8-3d14bd197383,3b4afcfa-1352-8fc7-b497-8ab52a0d5fda"
]
}, },
"credentials": [ "credentials": [
{ {