Fix: Admin club management 500 error - JWT clubs claim format
- 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:
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": [
|
||||||
{
|
{
|
||||||
@@ -337,4 +339,4 @@
|
|||||||
"dockerAuthenticationFlow": "docker auth",
|
"dockerAuthenticationFlow": "docker auth",
|
||||||
"attributes": {},
|
"attributes": {},
|
||||||
"keycloakVersion": "26.0.0"
|
"keycloakVersion": "26.0.0"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user