Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fdc1f415b7 | |||
| 13f9e7be7f | |||
| 87c315c6fd | |||
| 26d7d83811 | |||
| 4ba76288b5 | |||
| 97baf266a8 | |||
| 0f9a7aba5c | |||
| a3ca12da26 | |||
| b52d75591b | |||
| bb373a6b8e | |||
| ade9444682 | |||
| 112b299b8e | |||
| 04641319ce | |||
| d295c9123e | |||
| da70cf4b13 |
@@ -54,6 +54,37 @@ public class ClubRoleClaimsTransformation : IClaimsTransformation
|
|||||||
return Task.FromResult(principal);
|
return Task.FromResult(principal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- NEW: Skip DB role lookup if user is a global admin ---
|
||||||
|
var realmAccess = principal.FindFirst("realm_access")?.Value;
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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);
|
||||||
@@ -44,6 +46,37 @@ public class TenantValidationMiddleware
|
|||||||
|
|
||||||
if (string.IsNullOrEmpty(clubsClaim))
|
if (string.IsNullOrEmpty(clubsClaim))
|
||||||
{
|
{
|
||||||
|
// NEW: Skip check if user is a global admin
|
||||||
|
var realmAccess = context.User.FindFirst("realm_access")?.Value;
|
||||||
|
if (!string.IsNullOrEmpty(realmAccess) && IsAdminUser(realmAccess))
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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,6 +186,11 @@ 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.UseMiddleware<TenantValidationMiddleware>();
|
app.UseMiddleware<TenantValidationMiddleware>();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ namespace WorkClub.Api.Services;
|
|||||||
public class AdminClubService
|
public class AdminClubService
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _context;
|
private readonly AppDbContext _context;
|
||||||
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
|
|
||||||
public AdminClubService(AppDbContext context)
|
public AdminClubService(AppDbContext context, IHttpContextAccessor httpContextAccessor)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
|
_httpContextAccessor = httpContextAccessor;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<ClubDetailDto>> GetAllClubsAsync()
|
public async Task<List<ClubDetailDto>> GetAllClubsAsync()
|
||||||
@@ -33,7 +35,15 @@ public class AdminClubService
|
|||||||
|
|
||||||
public async Task<ClubDetailDto> CreateClubAsync(CreateClubRequest request)
|
public async Task<ClubDetailDto> CreateClubAsync(CreateClubRequest request)
|
||||||
{
|
{
|
||||||
var tenantId = Guid.NewGuid().ToString();
|
var tenantId = "club-" + Guid.NewGuid().ToString().Substring(0, 8);
|
||||||
|
|
||||||
|
// Ensure interceptors can see the new tenantId
|
||||||
|
var httpContext = _httpContextAccessor.HttpContext;
|
||||||
|
if (httpContext != null)
|
||||||
|
{
|
||||||
|
httpContext.Items["TenantId"] = tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
var club = new Club
|
var club = new Club
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
|
|||||||
@@ -83,12 +83,13 @@ public class ShiftService
|
|||||||
where ss.ShiftId == id
|
where ss.ShiftId == id
|
||||||
join m in _context.Members on ss.MemberId equals m.Id
|
join m in _context.Members on ss.MemberId equals m.Id
|
||||||
orderby ss.SignedUpAt
|
orderby ss.SignedUpAt
|
||||||
select new { ss.Id, ss.MemberId, m.ExternalUserId, ss.SignedUpAt })
|
select new { ss.Id, ss.MemberId, m.DisplayName, m.ExternalUserId, ss.SignedUpAt })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var signupDtos = signups.Select(ss => new ShiftSignupDto(
|
var signupDtos = signups.Select(ss => new ShiftSignupDto(
|
||||||
ss.Id,
|
ss.Id,
|
||||||
ss.MemberId,
|
ss.MemberId,
|
||||||
|
ss.DisplayName,
|
||||||
ss.ExternalUserId,
|
ss.ExternalUserId,
|
||||||
ss.SignedUpAt
|
ss.SignedUpAt
|
||||||
)).ToList();
|
)).ToList();
|
||||||
@@ -194,12 +195,13 @@ public class ShiftService
|
|||||||
where ss.ShiftId == id
|
where ss.ShiftId == id
|
||||||
join m in _context.Members on ss.MemberId equals m.Id
|
join m in _context.Members on ss.MemberId equals m.Id
|
||||||
orderby ss.SignedUpAt
|
orderby ss.SignedUpAt
|
||||||
select new { ss.Id, ss.MemberId, m.ExternalUserId, ss.SignedUpAt })
|
select new { ss.Id, ss.MemberId, m.DisplayName, m.ExternalUserId, ss.SignedUpAt })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var signupDtos = signups.Select(ss => new ShiftSignupDto(
|
var signupDtos = signups.Select(ss => new ShiftSignupDto(
|
||||||
ss.Id,
|
ss.Id,
|
||||||
ss.MemberId,
|
ss.MemberId,
|
||||||
|
ss.DisplayName,
|
||||||
ss.ExternalUserId,
|
ss.ExternalUserId,
|
||||||
ss.SignedUpAt
|
ss.SignedUpAt
|
||||||
)).ToList();
|
)).ToList();
|
||||||
|
|||||||
@@ -38,59 +38,81 @@ public class TaskService
|
|||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
Guid? memberId = null;
|
// Get current member ID for IsAssignedToMe check
|
||||||
|
Guid? currentMemberId = null;
|
||||||
if (currentExternalUserId != null)
|
if (currentExternalUserId != null)
|
||||||
{
|
{
|
||||||
var tenantId = _tenantProvider.GetTenantId();
|
var tenantId = _tenantProvider.GetTenantId();
|
||||||
memberId = await _context.Members
|
currentMemberId = await _context.Members
|
||||||
.Where(m => m.ExternalUserId == currentExternalUserId && m.TenantId == tenantId)
|
.Where(m => m.ExternalUserId == currentExternalUserId && m.TenantId == tenantId)
|
||||||
.Select(m => m.Id)
|
.Select(m => m.Id)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all assignee IDs to fetch their names in bulk
|
||||||
|
var assigneeIds = items.Where(w => w.AssigneeId.HasValue).Select(w => w.AssigneeId!.Value).Distinct().ToList();
|
||||||
|
var assigneeNames = await _context.Members
|
||||||
|
.Where(m => assigneeIds.Contains(m.Id))
|
||||||
|
.Select(m => new { m.Id, m.DisplayName })
|
||||||
|
.ToDictionaryAsync(m => m.Id, m => m.DisplayName);
|
||||||
|
|
||||||
var itemDtos = items.Select(w => new TaskListItemDto(
|
var itemDtos = items.Select(w => new TaskListItemDto(
|
||||||
w.Id,
|
w.Id,
|
||||||
w.Title,
|
w.Title,
|
||||||
w.Status.ToString(),
|
w.Status.ToString(),
|
||||||
w.AssigneeId,
|
w.AssigneeId,
|
||||||
|
w.AssigneeId.HasValue && assigneeNames.TryGetValue(w.AssigneeId.Value, out var name) ? name : null,
|
||||||
w.CreatedAt,
|
w.CreatedAt,
|
||||||
memberId != null && w.AssigneeId == memberId
|
currentMemberId != null && w.AssigneeId == currentMemberId
|
||||||
)).ToList();
|
)).ToList();
|
||||||
|
|
||||||
return new TaskListDto(itemDtos, total, page, pageSize);
|
return new TaskListDto(itemDtos, total, page, pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TaskDetailDto?> GetTaskByIdAsync(Guid id, string? currentExternalUserId = null)
|
public async Task<TaskDetailDto?> GetTaskByIdAsync(Guid id, string? currentExternalUserId = null)
|
||||||
{
|
{
|
||||||
var workItem = await _context.WorkItems.FindAsync(id);
|
var workItem = await _context.WorkItems.FindAsync(id);
|
||||||
|
|
||||||
if (workItem == null)
|
if (workItem == null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
Guid? memberId = null;
|
// Get current member ID for IsAssignedToMe check
|
||||||
|
Guid? currentMemberId = null;
|
||||||
if (currentExternalUserId != null)
|
if (currentExternalUserId != null)
|
||||||
{
|
{
|
||||||
var tenantId = _tenantProvider.GetTenantId();
|
var tenantId = _tenantProvider.GetTenantId();
|
||||||
memberId = await _context.Members
|
currentMemberId = await _context.Members
|
||||||
.Where(m => m.ExternalUserId == currentExternalUserId && m.TenantId == tenantId)
|
.Where(m => m.ExternalUserId == currentExternalUserId && m.TenantId == tenantId)
|
||||||
.Select(m => m.Id)
|
.Select(m => m.Id)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch assignee and creator names
|
||||||
|
var memberIds = new List<Guid>();
|
||||||
|
if (workItem.AssigneeId.HasValue) memberIds.Add(workItem.AssigneeId.Value);
|
||||||
|
memberIds.Add(workItem.CreatedById);
|
||||||
|
|
||||||
|
var memberNames = await _context.Members
|
||||||
|
.Where(m => memberIds.Contains(m.Id))
|
||||||
|
.Select(m => new { m.Id, m.DisplayName })
|
||||||
|
.ToDictionaryAsync(m => m.Id, m => m.DisplayName);
|
||||||
|
|
||||||
return new TaskDetailDto(
|
return new TaskDetailDto(
|
||||||
workItem.Id,
|
workItem.Id,
|
||||||
workItem.Title,
|
workItem.Title,
|
||||||
workItem.Description,
|
workItem.Description,
|
||||||
workItem.Status.ToString(),
|
workItem.Status.ToString(),
|
||||||
workItem.AssigneeId,
|
workItem.AssigneeId,
|
||||||
|
workItem.AssigneeId.HasValue && memberNames.TryGetValue(workItem.AssigneeId.Value, out var assigneeName) ? assigneeName : null,
|
||||||
workItem.CreatedById,
|
workItem.CreatedById,
|
||||||
|
memberNames.TryGetValue(workItem.CreatedById, out var createdByName) ? createdByName : null,
|
||||||
workItem.ClubId,
|
workItem.ClubId,
|
||||||
workItem.DueDate,
|
workItem.DueDate,
|
||||||
workItem.CreatedAt,
|
workItem.CreatedAt,
|
||||||
workItem.UpdatedAt,
|
workItem.UpdatedAt,
|
||||||
memberId != null && workItem.AssigneeId == memberId
|
currentMemberId != null && workItem.AssigneeId == currentMemberId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(TaskDetailDto? task, string? error)> CreateTaskAsync(CreateTaskRequest request, Guid createdById)
|
public async Task<(TaskDetailDto? task, string? error)> CreateTaskAsync(CreateTaskRequest request, Guid createdById)
|
||||||
{
|
{
|
||||||
@@ -114,13 +136,24 @@ public class TaskService
|
|||||||
_context.WorkItems.Add(workItem);
|
_context.WorkItems.Add(workItem);
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Fetch creator and assignee names
|
||||||
|
var memberIds = new List<Guid> { createdById };
|
||||||
|
if (workItem.AssigneeId.HasValue) memberIds.Add(workItem.AssigneeId.Value);
|
||||||
|
|
||||||
|
var memberNames = await _context.Members
|
||||||
|
.Where(m => memberIds.Contains(m.Id))
|
||||||
|
.Select(m => new { m.Id, m.DisplayName })
|
||||||
|
.ToDictionaryAsync(m => m.Id, m => m.DisplayName);
|
||||||
|
|
||||||
var dto = new TaskDetailDto(
|
var dto = new TaskDetailDto(
|
||||||
workItem.Id,
|
workItem.Id,
|
||||||
workItem.Title,
|
workItem.Title,
|
||||||
workItem.Description,
|
workItem.Description,
|
||||||
workItem.Status.ToString(),
|
workItem.Status.ToString(),
|
||||||
workItem.AssigneeId,
|
workItem.AssigneeId,
|
||||||
|
workItem.AssigneeId.HasValue && memberNames.TryGetValue(workItem.AssigneeId.Value, out var assigneeName) ? assigneeName : null,
|
||||||
workItem.CreatedById,
|
workItem.CreatedById,
|
||||||
|
memberNames.TryGetValue(workItem.CreatedById, out var createdByName) ? createdByName : null,
|
||||||
workItem.ClubId,
|
workItem.ClubId,
|
||||||
workItem.DueDate,
|
workItem.DueDate,
|
||||||
workItem.CreatedAt,
|
workItem.CreatedAt,
|
||||||
@@ -176,32 +209,45 @@ public class TaskService
|
|||||||
return (null, "Task was modified by another user. Please refresh and try again.", true);
|
return (null, "Task was modified by another user. Please refresh and try again.", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Guid? memberId = null;
|
// Get current member ID for IsAssignedToMe check
|
||||||
|
Guid? currentMemberId = null;
|
||||||
if (currentExternalUserId != null)
|
if (currentExternalUserId != null)
|
||||||
{
|
{
|
||||||
var tenantId = _tenantProvider.GetTenantId();
|
var tenantId = _tenantProvider.GetTenantId();
|
||||||
memberId = await _context.Members
|
currentMemberId = await _context.Members
|
||||||
.Where(m => m.ExternalUserId == currentExternalUserId && m.TenantId == tenantId)
|
.Where(m => m.ExternalUserId == currentExternalUserId && m.TenantId == tenantId)
|
||||||
.Select(m => m.Id)
|
.Select(m => m.Id)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch assignee and creator names
|
||||||
|
var memberIds = new List<Guid>();
|
||||||
|
if (workItem.AssigneeId.HasValue) memberIds.Add(workItem.AssigneeId.Value);
|
||||||
|
memberIds.Add(workItem.CreatedById);
|
||||||
|
|
||||||
|
var memberNames = await _context.Members
|
||||||
|
.Where(m => memberIds.Contains(m.Id))
|
||||||
|
.Select(m => new { m.Id, m.DisplayName })
|
||||||
|
.ToDictionaryAsync(m => m.Id, m => m.DisplayName);
|
||||||
|
|
||||||
var dto = new TaskDetailDto(
|
var dto = new TaskDetailDto(
|
||||||
workItem.Id,
|
workItem.Id,
|
||||||
workItem.Title,
|
workItem.Title,
|
||||||
workItem.Description,
|
workItem.Description,
|
||||||
workItem.Status.ToString(),
|
workItem.Status.ToString(),
|
||||||
workItem.AssigneeId,
|
workItem.AssigneeId,
|
||||||
|
workItem.AssigneeId.HasValue && memberNames.TryGetValue(workItem.AssigneeId.Value, out var assigneeName) ? assigneeName : null,
|
||||||
workItem.CreatedById,
|
workItem.CreatedById,
|
||||||
|
memberNames.TryGetValue(workItem.CreatedById, out var createdByName) ? createdByName : null,
|
||||||
workItem.ClubId,
|
workItem.ClubId,
|
||||||
workItem.DueDate,
|
workItem.DueDate,
|
||||||
workItem.CreatedAt,
|
workItem.CreatedAt,
|
||||||
workItem.UpdatedAt,
|
workItem.UpdatedAt,
|
||||||
memberId != null && workItem.AssigneeId == memberId
|
currentMemberId != null && workItem.AssigneeId == currentMemberId
|
||||||
);
|
);
|
||||||
|
|
||||||
return (dto, null, false);
|
return (dto, null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> DeleteTaskAsync(Guid id)
|
public async Task<bool> DeleteTaskAsync(Guid id)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ public record ShiftDetailDto(
|
|||||||
|
|
||||||
public record ShiftSignupDto(
|
public record ShiftSignupDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid MemberId, string? ExternalUserId,
|
Guid MemberId,
|
||||||
|
string? MemberName,
|
||||||
|
string? ExternalUserId,
|
||||||
DateTimeOffset SignedUpAt
|
DateTimeOffset SignedUpAt
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ public record TaskDetailDto(
|
|||||||
string? Description,
|
string? Description,
|
||||||
string Status,
|
string Status,
|
||||||
Guid? AssigneeId,
|
Guid? AssigneeId,
|
||||||
|
string? AssigneeName,
|
||||||
Guid CreatedById,
|
Guid CreatedById,
|
||||||
|
string? CreatedByName,
|
||||||
Guid ClubId,
|
Guid ClubId,
|
||||||
DateTimeOffset? DueDate,
|
DateTimeOffset? DueDate,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public record TaskListItemDto(
|
|||||||
string Title,
|
string Title,
|
||||||
string Status,
|
string Status,
|
||||||
Guid? AssigneeId,
|
Guid? AssigneeId,
|
||||||
|
string? AssigneeName,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
bool IsAssignedToMe
|
bool IsAssignedToMe
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -185,11 +185,6 @@ public class TenantDbTransactionInterceptor : DbCommandInterceptor, IDbTransacti
|
|||||||
{
|
{
|
||||||
var tenantId = _httpContextAccessor.HttpContext?.Items["TenantId"] as string;
|
var tenantId = _httpContextAccessor.HttpContext?.Items["TenantId"] as string;
|
||||||
if (string.IsNullOrWhiteSpace(tenantId)) return null;
|
if (string.IsNullOrWhiteSpace(tenantId)) return null;
|
||||||
if (!Guid.TryParse(tenantId, out _))
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Invalid tenant ID format: {TenantId}", tenantId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return tenantId;
|
return tenantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
using WorkClub.Domain.Enums;
|
||||||
|
using WorkClub.Application.Clubs.DTOs;
|
||||||
|
using WorkClub.Tests.Integration.Infrastructure;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace WorkClub.Tests.Integration.Clubs;
|
||||||
|
|
||||||
|
public class AdminClubEndpointsTests : IntegrationTestBase
|
||||||
|
{
|
||||||
|
public AdminClubEndpointsTests(CustomWebApplicationFactory<Program> factory) : base(factory)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateClub_WithAdminRole_ReturnsCreated()
|
||||||
|
{
|
||||||
|
AuthenticateAsAdmin();
|
||||||
|
|
||||||
|
var request = new CreateClubRequest("New Admin Club", SportType.Tennis, "Desc");
|
||||||
|
var response = await Client.PostAsJsonAsync("/api/admin/clubs", request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateClub_WithoutAdminRole_ReturnsForbidden()
|
||||||
|
{
|
||||||
|
AuthenticateAsNonAdmin();
|
||||||
|
|
||||||
|
var request = new CreateClubRequest("New Club", SportType.Tennis, "Desc");
|
||||||
|
var response = await Client.PostAsJsonAsync("/api/admin/clubs", request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AuthenticateAsAdmin()
|
||||||
|
{
|
||||||
|
Client.DefaultRequestHeaders.Remove("X-Test-Email");
|
||||||
|
Client.DefaultRequestHeaders.Add("X-Test-Email", "admin@workclub.com");
|
||||||
|
|
||||||
|
Client.DefaultRequestHeaders.Remove("X-Test-Realm-Access");
|
||||||
|
Client.DefaultRequestHeaders.Add("X-Test-Realm-Access", "{\"roles\":[\"admin\"]}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AuthenticateAsNonAdmin()
|
||||||
|
{
|
||||||
|
Client.DefaultRequestHeaders.Remove("X-Test-Email");
|
||||||
|
Client.DefaultRequestHeaders.Add("X-Test-Email", "user@workclub.com");
|
||||||
|
|
||||||
|
Client.DefaultRequestHeaders.Remove("X-Test-Realm-Access");
|
||||||
|
Client.DefaultRequestHeaders.Add("X-Test-Realm-Access", "{\"roles\":[\"user\"]}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -185,7 +185,7 @@ public class ClubEndpointsTests : IntegrationTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetClubsCurrent_NoTenantContext_ReturnsBadRequest()
|
public async Task GetClubsCurrent_NoTenantContext_ReturnsForbidden()
|
||||||
{
|
{
|
||||||
AuthenticateAs("admin@test.com", new Dictionary<string, string>
|
AuthenticateAs("admin@test.com", new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
@@ -194,7 +194,7 @@ 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);
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -62,12 +62,30 @@ public class CustomWebApplicationFactory<TProgram> : WebApplicationFactory<TProg
|
|||||||
using var cmd = conn.CreateCommand();
|
using var cmd = conn.CreateCommand();
|
||||||
cmd.CommandText = @"
|
cmd.CommandText = @"
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
|
-- Create test user for RLS
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'rls_test_user') THEN
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'rls_test_user') THEN
|
||||||
CREATE USER rls_test_user WITH PASSWORD 'rlspass';
|
CREATE USER rls_test_user WITH PASSWORD 'rlspass';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Grant basic permissions to test user
|
||||||
GRANT CONNECT ON DATABASE workclub_test TO rls_test_user;
|
GRANT CONNECT ON DATABASE workclub_test TO rls_test_user;
|
||||||
|
GRANT USAGE ON SCHEMA public TO rls_test_user;
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO rls_test_user;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO rls_test_user;
|
||||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO rls_test_user;
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO rls_test_user;
|
||||||
|
|
||||||
|
-- Create app_admin role for bypassing RLS
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_admin') THEN
|
||||||
|
CREATE ROLE app_admin WITH BYPASSRLS;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
|
-- Grant app_admin full access to tables
|
||||||
|
GRANT CONNECT ON DATABASE workclub_test TO app_admin;
|
||||||
|
GRANT USAGE ON SCHEMA public TO app_admin;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO app_admin;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO app_admin;
|
||||||
|
|
||||||
|
-- Allow rls_test_user to assume app_admin role
|
||||||
|
GRANT app_admin TO rls_test_user;
|
||||||
END $$;
|
END $$;
|
||||||
";
|
";
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions
|
|||||||
var emailClaim = Context.Request.Headers["X-Test-Email"].ToString();
|
var emailClaim = Context.Request.Headers["X-Test-Email"].ToString();
|
||||||
var userIdClaim = Context.Request.Headers["X-Test-UserId"].ToString();
|
var userIdClaim = Context.Request.Headers["X-Test-UserId"].ToString();
|
||||||
var clubRolesJson = Context.Request.Headers["X-Test-ClubRoles"].ToString();
|
var clubRolesJson = Context.Request.Headers["X-Test-ClubRoles"].ToString();
|
||||||
|
var realmAccessClaim = Context.Request.Headers["X-Test-Realm-Access"].ToString();
|
||||||
|
|
||||||
// If no test auth headers are present, return NoResult (unauthenticated)
|
// If no test auth headers are present, return NoResult (unauthenticated)
|
||||||
if (string.IsNullOrEmpty(emailClaim) && string.IsNullOrEmpty(userIdClaim) && string.IsNullOrEmpty(clubsClaim))
|
if (string.IsNullOrEmpty(emailClaim) && string.IsNullOrEmpty(userIdClaim) && string.IsNullOrEmpty(clubsClaim) && string.IsNullOrEmpty(realmAccessClaim))
|
||||||
{
|
{
|
||||||
return Task.FromResult(AuthenticateResult.NoResult());
|
return Task.FromResult(AuthenticateResult.NoResult());
|
||||||
}
|
}
|
||||||
@@ -47,6 +48,11 @@ public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions
|
|||||||
new Claim("preferred_username", resolvedEmail),
|
new Claim("preferred_username", resolvedEmail),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(realmAccessClaim))
|
||||||
|
{
|
||||||
|
claims.Add(new Claim("realm_access", realmAccessClaim, ClaimValueTypes.String));
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(clubsClaim))
|
if (!string.IsNullOrEmpty(clubsClaim))
|
||||||
{
|
{
|
||||||
claims.Add(new Claim("clubs", clubsClaim));
|
claims.Add(new Claim("clubs", clubsClaim));
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+17
-5
@@ -39,8 +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_STRICT: "false"
|
||||||
|
KC_PROXY: "edge"
|
||||||
|
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:8080"
|
- "8080:8081"
|
||||||
volumes:
|
volumes:
|
||||||
- ./infra/keycloak:/opt/keycloak/data/import
|
- ./infra/keycloak:/opt/keycloak/data/import
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -59,18 +66,22 @@ services:
|
|||||||
container_name: workclub_api
|
container_name: workclub_api
|
||||||
environment:
|
environment:
|
||||||
ASPNETCORE_ENVIRONMENT: Development
|
ASPNETCORE_ENVIRONMENT: Development
|
||||||
ASPNETCORE_URLS: "http://+:8080"
|
|
||||||
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://keycloak: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"
|
- "5001:8080"
|
||||||
|
extra_hosts:
|
||||||
|
- "localhost:172.18.0.1"
|
||||||
|
- "127.0.0.1:172.18.0.1"
|
||||||
|
working_dir: /app
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app:cached
|
- ./backend:/app:cached
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
command: watch run WorkClub.Api/WorkClub.Api.csproj
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
@@ -84,12 +95,13 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NEXT_PUBLIC_API_URL: "http://localhost:5001"
|
NEXT_PUBLIC_API_URL: "http://localhost:5001"
|
||||||
API_INTERNAL_URL: "http://dotnet-api:8080"
|
API_INTERNAL_URL: "http://dotnet-api:8080"
|
||||||
NEXTAUTH_URL: "http://localhost:3000"
|
|
||||||
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_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:8080/realms/workclub"
|
||||||
KEYCLOAK_ISSUER_INTERNAL: "http://keycloak:8080/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:8080/realms/workclub"
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ export default function ShiftDetailPage({ params }: { params: Promise<{ id: stri
|
|||||||
<p className="text-sm text-muted-foreground">No sign-ups yet</p>
|
<p className="text-sm text-muted-foreground">No sign-ups yet</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="list-disc list-inside text-sm">
|
<ul className="list-disc list-inside text-sm">
|
||||||
{shift.signups.map((signup) => (
|
{shift.signups.map((signup) => (
|
||||||
<li key={signup.id}>Member ID: {signup.memberId}</li>
|
<li key={signup.id}>{signup.memberName || signup.memberId}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -85,11 +85,11 @@ export default function TaskDetailPage({ params }: { params: Promise<{ id: strin
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">Assignee</p>
|
<p className="text-sm font-medium text-muted-foreground">Assignee</p>
|
||||||
<p className="mt-1">{task.assigneeId || 'Unassigned'}</p>
|
<p className="mt-1">{task.assigneeName || 'Unassigned'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">Created By</p>
|
<p className="text-sm font-medium text-muted-foreground">Created By</p>
|
||||||
<p className="mt-1">{task.createdById}</p>
|
<p className="mt-1">{task.createdByName || task.createdById}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">Created At</p>
|
<p className="text-sm font-medium text-muted-foreground">Created At</p>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default function TaskListPage() {
|
|||||||
{task.status}
|
{task.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{task.assigneeId || 'Unassigned'}</TableCell>
|
<TableCell>{task.assigneeName || 'Unassigned'}</TableCell>
|
||||||
<TableCell>{new Date(task.createdAt).toLocaleDateString()}</TableCell>
|
<TableCell>{new Date(task.createdAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
|||||||
@@ -27,13 +27,12 @@ declare module "next-auth" {
|
|||||||
const issuerPublic = process.env.KEYCLOAK_ISSUER!
|
const issuerPublic = process.env.KEYCLOAK_ISSUER!
|
||||||
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}/protocol/openid-connect`
|
const oidcInternal = `${issuerInternal.replace(':8080', ':8081')}/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!,
|
||||||
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
|
||||||
issuer: issuerPublic,
|
issuer: issuerPublic,
|
||||||
authorization: {
|
authorization: {
|
||||||
url: `${oidcPublic}/auth`,
|
url: `${oidcPublic}/auth`,
|
||||||
@@ -41,8 +40,31 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
},
|
},
|
||||||
token: `${oidcInternal}/token`,
|
token: `${oidcInternal}/token`,
|
||||||
userinfo: `${oidcInternal}/userinfo`,
|
userinfo: `${oidcInternal}/userinfo`,
|
||||||
|
jwks_endpoint: `${oidcInternal}/certs`,
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
|
trustHost: true,
|
||||||
|
cookies: {
|
||||||
|
pkceCodeVerifier: {
|
||||||
|
name: "authjs.pkce.code_verifier",
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
name: "authjs.state",
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
debug: true,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({ token, account }) {
|
async jwt({ token, account }) {
|
||||||
if (account && account.access_token) {
|
if (account && account.access_token) {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface ShiftDetailDto {
|
|||||||
export interface ShiftSignupDto {
|
export interface ShiftSignupDto {
|
||||||
id: string;
|
id: string;
|
||||||
memberId: string;
|
memberId: string;
|
||||||
|
memberName?: string;
|
||||||
externalUserId?: string;
|
externalUserId?: string;
|
||||||
signedUpAt: string;
|
signedUpAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface TaskListItemDto {
|
|||||||
title: string;
|
title: string;
|
||||||
status: string;
|
status: string;
|
||||||
assigneeId: string | null;
|
assigneeId: string | null;
|
||||||
|
assigneeName?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
isAssignedToMe: boolean;
|
isAssignedToMe: boolean;
|
||||||
}
|
}
|
||||||
@@ -24,7 +25,9 @@ export interface TaskDetailDto {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
assigneeId: string | null;
|
assigneeId: string | null;
|
||||||
|
assigneeName?: string;
|
||||||
createdById: string;
|
createdById: string;
|
||||||
|
createdByName?: string;
|
||||||
clubId: string;
|
clubId: string;
|
||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user