Compare commits
30 Commits
ade9444682
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ad8bb2d320 | |||
| b10c57bdb8 | |||
| 9304db2391 | |||
| 27f1ad5780 | |||
| 4e52544c79 | |||
| e6e1112060 | |||
| b5dd24b4c9 | |||
| f8d698ba42 | |||
| 86c7b0d46d | |||
| fd2931e59c | |||
| a5ebecc8b5 | |||
| 956c3ead0c | |||
| 0100def25a | |||
| 79fabd5348 | |||
| 1aea91da55 | |||
| d1e80e39a7 | |||
| 28284d7edc | |||
| 66719d9787 | |||
| 984ab77137 | |||
| 0f036a2ef6 | |||
| fdc1f415b7 | |||
| 13f9e7be7f | |||
| 87c315c6fd | |||
| 26d7d83811 | |||
| 4ba76288b5 | |||
| 97baf266a8 | |||
| 0f9a7aba5c | |||
| a3ca12da26 | |||
| b52d75591b | |||
| bb373a6b8e |
@@ -54,13 +54,36 @@ public class ClubRoleClaimsTransformation : IClaimsTransformation
|
|||||||
return Task.FromResult(principal);
|
return Task.FromResult(principal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 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);
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
return Task.FromResult(principal);
|
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;
|
||||||
|
|||||||
@@ -22,14 +22,16 @@ 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") ||
|
||||||
_logger.LogInformation("TenantValidationMiddleware: Exempting {Path} from tenant validation", context.Request.Path);
|
context.Request.Path.StartsWithSegments("/realms"))
|
||||||
await _next(context);
|
{
|
||||||
return;
|
_logger.LogInformation("TenantValidationMiddleware: Exempting {Path} from tenant validation", context.Request.Path);
|
||||||
}
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantIdHeader) ||
|
if (!context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantIdHeader) ||
|
||||||
string.IsNullOrWhiteSpace(tenantIdHeader))
|
string.IsNullOrWhiteSpace(tenantIdHeader))
|
||||||
@@ -44,13 +46,36 @@ public class TenantValidationMiddleware
|
|||||||
|
|
||||||
if (string.IsNullOrEmpty(clubsClaim))
|
if (string.IsNullOrEmpty(clubsClaim))
|
||||||
{
|
{
|
||||||
// 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" });
|
||||||
|
|||||||
@@ -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,12 +50,75 @@ 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;
|
||||||
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
|
|
||||||
|
// 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)
|
||||||
{
|
{
|
||||||
ValidateIssuer = false, // Disabled for local dev - external clients use localhost:8080, internal use keycloak:8080
|
// Set metadata address to internal Keycloak URL
|
||||||
ValidateAudience = true,
|
options.MetadataAddress = $"{keycloakAuthority}/.well-known/openid-configuration";
|
||||||
ValidateLifetime = true,
|
|
||||||
ValidateIssuerSigningKey = true
|
// 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
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
ValidateLifetime = 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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,18 +184,34 @@ 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"
|
||||||
{
|
}, userId: "admin-user-id");
|
||||||
[Tenant1Id] = "Admin"
|
|
||||||
}, userId: "admin-user-id");
|
|
||||||
|
|
||||||
var response = await Client.GetAsync("/api/clubs/current");
|
var response = await Client.GetAsync("/api/clubs/current");
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetClubsMe_Unauthenticated_ReturnsUnauthorized()
|
public async Task GetClubsMe_Unauthenticated_ReturnsUnauthorized()
|
||||||
|
|||||||
+13
-11
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')}`;
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ spec:
|
|||||||
selector:
|
selector:
|
||||||
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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -10,8 +10,8 @@ spec:
|
|||||||
selector:
|
selector:
|
||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -68,18 +68,18 @@ data:
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"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"
|
||||||
],
|
],
|
||||||
"directAccessGrantsEnabled": true,
|
"directAccessGrantsEnabled": true,
|
||||||
"standardFlowEnabled": true,
|
"standardFlowEnabled": true,
|
||||||
"implicitFlowEnabled": false,
|
"implicitFlowEnabled": false,
|
||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ spec:
|
|||||||
selector:
|
selector:
|
||||||
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
|
||||||
|
|||||||
@@ -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)
|
||||||
+43
@@ -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
|
||||||
Reference in New Issue
Block a user