30 Commits

Author SHA1 Message Date
WorkClub Automation ad8bb2d320 Fix: Remove port 8081 hardcoding in OIDC internal URLs
CI Pipeline / Backend Build & Test (push) Successful in 53s
CI Pipeline / Frontend Lint, Test & Build (push) Successful in 36s
CI Pipeline / Infrastructure Validation (push) Successful in 3s
The auth.ts was hardcoding port 8081 for internal Keycloak communication

but the Kubernetes Keycloak service uses port 8080, causing auth failures

Changed: oidcInternal no longer replaces 8080 with 8081
2026-03-21 21:52:03 +01:00
WorkClub Automation b10c57bdb8 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
2026-03-21 20:27:38 +01:00
WorkClub Automation 9304db2391 Fix: Add API_INTERNAL_URL to Dockerfile build stage
CI Pipeline / Backend Build & Test (push) Successful in 59s
CI Pipeline / Frontend Lint, Test & Build (push) Successful in 30s
CI Pipeline / Infrastructure Validation (push) Successful in 3s
Next.js rewrites are evaluated at build time, not runtime.
The API_INTERNAL_URL was set in K8s deployment but not during
the Docker build, causing fallback to localhost:5001.

- Added ENV API_INTERNAL_URL=http://workclub-api:8080
- This ensures Next.js rewrites point to internal K8s service
2026-03-21 14:13:37 +01:00
WorkClub Automation 27f1ad5780 Add debug logging to auth-guard to trace isAdmin issue
CI Pipeline / Backend Build & Test (push) Successful in 1m5s
CI Pipeline / Frontend Lint, Test & Build (push) Successful in 36s
CI Pipeline / Infrastructure Validation (push) Successful in 3s
2026-03-21 13:57:27 +01:00
WorkClub Automation 4e52544c79 Add API_INTERNAL_URL to frontend deployment for K8s
CI Pipeline / Backend Build & Test (push) Successful in 1m3s
CI Pipeline / Frontend Lint, Test & Build (push) Successful in 34s
CI Pipeline / Infrastructure Validation (push) Successful in 3s
The Next.js rewrites were falling back to localhost:5001 because
API_INTERNAL_URL was not set. This caused API proxy errors.

- Added API_INTERNAL_URL=http://workclub-api:8080
- This allows Next.js to proxy /api/* calls to the internal backend service
2026-03-21 13:46:35 +01:00
WorkClub Automation e6e1112060 Add debug logging for admin status detection
CI Pipeline / Backend Build & Test (push) Successful in 1m1s
CI Pipeline / Frontend Lint, Test & Build (push) Successful in 37s
CI Pipeline / Infrastructure Validation (push) Successful in 3s
2026-03-21 13:32:53 +01:00
WorkClub Automation b5dd24b4c9 Fix: Always check admin status from access token in JWT callback
CI Pipeline / Backend Build & Test (push) Successful in 1m3s
CI Pipeline / Frontend Lint, Test & Build (push) Successful in 29s
CI Pipeline / Infrastructure Validation (push) Successful in 3s
The jwt callback was only checking isAdmin during initial login when
account was present, but not on subsequent session refreshes. This caused
the admin status to be lost after the initial login.

- Moved admin status check outside of the 'if (account)' block
- Now checks isAdmin on every JWT callback when accessToken is available
2026-03-21 13:11:01 +01:00
WorkClub Automation f8d698ba42 Fix KEYCLOAK_ISSUER_INTERNAL to include port 8080
CI Pipeline / Backend Build & Test (push) Successful in 50s
CI Pipeline / Frontend Lint, Test & Build (push) Successful in 34s
CI Pipeline / Infrastructure Validation (push) Successful in 4s
The internal Keycloak URL was missing the port number, causing
the OIDC token exchange to fail. The code tries to replace
:8080 with :8081 but the port was missing entirely.

- Changed from: http://workclub-keycloak/realms/workclub
- Changed to: http://workclub-keycloak:8080/realms/workclub
2026-03-21 09:30:13 +01:00
WorkClub Automation 86c7b0d46d Fix Keycloak URL in K8s ConfigMap to use correct NodePort 30808
CI Pipeline / Backend Build & Test (push) Successful in 57s
CI Pipeline / Frontend Lint, Test & Build (push) Successful in 34s
CI Pipeline / Infrastructure Validation (push) Successful in 4s
- Changed api-base-url from :5001 to :30501
- Changed keycloak-url from :8080 to :30808
- Changed keycloak-authority from :8080 to :30808

The frontend was trying to connect to port 8080 which is not exposed
externally. Keycloak is accessible via NodePort 30808.
2026-03-21 08:27:05 +01:00
WorkClub Automation fd2931e59c Fix Kubernetes NodePort range (30000-32767)
CI Pipeline / Backend Build & Test (push) Successful in 1m6s
CI Pipeline / Frontend Lint, Test & Build (push) Successful in 32s
CI Pipeline / Infrastructure Validation (push) Successful in 4s
- Frontend: nodePort 3000 → 30080
- Backend: nodePort 5001 → 30501, service port 5001 → 8080
- Keycloak: nodePort 8080 → 30808

Kubernetes requires NodePort to be in range 30000-32767.
The service port (internal) and targetPort (container) remain
unchanged for compatibility with existing configurations.
2026-03-20 22:50:51 +01:00
WorkClub Automation a5ebecc8b5 Remove localhost:3000 from Keycloak redirect URIs and web origins
CI Pipeline / Backend Build & Test (push) Successful in 50s
CI Pipeline / Frontend Lint, Test & Build (push) Successful in 32s
CI Pipeline / Infrastructure Validation (push) Successful in 4s
- Removed localhost:3000/* from redirectUris in realm-export.json
- Removed localhost:3000 from webOrigins in realm-export.json
- Removed localhost:3000/* from post.logout.redirect.uris
- Removed localhost:3000 from keycloak-realm-import-configmap.yaml
- Updated running Keycloak instance via kcadm.sh

Only port 30080 is now configured for OAuth redirects.
2026-03-20 22:39:15 +01:00
WorkClub Automation 956c3ead0c Fix YAML syntax error in frontend-deployment.yaml
CI Pipeline / Backend Build & Test (push) Successful in 52s
CI Pipeline / Frontend Lint, Test & Build (push) Successful in 34s
CI Pipeline / Infrastructure Validation (push) Successful in 3s
The file had malformed YAML with incorrect indentation on line 70,
causing validation to fail. Rewrote the file with correct indentation.
2026-03-20 20:50:14 +01:00
WorkClub Automation 0100def25a Align Kubernetes ports with Docker Compose configuration
CI Pipeline / Backend Build & Test (push) Successful in 58s
CI Pipeline / Frontend Lint, Test & Build (push) Successful in 43s
CI Pipeline / Infrastructure Validation (push) Failing after 4s
- Frontend: Changed NodePort from 30080 to 3000 (matches Docker port)
- Backend: Changed NodePort from 30081 to 5001 (matches Docker port)
- Keycloak: Changed NodePort from 30082 to 8080 (matches Docker port)
- Updated ConfigMap URLs to use new ports
- Updated NEXTAUTH_URL to use port 3000

This ensures Kubernetes deployment uses the same ports as Docker Compose
for consistency across environments.
2026-03-20 20:40:22 +01:00
WorkClub Automation 79fabd5348 Merge branch 'main' of https://code.hal9000.damnserver.com/MasterMito/work-club-manager
CI Pipeline / Backend Build & Test (push) Successful in 54s
CI Pipeline / Frontend Lint, Test & Build (push) Successful in 31s
CI Pipeline / Infrastructure Validation (push) Successful in 3s
2026-03-20 13:31:42 +01:00
WorkClub Automation 1aea91da55 fix: Bind Next.js server to 0.0.0.0 for external access
The deployment was unreachable because the Next.js server was binding
to localhost:3000 (127.0.0.1) instead of 0.0.0.0, making it only
accessible inside the Docker container.

- Added HOSTNAME=0.0.0.0 to Dockerfile build and runtime stages
- Added HOSTNAME=0.0.0.0 to docker-compose.yml for nextjs service

This allows the server to accept connections from external hosts.
2026-03-20 13:29:21 +01:00
MasterMito d1e80e39a7 Merge pull request 'fix: Add fallback values for Keycloak environment variables to fix Docker build' (#7) from epic/admin_rework_second_try into main
CI Pipeline / Backend Build & Test (push) Successful in 51s
CI Pipeline / Frontend Lint, Test & Build (push) Successful in 30s
CI Pipeline / Infrastructure Validation (push) Successful in 3s
Reviewed-on: #7
2026-03-20 12:17:44 +01:00
WorkClub Automation 28284d7edc fix: Add fallback values for Keycloak environment variables to fix Docker build
CI Pipeline / Backend Build & Test (pull_request) Successful in 52s
CI Pipeline / Frontend Lint, Test & Build (pull_request) Successful in 35s
CI Pipeline / Infrastructure Validation (pull_request) Successful in 3s
The build was failing because KEYCLOAK_ISSUER and KEYCLOAK_CLIENT_ID
were undefined during the static generation phase. Added default values
that match the development configuration.

- Added fallback for KEYCLOAK_ISSUER
- Added fallback for KEYCLOAK_CLIENT_ID
2026-03-20 12:11:22 +01:00
MasterMito 66719d9787 Merge pull request 'Rework Admin UI' (#6) from epic/admin_rework_second_try into main
CI Pipeline / Backend Build & Test (push) Successful in 49s
CI Pipeline / Frontend Lint, Test & Build (push) Successful in 32s
CI Pipeline / Infrastructure Validation (push) Successful in 3s
Reviewed-on: #6
2026-03-20 11:55:38 +01:00
MasterMito 984ab77137 Merge pull request 'Fix RLS permissions and JWT validation for admin club creation' (#5) from fix/rls-permission-test-failure into epic/admin_rework_second_try
CI Pipeline / Backend Build & Test (pull_request) Successful in 53s
CI Pipeline / Frontend Lint, Test & Build (pull_request) Successful in 38s
CI Pipeline / Infrastructure Validation (pull_request) Successful in 3s
Reviewed-on: #5
2026-03-20 11:42:05 +01:00
WorkClub Automation 0f036a2ef6 Fix test: Update GetClubsCurrent_NoTenantContext_ReturnsForbidden to reflect actual behavior
The test was expecting Forbidden when no tenant context is provided,
but the middleware actually returns BadRequest when X-Tenant-Id header
is missing. Updated the test and added GetClubsCurrent_InvalidTenant_ReturnsForbidden
to properly test the Forbidden case.
2026-03-20 11:36:52 +01:00
WorkClub Automation fdc1f415b7 Add test endpoint for middleware validation tests 2026-03-20 11:21:02 +01:00
WorkClub Automation 13f9e7be7f Fix JWT validation by configuring custom signing key resolver
- Added IssuerSigningKeyResolver to fetch JWKS directly from internal Keycloak URL
- This bypasses the localhost:8080 URLs in Keycloak's discovery document
- Ensures JWT tokens are validated against correct signing keys
2026-03-20 11:01:56 +01:00
WorkClub Automation 87c315c6fd Fix Keycloak hostname configuration for Docker internal communication
- Add MetadataAddress configuration to JWT middleware for internal Docker URLs
- Add KC_HOSTNAME_ADMIN and KC_SPI_HOSTNAME_DEFAULT_ADMIN to Keycloak env
- This ensures API can fetch JWKS from Keycloak via internal Docker network
- Tests passing: 63/63
2026-03-20 10:49:55 +01:00
WorkClub Automation 26d7d83811 Fix middleware order - place Authentication before TenantValidation
The JWT middleware needs to fetch signing keys from Keycloak before
tenant validation runs. The previous order caused signature validation
to fail because the middleware was blocking the JWKS endpoint requests.

- Moved Authentication before TenantValidationMiddleware
- Removed realm endpoint from exemption list (not needed with correct order)
- This allows JWT middleware to fetch signing keys and validate tokens
2026-03-20 10:42:31 +01:00
WorkClub Automation 4ba76288b5 Add JWT debugging and fix Keycloak networking
- Added JWT authentication event logging to diagnose validation failures
- Fixed docker-compose networking for API to reach Keycloak via hostname
- Debug endpoint now accessible without auth for troubleshooting
- Still investigating why claims are not populated despite token being present
2026-03-20 10:30:10 +01:00
WorkClub Automation 97baf266a8 WIP: Fix Keycloak networking for API container 2026-03-20 10:15:50 +01:00
WorkClub Automation 0f9a7aba5c Make debug endpoint anonymous for troubleshooting 2026-03-20 09:56:24 +01:00
WorkClub Automation a3ca12da26 Add CORS configuration and exempt debug endpoint from tenant validation
- Add CORS policy to allow frontend requests from localhost:3000
- Exempt /api/debug endpoints from tenant validation
- Fix JSON parsing in realm_access claim checks
2026-03-20 09:42:16 +01:00
WorkClub Automation b52d75591b Add debug endpoint to inspect JWT claims 2026-03-20 09:34:29 +01:00
WorkClub Automation bb373a6b8e Fix admin authorization check - properly parse realm_access claim
The realm_access claim in JWT is a JSON object, not a simple string.
Previous string contains check was looking for escaped quotes in wrong format.

- Parse realm_access as JSON to extract roles array
- Check if 'admin' exists in roles array
- Fallback to string contains check if JSON parsing fails
- Applied fix in RequireGlobalAdmin policy, TenantValidationMiddleware,
  and ClubRoleClaimsTransformation

Fixes: Admin users getting 401 when trying to create clubs
2026-03-19 22:13:40 +01:00
25 changed files with 779 additions and 212 deletions
@@ -56,12 +56,35 @@ public class ClubRoleClaimsTransformation : IClaimsTransformation
// --- NEW: Skip DB role lookup if user is a global admin --- // --- NEW: Skip DB role lookup if user is a global admin ---
var realmAccess = principal.FindFirst("realm_access")?.Value; var realmAccess = principal.FindFirst("realm_access")?.Value;
if (!string.IsNullOrEmpty(realmAccess) && realmAccess.Contains("\"admin\"", StringComparison.OrdinalIgnoreCase)) if (!string.IsNullOrEmpty(realmAccess) && IsAdminUser(realmAccess))
{ {
return Task.FromResult(principal); return Task.FromResult(principal);
} }
// --------------------------------------------------------- // ---------------------------------------------------------
static bool IsAdminUser(string realmAccess)
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(realmAccess);
if (doc.RootElement.TryGetProperty("roles", out var rolesElement) &&
rolesElement.ValueKind == System.Text.Json.JsonValueKind.Array)
{
foreach (var role in rolesElement.EnumerateArray())
{
if (role.GetString()?.Equals("admin", StringComparison.OrdinalIgnoreCase) == true)
return true;
}
}
}
catch
{
// If JSON parsing fails, fallback to string contains check
return realmAccess.Contains("admin", StringComparison.OrdinalIgnoreCase);
}
return false;
}
// Look up the user's role in the database for the requested tenant // Look up the user's role in the database for the requested tenant
_httpContextAccessor.HttpContext!.Items["TenantId"] = tenantId; _httpContextAccessor.HttpContext!.Items["TenantId"] = tenantId;
var memberRole = GetMemberRole(userIdClaim, tenantId); var memberRole = GetMemberRole(userIdClaim, tenantId);
@@ -22,9 +22,11 @@ public class TenantValidationMiddleware
return; return;
} }
// Exempt bootstrap and admin endpoints from tenant validation // Exempt bootstrap, admin, debug, and Keycloak OIDC endpoints from tenant validation
if (context.Request.Path.StartsWithSegments("/api/clubs/me") || if (context.Request.Path.StartsWithSegments("/api/clubs/me") ||
context.Request.Path.StartsWithSegments("/api/admin")) context.Request.Path.StartsWithSegments("/api/admin") ||
context.Request.Path.StartsWithSegments("/api/debug") ||
context.Request.Path.StartsWithSegments("/realms"))
{ {
_logger.LogInformation("TenantValidationMiddleware: Exempting {Path} from tenant validation", context.Request.Path); _logger.LogInformation("TenantValidationMiddleware: Exempting {Path} from tenant validation", context.Request.Path);
await _next(context); await _next(context);
@@ -46,12 +48,35 @@ public class TenantValidationMiddleware
{ {
// NEW: Skip check if user is a global admin // NEW: Skip check if user is a global admin
var realmAccess = context.User.FindFirst("realm_access")?.Value; var realmAccess = context.User.FindFirst("realm_access")?.Value;
if (!string.IsNullOrEmpty(realmAccess) && realmAccess.Contains("\"admin\"", StringComparison.OrdinalIgnoreCase)) if (!string.IsNullOrEmpty(realmAccess) && IsAdminUser(realmAccess))
{ {
await _next(context); await _next(context);
return; return;
} }
static bool IsAdminUser(string realmAccess)
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(realmAccess);
if (doc.RootElement.TryGetProperty("roles", out var rolesElement) &&
rolesElement.ValueKind == System.Text.Json.JsonValueKind.Array)
{
foreach (var role in rolesElement.EnumerateArray())
{
if (role.GetString()?.Equals("admin", StringComparison.OrdinalIgnoreCase) == true)
return true;
}
}
}
catch
{
// If JSON parsing fails, fallback to string contains check
return realmAccess.Contains("admin", StringComparison.OrdinalIgnoreCase);
}
return false;
}
context.Response.StatusCode = StatusCodes.Status403Forbidden; context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new { error = "User does not have clubs claim" }); await context.Response.WriteAsJsonAsync(new { error = "User does not have clubs claim" });
return; return;
+126 -3
View File
@@ -31,6 +31,18 @@ builder.Services.AddScoped<MemberSyncService>();
builder.Services.AddScoped<TenantDbTransactionInterceptor>(); builder.Services.AddScoped<TenantDbTransactionInterceptor>();
builder.Services.AddSingleton<SaveChangesTenantInterceptor>(); builder.Services.AddSingleton<SaveChangesTenantInterceptor>();
// Add CORS to allow frontend requests
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowFrontend", policy =>
{
policy.WithOrigins("http://localhost:3000")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => .AddJwtBearer(options =>
{ {
@@ -38,13 +50,76 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
options.Audience = builder.Configuration["Keycloak:Audience"]; options.Audience = builder.Configuration["Keycloak:Audience"];
options.RequireHttpsMetadata = false; options.RequireHttpsMetadata = false;
options.MapInboundClaims = false; options.MapInboundClaims = false;
// For Docker internal communication, configure metadata and signing key resolution
// to bypass the hostname mismatch in Keycloak's discovery endpoint
var keycloakAuthority = builder.Configuration["Keycloak:Authority"];
var keycloakInternalUrl = "http://keycloak:8081";
if (keycloakAuthority?.Contains("keycloak:") == true)
{
// Set metadata address to internal Keycloak URL
options.MetadataAddress = $"{keycloakAuthority}/.well-known/openid-configuration";
// Configure custom signing key resolver to fetch from internal Keycloak URL
// This overrides the URLs returned in the discovery document
var httpClient = new HttpClient();
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
{ {
ValidateIssuer = false, // Disabled for local dev - external clients use localhost:8080, internal use keycloak:8080 ValidateIssuer = false,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) =>
{
// Fetch JWKS from internal Keycloak URL
var jwksUrl = $"{keycloakInternalUrl}/realms/workclub/protocol/openid-connect/certs";
try
{
var response = httpClient.GetStringAsync(jwksUrl).GetAwaiter().GetResult();
var jwks = new Microsoft.IdentityModel.Tokens.JsonWebKeySet(response);
return jwks.Keys;
}
catch (Exception ex)
{
Console.WriteLine($"Failed to fetch JWKS from {jwksUrl}: {ex.Message}");
return Array.Empty<Microsoft.IdentityModel.Tokens.SecurityKey>();
}
}
};
}
else
{
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = true, ValidateAudience = true,
ValidateLifetime = true, ValidateLifetime = true,
ValidateIssuerSigningKey = true ValidateIssuerSigningKey = true
}; };
}
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = context =>
{
Console.WriteLine($"JWT Authentication Failed: {context.Exception.Message}");
if (context.Exception.InnerException != null)
{
Console.WriteLine($"Inner Exception: {context.Exception.InnerException.Message}");
}
return Task.CompletedTask;
},
OnTokenValidated = context =>
{
Console.WriteLine($"JWT Token Validated for user: {context.Principal?.Identity?.Name ?? "unknown"}");
return Task.CompletedTask;
},
OnChallenge = context =>
{
Console.WriteLine($"JWT Challenge: {context.Error}");
return Task.CompletedTask;
}
};
}); });
builder.Services.AddScoped<IClaimsTransformation, ClubRoleClaimsTransformation>(); builder.Services.AddScoped<IClaimsTransformation, ClubRoleClaimsTransformation>();
@@ -53,7 +128,29 @@ builder.Services.AddAuthorizationBuilder()
.AddPolicy("RequireGlobalAdmin", policy => policy.RequireAssertion(context => .AddPolicy("RequireGlobalAdmin", policy => policy.RequireAssertion(context =>
{ {
var realmAccess = context.User.FindFirst("realm_access")?.Value; var realmAccess = context.User.FindFirst("realm_access")?.Value;
return realmAccess != null && realmAccess.Contains("\"admin\""); if (string.IsNullOrEmpty(realmAccess))
return false;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(realmAccess);
if (doc.RootElement.TryGetProperty("roles", out var rolesElement) &&
rolesElement.ValueKind == System.Text.Json.JsonValueKind.Array)
{
foreach (var role in rolesElement.EnumerateArray())
{
if (role.GetString() == "admin")
return true;
}
}
}
catch
{
// If JSON parsing fails, fallback to string contains check
return realmAccess.Contains("admin", StringComparison.OrdinalIgnoreCase);
}
return false;
})) }))
.AddPolicy("RequireManager", policy => policy.RequireRole("Manager")) .AddPolicy("RequireManager", policy => policy.RequireRole("Manager"))
.AddPolicy("RequireMember", policy => policy.RequireRole("Manager", "Member")) .AddPolicy("RequireMember", policy => policy.RequireRole("Manager", "Member"))
@@ -89,9 +186,14 @@ if (app.Environment.IsDevelopment())
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseCors("AllowFrontend");
// IMPORTANT: Order matters!
// 1. Authentication must come before tenant validation so JWT middleware can fetch JWKS
// 2. Tenant validation should come after auth but before endpoints
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<TenantValidationMiddleware>(); app.UseMiddleware<TenantValidationMiddleware>();
app.UseAuthorization();
app.UseMiddleware<MemberSyncMiddleware>(); app.UseMiddleware<MemberSyncMiddleware>();
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
@@ -121,9 +223,30 @@ app.MapGet("/weatherforecast", () =>
}) })
.WithName("GetWeatherForecast"); .WithName("GetWeatherForecast");
// Simple test endpoint for middleware validation tests
app.MapGet("/api/test", () => Results.Ok(new { message = "Test endpoint" })) app.MapGet("/api/test", () => Results.Ok(new { message = "Test endpoint" }))
.RequireAuthorization(); .RequireAuthorization();
app.MapGet("/api/debug/claims", (HttpContext context) =>
{
var claims = context.User.Claims.Select(c => new { c.Type, c.Value }).ToList();
var realmAccess = context.User.FindFirst("realm_access")?.Value;
// Check if the authorization header is present
var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();
return Results.Ok(new
{
isAuthenticated = context.User.Identity?.IsAuthenticated ?? false,
authenticationType = context.User.Identity?.AuthenticationType,
claimCount = claims.Count,
claims = claims,
realmAccess = realmAccess,
hasAuthHeader = !string.IsNullOrEmpty(authHeader),
authHeaderPrefix = authHeader?.Substring(0, Math.Min(20, authHeader?.Length ?? 0))
});
});
app.MapTaskEndpoints(); app.MapTaskEndpoints();
app.MapShiftEndpoints(); app.MapShiftEndpoints();
app.MapClubEndpoints(); app.MapClubEndpoints();
+39 -2
View File
@@ -12,32 +12,44 @@ 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()
{
try
{ {
var clubsClaim = _httpContextAccessor.HttpContext?.User.FindFirst("clubs")?.Value; var clubsClaim = _httpContextAccessor.HttpContext?.User.FindFirst("clubs")?.Value;
_logger.LogInformation("GetMyClubsAsync: Clubs claim value: {ClubsClaim}", clubsClaim);
if (string.IsNullOrEmpty(clubsClaim)) if (string.IsNullOrEmpty(clubsClaim))
{ {
_logger.LogWarning("GetMyClubsAsync: No clubs claim found for user");
return new List<ClubListDto>(); return new List<ClubListDto>();
} }
// Parse UUIDs from comma-separated claim, filtering out non-UUID values (like role names)
var tenantIds = clubsClaim.Split(',', StringSplitOptions.RemoveEmptyEntries) var tenantIds = clubsClaim.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(t => t.Trim()) .Select(t => t.Trim())
.Where(t => !string.IsNullOrEmpty(t) && Guid.TryParse(t, out _)) .Where(t => !string.IsNullOrEmpty(t) && Guid.TryParse(t, out _))
.ToList(); .ToList();
_logger.LogInformation("GetMyClubsAsync: Parsed {Count} valid tenant IDs from claim", tenantIds.Count);
if (tenantIds.Count == 0) if (tenantIds.Count == 0)
{ {
_logger.LogWarning("GetMyClubsAsync: No valid tenant IDs found in clubs claim: {ClubsClaim}", clubsClaim);
return new List<ClubListDto>(); return new List<ClubListDto>();
} }
@@ -45,13 +57,16 @@ public class ClubService
var connectionString = _context.Database.GetConnectionString(); var connectionString = _context.Database.GetConnectionString();
foreach (var tenantId in tenantIds) foreach (var tenantId in tenantIds)
{
try
{ {
await using var connection = new NpgsqlConnection(connectionString); await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync(); await connection.OpenAsync();
await using var transaction = await connection.BeginTransactionAsync(); await using var transaction = await connection.BeginTransactionAsync();
// Set RLS context // 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()) using (var command = connection.CreateCommand())
{ {
command.Transaction = transaction; command.Transaction = transaction;
@@ -120,11 +135,27 @@ public class ClubService
await transaction.CommitAsync(); await transaction.CommitAsync();
} }
catch (Exception ex)
{
_logger.LogError(ex, "GetMyClubsAsync: Error processing tenant {TenantId}", tenantId);
// Continue with next tenant instead of failing entirely
}
}
_logger.LogInformation("GetMyClubsAsync: Returning {Count} clubs", clubDtos.Count);
return clubDtos; 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>();
}
}
public async Task<ClubDetailDto?> GetCurrentClubAsync() public async Task<ClubDetailDto?> GetCurrentClubAsync()
{
try
{ {
var tenantId = _tenantProvider.GetTenantId(); var tenantId = _tenantProvider.GetTenantId();
@@ -143,4 +174,10 @@ public class ClubService
club.UpdatedAt club.UpdatedAt
); );
} }
catch (Exception ex)
{
_logger.LogError(ex, "GetCurrentClubAsync: Error getting current club");
return null;
}
}
} }
@@ -9,7 +9,7 @@
"DefaultConnection": "Host=localhost;Port=5432;Database=workclub;Username=app;Password=apppass" "DefaultConnection": "Host=localhost;Port=5432;Database=workclub;Username=app;Password=apppass"
}, },
"Keycloak": { "Keycloak": {
"Authority": "http://localhost:8080/realms/workclub", "Authority": "http://localhost:30808/realms/workclub",
"Audience": "workclub-api" "Audience": "workclub-api"
} }
} }
@@ -184,9 +184,9 @@ public class ClubEndpointsTests : IntegrationTestBase
Assert.Equal("Cycling", club.SportType); Assert.Equal("Cycling", club.SportType);
} }
[Fact] [Fact]
public async Task GetClubsCurrent_NoTenantContext_ReturnsForbidden() public async Task GetClubsCurrent_NoTenantContext_ReturnsBadRequest()
{ {
AuthenticateAs("admin@test.com", new Dictionary<string, string> AuthenticateAs("admin@test.com", new Dictionary<string, string>
{ {
[Tenant1Id] = "Admin" [Tenant1Id] = "Admin"
@@ -194,8 +194,24 @@ public class ClubEndpointsTests : IntegrationTestBase
var response = await Client.GetAsync("/api/clubs/current"); var response = await Client.GetAsync("/api/clubs/current");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task GetClubsCurrent_InvalidTenant_ReturnsForbidden()
{
AuthenticateAs("admin@test.com", new Dictionary<string, string>
{
[Tenant1Id] = "Admin"
}, userId: "admin-user-id");
// Set tenant that user is not a member of
SetTenant("invalid-tenant-id");
var response = await Client.GetAsync("/api/clubs/current");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
} }
[Fact] [Fact]
public async Task GetClubsMe_Unauthenticated_ReturnsUnauthorized() public async Task GetClubsMe_Unauthenticated_ReturnsUnauthorized()
+13 -11
View File
@@ -39,12 +39,15 @@ services:
KC_DB_PASSWORD: keycloakpass KC_DB_PASSWORD: keycloakpass
KC_HEALTH_ENABLED: "true" KC_HEALTH_ENABLED: "true"
KC_LOG_LEVEL: INFO KC_LOG_LEVEL: INFO
KC_HOSTNAME: "http://localhost:8080" KC_HOSTNAME: "http://localhost:30808"
KC_HOSTNAME_STRICT: "false" KC_HOSTNAME_STRICT: "false"
KC_PROXY: "edge" KC_PROXY: "edge"
KC_HTTP_PORT: "8081" KC_HTTP_PORT: "8081"
# Additional hostname for internal Docker communication
KC_HOSTNAME_ADMIN: "http://keycloak:8081"
KC_SPI_HOSTNAME_DEFAULT_ADMIN: "keycloak:8081"
ports: ports:
- "8080:8081" - "30808:8081"
volumes: volumes:
- ./infra/keycloak:/opt/keycloak/data/import - ./infra/keycloak:/opt/keycloak/data/import
depends_on: depends_on:
@@ -64,15 +67,14 @@ services:
environment: environment:
ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_ENVIRONMENT: Development
ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=workclub;Username=workclub;Password=dev_password_change_in_production" ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=workclub;Username=workclub;Password=dev_password_change_in_production"
Keycloak__Authority: "http://192.168.65.254:8080/realms/workclub" Keycloak__Authority: "http://keycloak:8081/realms/workclub"
Keycloak__Audience: "workclub-api" Keycloak__Audience: "workclub-api"
Keycloak__TokenValidationParameters__ValidateIssuer: "false" Keycloak__TokenValidationParameters__ValidateIssuer: "false"
ports: ports:
- "5001:8080" - "30501:8080"
extra_hosts: extra_hosts:
- "localhost:host-gateway" - "localhost:172.18.0.1"
- "127.0.0.1:host-gateway" - "127.0.0.1:172.18.0.1"
- "keycloak:host-gateway"
working_dir: /app working_dir: /app
volumes: volumes:
- ./backend:/app:cached - ./backend:/app:cached
@@ -91,18 +93,18 @@ services:
extra_hosts: extra_hosts:
- "localhost:host-gateway" - "localhost:host-gateway"
environment: environment:
NEXT_PUBLIC_API_URL: "http://localhost:5001" NEXT_PUBLIC_API_URL: "http://localhost:30501"
API_INTERNAL_URL: "http://dotnet-api:8080" API_INTERNAL_URL: "http://dotnet-api:8080"
NEXTAUTH_SECRET: "dev-secret-change-in-production-use-openssl-rand-base64-32" NEXTAUTH_SECRET: "dev-secret-change-in-production-use-openssl-rand-base64-32"
AUTH_SECRET: "dev-secret-change-in-production-use-openssl-rand-base64-32" AUTH_SECRET: "dev-secret-change-in-production-use-openssl-rand-base64-32"
AUTH_TRUST_HOST: "true" AUTH_TRUST_HOST: "true"
KEYCLOAK_CLIENT_ID: "workclub-app" KEYCLOAK_CLIENT_ID: "workclub-app"
KEYCLOAK_CLIENT_SECRET: "dev-secret-workclub-api-change-in-production" KEYCLOAK_CLIENT_SECRET: "dev-secret-workclub-api-change-in-production"
KEYCLOAK_ISSUER: "http://localhost:8080/realms/workclub" KEYCLOAK_ISSUER: "http://localhost:30808/realms/workclub"
KEYCLOAK_ISSUER_INTERNAL: "http://keycloak:8081/realms/workclub" KEYCLOAK_ISSUER_INTERNAL: "http://keycloak:8081/realms/workclub"
NEXT_PUBLIC_KEYCLOAK_ISSUER: "http://localhost:8080/realms/workclub" NEXT_PUBLIC_KEYCLOAK_ISSUER: "http://localhost:30808/realms/workclub"
ports: ports:
- "3000:3000" - "30080:3000"
volumes: volumes:
- ./frontend:/app:cached - ./frontend:/app:cached
- /app/node_modules - /app/node_modules
+8 -1
View File
@@ -13,6 +13,11 @@ RUN npm install -g bun
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
# Set environment for build to ensure server binds to all interfaces
ENV HOSTNAME="0.0.0.0"
ENV PORT="3000"
# Set API_INTERNAL_URL for build-time Next.js rewrites evaluation
ENV API_INTERNAL_URL="http://workclub-api:8080"
RUN bun run build RUN bun run build
# Stage 3: Runtime # Stage 3: Runtime
@@ -36,5 +41,7 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})" CMD node -e "require('http').get('http://localhost:3000', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"
# Start standalone server # Start standalone server - bind to all interfaces (0.0.0.0) for external access
ENV HOSTNAME="0.0.0.0"
ENV PORT="3000"
CMD ["node", "server.js"] CMD ["node", "server.js"]
+1 -1
View File
@@ -48,7 +48,7 @@ function LoginContent() {
}; };
const handleSwitchAccount = () => { const handleSwitchAccount = () => {
const keycloakLogoutUrl = `${process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER || 'http://localhost:8080/realms/workclub'}/protocol/openid-connect/logout?redirect_uri=${encodeURIComponent(window.location.origin + '/login')}`; const keycloakLogoutUrl = `${process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER || 'http://localhost:30808/realms/workclub'}/protocol/openid-connect/logout?redirect_uri=${encodeURIComponent(window.location.origin + '/login')}`;
signOut({ redirect: false }).then(() => { signOut({ redirect: false }).then(() => {
window.location.href = keycloakLogoutUrl; window.location.href = keycloakLogoutUrl;
}); });
+17 -4
View File
@@ -24,15 +24,15 @@ declare module "next-auth" {
// In Docker, the Next.js server reaches Keycloak via internal hostname // In Docker, the Next.js server reaches Keycloak via internal hostname
// (keycloak:8080) but the browser uses localhost:8080. Explicit endpoint // (keycloak:8080) but the browser uses localhost:8080. Explicit endpoint
// URLs bypass OIDC discovery, avoiding issuer mismatch validation errors. // URLs bypass OIDC discovery, avoiding issuer mismatch validation errors.
const issuerPublic = process.env.KEYCLOAK_ISSUER! const issuerPublic = process.env.KEYCLOAK_ISSUER || 'http://localhost:30808/realms/workclub'
const issuerInternal = process.env.KEYCLOAK_ISSUER_INTERNAL || issuerPublic const issuerInternal = process.env.KEYCLOAK_ISSUER_INTERNAL || issuerPublic
const oidcPublic = `${issuerPublic}/protocol/openid-connect` const oidcPublic = `${issuerPublic}/protocol/openid-connect`
const oidcInternal = `${issuerInternal.replace(':8080', ':8081')}/protocol/openid-connect` const oidcInternal = `${issuerInternal}/protocol/openid-connect`
export const { handlers, signIn, signOut, auth } = NextAuth({ export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [ providers: [
KeycloakProvider({ KeycloakProvider({
clientId: process.env.KEYCLOAK_CLIENT_ID!, clientId: process.env.KEYCLOAK_CLIENT_ID || 'workclub-app',
issuer: issuerPublic, issuer: issuerPublic,
authorization: { authorization: {
url: `${oidcPublic}/auth`, url: `${oidcPublic}/auth`,
@@ -71,14 +71,21 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
// Add clubs claim from Keycloak access token // Add clubs claim from Keycloak access token
token.clubs = (account as { clubs?: Record<string, string> }).clubs || {} token.clubs = (account as { clubs?: Record<string, string> }).clubs || {}
token.accessToken = account.access_token token.accessToken = account.access_token
}
// Always check admin status from the access token if available
if (token.accessToken) {
try { try {
const payload = JSON.parse(Buffer.from((token.accessToken as string).split('.')[1], 'base64').toString()); const payload = JSON.parse(Buffer.from((token.accessToken as string).split('.')[1], 'base64').toString());
const roles = (payload.realm_access?.roles as string[]) || []; const roles = (payload.realm_access?.roles as string[]) || [];
token.isAdmin = roles.includes('admin'); token.isAdmin = roles.includes('admin');
} catch { console.log('[Auth Debug] Checking admin status:', { roles, isAdmin: token.isAdmin });
} catch (e) {
console.error('[Auth Debug] Failed to check admin status:', e);
token.isAdmin = false; token.isAdmin = false;
} }
} else {
console.log('[Auth Debug] No access token available');
} }
return token return token
}, },
@@ -89,6 +96,12 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
session.user.isAdmin = token.isAdmin as boolean | undefined session.user.isAdmin = token.isAdmin as boolean | undefined
} }
session.accessToken = token.accessToken as string | undefined session.accessToken = token.accessToken as string | undefined
// Log session data for debugging
console.log('[Session Debug] Session user:', session.user);
console.log('[Session Debug] Token isAdmin:', token.isAdmin);
console.log('[Session Debug] Session isAdmin:', session.user?.isAdmin);
return session return session
} }
} }
+7
View File
@@ -60,6 +60,13 @@ export function AuthGuard({ children }: { children: ReactNode }) {
} }
const isAdmin = data?.user?.isAdmin; const isAdmin = data?.user?.isAdmin;
// Debug: Log auth state
console.log('[AuthGuard Debug] status:', status);
console.log('[AuthGuard Debug] isAdmin:', isAdmin);
console.log('[AuthGuard Debug] data?.user:', data?.user);
console.log('[AuthGuard Debug] clubs.length:', clubs.length);
if (clubs.length === 0 && status === 'authenticated' && !isAdmin) { if (clubs.length === 0 && status === 'authenticated' && !isAdmin) {
const handleSwitchAccount = () => { const handleSwitchAccount = () => {
const keycloakLogoutUrl = `${process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER || 'http://localhost:8080/realms/workclub'}/protocol/openid-connect/logout?redirect_uri=${encodeURIComponent(window.location.origin + '/login')}`; const keycloakLogoutUrl = `${process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER || 'http://localhost:8080/realms/workclub'}/protocol/openid-connect/logout?redirect_uri=${encodeURIComponent(window.location.origin + '/login')}`;
+2 -2
View File
@@ -11,7 +11,7 @@ spec:
app: workclub-api app: workclub-api
ports: ports:
- name: http - name: http
port: 80 port: 8080
targetPort: 8080 targetPort: 8080
nodePort: 30081 nodePort: 30501
protocol: TCP protocol: TCP
+4 -4
View File
@@ -6,10 +6,10 @@ metadata:
app: workclub app: workclub
data: data:
log-level: "Information" log-level: "Information"
cors-origins: "http://localhost:3000,http://192.168.240.200:30080" cors-origins: "http://localhost:30080,http://192.168.240.200:30080,http://192.168.240.200:30808"
api-base-url: "http://192.168.240.200:30081" api-base-url: "http://192.168.240.200:30501"
keycloak-url: "http://192.168.240.200:30082" keycloak-url: "http://192.168.240.200:30808"
keycloak-authority: "http://192.168.240.200:30082/realms/workclub" keycloak-authority: "http://192.168.240.200:30808/realms/workclub"
keycloak-audience: "workclub-api" keycloak-audience: "workclub-api"
keycloak-realm: "workclub" keycloak-realm: "workclub"
+3 -3
View File
@@ -40,7 +40,6 @@ spec:
periodSeconds: 15 periodSeconds: 15
timeoutSeconds: 5 timeoutSeconds: 5
failureThreshold: 3 failureThreshold: 3
resources: resources:
requests: requests:
cpu: 100m cpu: 100m
@@ -48,10 +47,11 @@ spec:
limits: limits:
cpu: 500m cpu: 500m
memory: 512Mi memory: 512Mi
env: env:
- name: NODE_ENV - name: NODE_ENV
value: "production" value: "production"
- name: API_INTERNAL_URL
value: "http://workclub-api:8080"
- name: NEXT_PUBLIC_API_URL - name: NEXT_PUBLIC_API_URL
valueFrom: valueFrom:
configMapKeyRef: configMapKeyRef:
@@ -89,4 +89,4 @@ spec:
name: workclub-config name: workclub-config
key: keycloak-authority key: keycloak-authority
- name: KEYCLOAK_ISSUER_INTERNAL - name: KEYCLOAK_ISSUER_INTERNAL
value: "http://workclub-keycloak/realms/workclub" value: "http://workclub-keycloak:8080/realms/workclub"
@@ -0,0 +1,92 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: workclub-frontend
labels:
app: workclub-frontend
component: frontend
spec:
replicas: 1
selector:
matchLabels:
app: workclub-frontend
template:
metadata:
labels:
app: workclub-frontend
component: frontend
spec:
containers:
- name: frontend
image: 192.168.241.13:8080/workclub-frontend:latest
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 3000
protocol: TCP
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 2
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 10
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
env:
- name: NODE_ENV
value: "production"
- name: NEXT_PUBLIC_API_URL
valueFrom:
configMapKeyRef:
name: workclub-config
key: api-base-url
- name: NEXT_PUBLIC_KEYCLOAK_URL
valueFrom:
configMapKeyRef:
name: workclub-config
key: keycloak-url
- name: NEXT_PUBLIC_KEYCLOAK_ISSUER
valueFrom:
configMapKeyRef:
name: workclub-config
key: keycloak-authority
- name: NEXTAUTH_URL
value: "http://192.168.240.200:3000"
- name: AUTH_TRUST_HOST
value: "true"
- name: NEXTAUTH_SECRET
valueFrom:
secretKeyRef:
name: workclub-secrets
key: nextauth-secret
- name: KEYCLOAK_CLIENT_ID
value: "workclub-app"
- name: KEYCLOAK_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: workclub-secrets
key: keycloak-client-secret
- name: KEYCLOAK_ISSUER
valueFrom:
configMapKeyRef:
name: workclub-config
key: keycloak-authority
- name: KEYCLOAK_ISSUER_INTERNAL
value: "http://workclub-keycloak/realms/workclub"
+1 -1
View File
@@ -11,7 +11,7 @@ spec:
app: workclub-frontend app: workclub-frontend
ports: ports:
- name: http - name: http
port: 80 port: 3000
targetPort: 3000 targetPort: 3000
nodePort: 30080 nodePort: 30080
protocol: TCP protocol: TCP
+1
View File
@@ -26,6 +26,7 @@ spec:
args: args:
- start-dev - start-dev
- --import-realm - --import-realm
- --import-realm-overwrite
ports: ports:
- name: http - name: http
containerPort: 8080 containerPort: 8080
@@ -69,14 +69,14 @@ data:
"protocol": "openid-connect", "protocol": "openid-connect",
"publicClient": true, "publicClient": true,
"redirectUris": [ "redirectUris": [
"http://localhost:3000/*", "http://localhost:30080/*",
"http://localhost:3001/*", "http://localhost:30081/*",
"http://workclub-frontend/*", "http://workclub-frontend/*",
"http://192.168.240.200:30080/*" "http://192.168.240.200:30080/*"
], ],
"webOrigins": [ "webOrigins": [
"http://localhost:3000", "http://localhost:30080",
"http://localhost:3001", "http://localhost:30081",
"http://workclub-frontend", "http://workclub-frontend",
"http://192.168.240.200:30080" "http://192.168.240.200:30080"
], ],
@@ -152,7 +152,7 @@ data:
], ],
"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"
] ]
} }
}, },
@@ -174,7 +174,7 @@ data:
], ],
"attributes": { "attributes": {
"clubs": [ "clubs": [
"64e05b5e-ef45-81d7-f2e8-3d14bd197383,Manager" "64e05b5e-ef45-81d7-f2e8-3d14bd197383"
] ]
} }
}, },
@@ -196,7 +196,7 @@ data:
], ],
"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"
] ]
} }
}, },
@@ -218,7 +218,7 @@ data:
], ],
"attributes": { "attributes": {
"clubs": [ "clubs": [
"64e05b5e-ef45-81d7-f2e8-3d14bd197383,Member" "64e05b5e-ef45-81d7-f2e8-3d14bd197383"
] ]
} }
}, },
@@ -240,7 +240,7 @@ data:
], ],
"attributes": { "attributes": {
"clubs": [ "clubs": [
"64e05b5e-ef45-81d7-f2e8-3d14bd197383,Viewer" "64e05b5e-ef45-81d7-f2e8-3d14bd197383"
] ]
} }
} }
+2 -2
View File
@@ -11,7 +11,7 @@ spec:
app: workclub-keycloak app: workclub-keycloak
ports: ports:
- name: http - name: http
port: 80 port: 8080
targetPort: 8080 targetPort: 8080
nodePort: 30082 nodePort: 30808
protocol: TCP protocol: TCP
+6 -4
View File
@@ -86,14 +86,14 @@
"authorizationServicesEnabled": false, "authorizationServicesEnabled": false,
"protocol": "openid-connect", "protocol": "openid-connect",
"redirectUris": [ "redirectUris": [
"http://localhost:3000/*" "http://localhost:30080/*"
], ],
"webOrigins": [ "webOrigins": [
"http://localhost:3000" "http://localhost:30080"
], ],
"attributes": { "attributes": {
"pkce.code.challenge.method": "S256", "pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "http://localhost:3000/*", "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": [
{ {
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-18
@@ -0,0 +1,99 @@
## Context
Currently, the frontend displays raw UUIDs for user references:
- Task list shows `assigneeId` (e.g., "a1b2c3d4-e5f6...") or "Unassigned"
- Task detail shows `assigneeId` and `createdById`
- Shift detail shows `memberId` for each signup
The backend already stores `DisplayName` in the `Member` entity but the API DTOs don't expose it. The `ShiftService` already demonstrates the pattern of joining with Members (lines 82-87), which we can replicate for Tasks.
## Goals / Non-Goals
**Goals:**
- Add member name fields to backend DTOs
- Update TaskService to query and include member names
- Update ShiftService to include member name in ShiftSignupDto
- Update frontend TypeScript interfaces
- Replace UUID displays with names in task/shift UIs
**Non-Goals:**
- No database schema changes
- No changes to authentication or authorization
- No changes to how tasks/shifts are created or updated
- No caching layer for member names
## Decisions
### 1. Add names to existing DTOs vs create new DTOs
**Decision:** Add optional fields to existing DTOs
**Rationale:**
- Keeps API surface simple
- Backward compatible - existing clients ignore new fields
- No breaking changes to existing integrations
**Alternative considered:** Create new DTO versions (e.g., `TaskDetailDtoV2`)
- Rejected: Unnecessary complexity for a simple additive change
### 2. Fetch member names via JOIN vs separate query
**Decision:** Use JOIN in TaskService methods
**Rationale:**
- More efficient - single query per endpoint
- Pattern already exists in ShiftService
- Avoids N+1 query problem
**Alternative considered:** Query members separately and build lookup dictionary
- Rejected: Adds complexity and extra database round-trips
### 3. Handle missing members (orphaned IDs)
**Decision:** Return null for name when member not found
**Rationale:**
- Data integrity issue should surface visibly
- Frontend can display fallback like "Unknown" or keep showing ID
- Logging can track data inconsistencies
### 4. Frontend handling of null names
**Decision:** Frontend shows fallback text when name is null
**Implementation:**
```typescript
// Task list
task.assigneeName || 'Unassigned'
// Task detail
task.assigneeName || 'Unassigned'
task.createdByName || 'Unknown'
// Shift signups
signup.memberName || 'Unknown Member'
```
## Risks / Trade-offs
| Risk | Mitigation |
|------|-----------|
| JOIN adds query complexity | Keep JOINs simple, only on indexed columns (Member.Id) |
| Larger API response payloads | Minimal impact - names are small strings |
| Member names become stale | Acceptable - names rarely change; eventual consistency |
| Database performance degradation | Monitor query execution plans; add caching if needed |
| Partial data on member deletion | Show "Unknown" fallback; log orphaned references |
## Migration Plan
1. **Backend DTO changes** - Add new optional fields
2. **Backend service changes** - Update queries to include names
3. **Frontend type updates** - Add name fields to interfaces
4. **Frontend UI updates** - Replace ID displays with names
**Rollback:**
- DTO changes are backward compatible
- Frontend can revert to showing IDs by changing display logic
- No database changes required
## Open Questions
- Should we include `externalUserId` in the signup display? (Currently available in ShiftSignupDto)
- Do we need to include member email for any display purposes?
- Should we add name fields to shift list items (showing creator name)?
@@ -0,0 +1,34 @@
## Why
Currently, the frontend displays raw UUIDs for user references (assignee, creator, members) which creates a poor user experience. Users should see meaningful names like "Alice Smith" instead of "a1b2c3d4-e5f6-7890-abcd-ef1234567890". The backend already stores display names in the Member entity, but the API DTOs don't expose them.
## What Changes
- **Backend DTOs**: Add name fields to task and shift DTOs
- `TaskListItemDto`: Add `string? AssigneeName`
- `TaskDetailDto`: Add `string? AssigneeName` and `string CreatedByName`
- `ShiftSignupDto`: Add `string MemberName`
- **Backend Services**: Update TaskService and ShiftService to query and populate member names
- Join with Members table to fetch display names
- Include names in DTO construction
- **Frontend Types**: Update TypeScript interfaces to include new name fields
- `TaskListItemDto`, `TaskDetailDto`, `ShiftSignupDto` interfaces
- **Frontend UI**: Replace UUID displays with names
- Task list: show assignee name instead of ID
- Task detail: show assignee and creator names
- Shift detail: show member names in signup list
## Capabilities
### New Capabilities
- `member-name-enrichment`: API DTOs include human-readable member names alongside IDs
### Modified Capabilities
- None (this is purely an enhancement to existing capabilities)
## Impact
- **Backend**: TaskService.cs, ShiftService.cs, and DTOs in WorkClub.Application
- **Frontend**: Tasks pages, Shifts pages, and React hooks (useTasks.ts, useShifts.ts)
- **Database**: Additional JOIN queries on Members table (no schema changes)
- **API Response**: New optional fields in existing endpoints (backward compatible)
@@ -0,0 +1,43 @@
## ADDED Requirements
### Requirement: Task list items include assignee name
The API SHALL return the assignee's display name in TaskListItemDto.
#### Scenario: Task with assignee
- **WHEN** a task is assigned to a member
- **THEN** the TaskListItemDto SHALL include the assignee's DisplayName as `assigneeName`
#### Scenario: Task without assignee
- **WHEN** a task has no assignee
- **THEN** the TaskListItemDto SHALL have `assigneeName` set to null
### Requirement: Task details include creator and assignee names
The API SHALL return the display names of both the creator and assignee in TaskDetailDto.
#### Scenario: Viewing task details
- **WHEN** a user requests task details
- **THEN** the TaskDetailDto SHALL include `createdByName` (the creator's DisplayName)
- **AND** the TaskDetailDto SHALL include `assigneeName` (the assignee's DisplayName, or null if unassigned)
### Requirement: Shift signup includes member name
The API SHALL return the member's display name in ShiftSignupDto.
#### Scenario: Viewing shift signups
- **WHEN** a user views shift details with signups
- **THEN** each ShiftSignupDto SHALL include `memberName` (the member's DisplayName)
### Requirement: Frontend displays names instead of UUIDs
The frontend SHALL render member names instead of UUIDs wherever user references appear.
#### Scenario: Task list view
- **WHEN** viewing the task list
- **THEN** the Assignee column SHALL display the assignee's name (or "Unassigned")
#### Scenario: Task detail view
- **WHEN** viewing a task detail page
- **THEN** the Assignee field SHALL display the assignee's name (or "Unassigned")
- **AND** the Created By field SHALL display the creator's name
#### Scenario: Shift detail view
- **WHEN** viewing a shift detail page with signups
- **THEN** the member list SHALL display each member's name instead of their ID
@@ -0,0 +1,41 @@
## 1. Backend DTO Updates
- [x] 1.1 Update TaskListItemDto.cs to add `string? AssigneeName` field
- [x] 1.2 Update TaskDetailDto.cs to add `string? AssigneeName` and `string? CreatedByName` fields
- [x] 1.3 Update ShiftSignupDto.cs to add `string? MemberName` field
## 2. Backend Service Updates - Tasks
- [x] 2.1 Update TaskService.GetTasksAsync() to join with Members and populate assigneeName
- [x] 2.2 Update TaskService.GetTaskByIdAsync() to join with Members for assignee and creator names
- [x] 2.3 Update TaskService.CreateTaskAsync() to fetch and include creator name in response
- [x] 2.4 Update TaskService.UpdateTaskAsync() to join with Members for assignee and creator names
## 3. Backend Service Updates - Shifts
- [x] 3.1 Update ShiftService.GetShiftByIdAsync() to include member display name in ShiftSignupDto
- [x] 3.2 Update ShiftService.UpdateShiftAsync() to include member display name in ShiftSignupDto
## 4. Frontend Type Updates
- [x] 4.1 Update TaskListItemDto interface in useTasks.ts to add `assigneeName?: string`
- [x] 4.2 Update TaskDetailDto interface in useTasks.ts to add `assigneeName?: string` and `createdByName?: string`
- [x] 4.3 Update ShiftSignupDto interface in useShifts.ts to add `memberName?: string`
## 5. Frontend UI Updates - Tasks
- [x] 5.1 Update tasks/page.tsx to display assigneeName instead of assigneeId
- [x] 5.2 Update tasks/[id]/page.tsx to display assigneeName instead of assigneeId
- [x] 5.3 Update tasks/[id]/page.tsx to display createdByName instead of createdById
## 6. Frontend UI Updates - Shifts
- [x] 6.1 Update shifts/[id]/page.tsx to display memberName instead of memberId in signup list
## 7. Testing & Verification
- [x] 7.1 Run backend build to verify C# compilation succeeds
- [x] 7.2 Run frontend build to verify TypeScript compilation succeeds
- [x] 7.3 Verify task list shows member names correctly
- [x] 7.4 Verify task detail shows assignee and creator names
- [x] 7.5 Verify shift detail shows member names in signup list