DeutschLernen/GermanApp/Presentation/Controllers/AdminController.cs
Lasse Rune Hansen f1ed8a1a7a
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix(backend/auth): Fix JWT claim mapping issue causing 401 on /me endpoint
PROBLEM:
- Login returns JWT token with 'sub' claim
- /me endpoint tries to read user ID from JWT
- Gets 401 Unauthorized because user ID claim cannot be found

ROOT CAUSE:
ASP.NET Core JWT middleware automatically maps JWT standard claims to .NET claim types:
- JwtRegisteredClaimNames.Sub ('sub') -> ClaimTypes.NameIdentifier ('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier')

Controllers were looking for 'sub' or 'nameid' but JWT middleware creates the claim with the full URI.

SOLUTION:
Updated all controllers to use ClaimTypes.NameIdentifier with fallback to 'sub':
- AuthController.GetCurrentUser()
- AdminController.DeleteUserAsync()
- StoryController.GetUserId()

This ensures the user ID can be found regardless of how the JWT middleware maps the claims.

CHANGES:
- AuthService: Generates JWT tokens with JwtRegisteredClaimNames.Sub (JWT standard)
- AuthController: Uses ClaimTypes.NameIdentifier ?? 'sub' fallback
- AdminController: Uses ClaimTypes.NameIdentifier ?? 'sub' fallback
- StoryController: Uses ClaimTypes.NameIdentifier ?? 'sub' fallback
- LessonsEndpoints.cs: Added .RequireAuthorization() to all GET endpoints
- docs/features/admin-module.md: Updated acceptance criteria and requirements
- Added unit tests in JwtTokenValidationTests.cs to verify the fix

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-14 18:25:49 +02:00

276 lines
11 KiB
C#

using GermanApp.Application.DTOs;
using GermanApp.Application.Interfaces;
using GermanApp.Application.DTOs.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
namespace GermanApp.Presentation.Controllers;
/// <summary>
/// API controller for admin operations.
/// All endpoints require Admin role.
/// This is part of the Presentation layer.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize(Roles = "Admin")]
public class AdminController : ControllerBase
{
private readonly IAdminService _adminService;
private readonly IUserReportService _reportService;
public AdminController(IAdminService adminService, IUserReportService reportService)
{
_adminService = adminService;
_reportService = reportService;
}
// ============================================
// USER MANAGEMENT ENDPOINTS
// ============================================
/// <summary>
/// Gets all users (Admin only).
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of all users</returns>
[HttpGet("users")]
[ProducesResponseType(typeof(IReadOnlyList<AdminUserListItemDto>), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
public async Task<IActionResult> GetAllUsersAsync(CancellationToken cancellationToken = default)
{
var users = await _adminService.GetAllUsersAsync(cancellationToken);
return Ok(users);
}
/// <summary>
/// Gets a specific user by ID (Admin only).
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The user DTO</returns>
[HttpGet("users/{userId}")]
[ProducesResponseType(typeof(AdminUserDto), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
public async Task<IActionResult> GetUserByIdAsync(int userId, CancellationToken cancellationToken = default)
{
var user = await _adminService.GetUserByIdAsync(userId, cancellationToken);
if (user == null)
return NotFound();
return Ok(user);
}
/// <summary>
/// Updates a user's role (Admin only).
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="dto">The role update data</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The updated user DTO</returns>
[HttpPut("users/{userId}/role")]
[ProducesResponseType(typeof(AdminUserDto), (int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
public async Task<IActionResult> UpdateUserRoleAsync(
int userId,
[FromBody] UpdateUserRoleDto dto,
CancellationToken cancellationToken = default)
{
try
{
var user = await _adminService.UpdateUserRoleAsync(userId, dto.Role, cancellationToken);
if (user == null)
return NotFound();
return Ok(user);
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
/// <summary>
/// Deletes a user (Admin only, cannot delete self).
/// </summary>
/// <param name="userId">The user ID to delete</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Success or error</returns>
[HttpDelete("users/{userId}")]
[ProducesResponseType((int)HttpStatusCode.NoContent)]
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
public async Task<IActionResult> DeleteUserAsync(int userId, CancellationToken cancellationToken = default)
{
try
{
// Get admin user ID from JWT claim
// Note: JWT "sub" claim is mapped by ASP.NET Core JWT middleware to ClaimTypes.NameIdentifier
var adminUserIdClaim = User.FindFirst(ClaimTypes.NameIdentifier) ?? User.FindFirst("sub");
if (adminUserIdClaim == null || !int.TryParse(adminUserIdClaim.Value, out var adminUserId) || adminUserId == 0)
return Unauthorized();
var result = await _adminService.DeleteUserAsync(userId, adminUserId, cancellationToken);
if (!result)
return NotFound();
return NoContent();
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
// ============================================
// DASHBOARD ENDPOINTS
// ============================================
/// <summary>
/// Gets admin dashboard statistics.
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Dashboard statistics</returns>
[HttpGet("dashboard/stats")]
[ProducesResponseType(typeof(AdminDashboardStatsDto), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
public async Task<IActionResult> GetDashboardStatsAsync(CancellationToken cancellationToken = default)
{
var stats = await _adminService.GetDashboardStatsAsync(cancellationToken);
return Ok(stats);
}
// ============================================
// REPORT ENDPOINTS
// ============================================
/// <summary>
/// Generates a progress report for a specific user.
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>User progress report</returns>
[HttpGet("reports/users/{userId}")]
[ProducesResponseType(typeof(UserProgressReportDto), (int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
public async Task<IActionResult> GetUserReportAsync(int userId, CancellationToken cancellationToken = default)
{
try
{
var report = await _reportService.GenerateUserReportAsync(userId, cancellationToken);
return Ok(report);
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
/// <summary>
/// Generates progress reports for all users.
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of all user progress reports</returns>
[HttpGet("reports/all-users")]
[ProducesResponseType(typeof(IReadOnlyList<UserProgressReportDto>), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
public async Task<IActionResult> GetAllUserReportsAsync(CancellationToken cancellationToken = default)
{
var reports = await _reportService.GenerateAllUserReportsAsync(cancellationToken);
return Ok(reports);
}
/// <summary>
/// Gets lesson completion data for a user.
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of lesson completion data</returns>
[HttpGet("reports/users/{userId}/lessons")]
[ProducesResponseType(typeof(IReadOnlyList<LessonCompletionDto>), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
public async Task<IActionResult> GetUserLessonProgressAsync(int userId, CancellationToken cancellationToken = default)
{
var progress = await _reportService.GetUserLessonProgressAsync(userId, cancellationToken);
return Ok(progress);
}
/// <summary>
/// Gets quiz results for a user.
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of quiz results</returns>
[HttpGet("reports/users/{userId}/quizzes")]
[ProducesResponseType(typeof(IReadOnlyList<UserQuizResultDto>), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
public async Task<IActionResult> GetUserQuizResultsAsync(int userId, CancellationToken cancellationToken = default)
{
var results = await _reportService.GetUserQuizResultsAsync(userId, cancellationToken);
return Ok(results);
}
/// <summary>
/// Exports all user reports as CSV.
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>CSV content</returns>
[HttpGet("reports/export/csv")]
[ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
public async Task<IActionResult> ExportReportsAsCsvAsync(CancellationToken cancellationToken = default)
{
var csv = await _reportService.ExportReportsAsCsvAsync(cancellationToken);
return Ok(new { CsvContent = csv });
}
/// <summary>
/// Gets a specific user's full report (combined data).
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Combined user report data</returns>
[HttpGet("reports/users/{userId}/full")]
[ProducesResponseType((int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
public async Task<IActionResult> GetUserFullReportAsync(int userId, CancellationToken cancellationToken = default)
{
try
{
var progressReportTask = _reportService.GenerateUserReportAsync(userId, cancellationToken);
var lessonProgressTask = _reportService.GetUserLessonProgressAsync(userId, cancellationToken);
var quizResultsTask = _reportService.GetUserQuizResultsAsync(userId, cancellationToken);
await Task.WhenAll(progressReportTask, lessonProgressTask, quizResultsTask);
return Ok(new
{
ProgressReport = await progressReportTask,
LessonProgress = await lessonProgressTask,
QuizResults = await quizResultsTask
});
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
}