From 74dfb95d99938f0c69b3b1c1d8f90234ecf45653 Mon Sep 17 00:00:00 2001 From: Marek Lesko Date: Fri, 7 Nov 2025 20:49:39 +0000 Subject: [PATCH] feat: Add UserController and user-related DTOs for user management functionality --- Api/Controllers/UserController.cs | 483 ++++++++++++++++++ Api/Models/DTOs/AuthenticationDtos.cs | 58 +++ Api/User.http | 70 +++ .../app/services/authentication.service.ts | 2 +- 4 files changed, 612 insertions(+), 1 deletion(-) create mode 100644 Api/Controllers/UserController.cs create mode 100644 Api/User.http diff --git a/Api/Controllers/UserController.cs b/Api/Controllers/UserController.cs new file mode 100644 index 0000000..52ce6b6 --- /dev/null +++ b/Api/Controllers/UserController.cs @@ -0,0 +1,483 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Api.Models; +using Api.Models.DTOs; +using Microsoft.AspNetCore.Authorization; +using System.Security.Claims; + +namespace Api.Controllers +{ + [ApiController] + [Authorize] + [Route("api/[controller]")] + public class UserController : ControllerBase + { + private readonly AppDbContext _context; + private readonly ILogger _logger; + + public UserController(AppDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + /// + /// Get all users with pagination support + /// + /// Page number (default: 1) + /// Page size (default: 10, max: 100) + /// Search term for email, first name, or last name + /// Filter by active status + /// Paginated list of users + [HttpGet] + public async Task> GetUsers( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 10, + [FromQuery] string? search = null, + [FromQuery] bool? isActive = null) + { + try + { + // Validate pagination parameters + page = Math.Max(1, page); + pageSize = Math.Min(100, Math.Max(1, pageSize)); + + var query = _context.Users.Include(u => u.OAuthProviders).AsQueryable(); + + // Apply filters + if (isActive.HasValue) + { + query = query.Where(u => u.IsActive == isActive.Value); + } + + if (!string.IsNullOrEmpty(search)) + { + var searchTerm = search.ToLower(); + query = query.Where(u => + u.Email.ToLower().Contains(searchTerm) || + (u.FirstName != null && u.FirstName.ToLower().Contains(searchTerm)) || + (u.LastName != null && u.LastName.ToLower().Contains(searchTerm))); + } + + var totalCount = await query.CountAsync(); + var users = await query + .OrderByDescending(u => u.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(u => new UserDto + { + Id = u.Id, + Email = u.Email, + FirstName = u.FirstName, + LastName = u.LastName, + ProfilePictureUrl = u.ProfilePictureUrl, + CreatedAt = u.CreatedAt, + LastLoginAt = u.LastLoginAt, + IsActive = u.IsActive, + OAuthProviders = u.OAuthProviders.Select(op => new UserOAuthProviderDto + { + Id = op.Id, + Provider = op.Provider, + ProviderId = op.ProviderId, + ProviderEmail = op.ProviderEmail, + ProviderName = op.ProviderName, + CreatedAt = op.CreatedAt, + LastUsedAt = op.LastUsedAt + }).ToList() + }) + .ToListAsync(); + + return Ok(new + { + users, + pagination = new + { + page, + pageSize, + totalCount, + totalPages = (int)Math.Ceiling(totalCount / (double)pageSize), + hasNext = page * pageSize < totalCount, + hasPrevious = page > 1 + } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving users"); + return StatusCode(500, "Internal server error while retrieving users"); + } + } + + /// + /// Get a specific user by ID + /// + /// User ID + /// User details + [HttpGet("{id}")] + public async Task> GetUser(int id) + { + try + { + var user = await _context.Users + .Include(u => u.OAuthProviders) + .FirstOrDefaultAsync(u => u.Id == id); + + if (user == null) + { + return NotFound($"User with ID {id} not found"); + } + + var userDto = new UserDto + { + Id = user.Id, + Email = user.Email, + FirstName = user.FirstName, + LastName = user.LastName, + ProfilePictureUrl = user.ProfilePictureUrl, + CreatedAt = user.CreatedAt, + LastLoginAt = user.LastLoginAt, + IsActive = user.IsActive, + OAuthProviders = user.OAuthProviders.Select(op => new UserOAuthProviderDto + { + Id = op.Id, + Provider = op.Provider, + ProviderId = op.ProviderId, + ProviderEmail = op.ProviderEmail, + ProviderName = op.ProviderName, + CreatedAt = op.CreatedAt, + LastUsedAt = op.LastUsedAt + }).ToList() + }; + + return Ok(userDto); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving user with ID {UserId}", id); + return StatusCode(500, "Internal server error while retrieving user"); + } + } + + /// + /// Get the current user's profile + /// + /// Current user's profile + [HttpGet("me")] + public async Task> GetCurrentUser() + { + try + { + var userIdClaim = User.FindFirst("user_id")?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return Unauthorized("Invalid user token"); + } + + return await GetUser(userId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving current user profile"); + return StatusCode(500, "Internal server error while retrieving current user"); + } + } + + /// + /// Create a new user + /// + /// User creation request + /// Created user + [HttpPost] + public async Task> CreateUser([FromBody] CreateUserRequest request) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + try + { + // Check if user with this email already exists + var existingUser = await _context.Users + .FirstOrDefaultAsync(u => u.Email == request.Email); + + if (existingUser != null) + { + return Conflict($"User with email {request.Email} already exists"); + } + + var user = new User + { + Email = request.Email, + FirstName = request.FirstName, + LastName = request.LastName, + ProfilePictureUrl = request.ProfilePictureUrl, + IsActive = request.IsActive, + CreatedAt = DateTime.UtcNow + }; + + _context.Users.Add(user); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Created new user with ID {UserId} and email {Email}", user.Id, user.Email); + + var userDto = new UserDto + { + Id = user.Id, + Email = user.Email, + FirstName = user.FirstName, + LastName = user.LastName, + ProfilePictureUrl = user.ProfilePictureUrl, + CreatedAt = user.CreatedAt, + LastLoginAt = user.LastLoginAt, + IsActive = user.IsActive, + OAuthProviders = new List() + }; + + return CreatedAtAction(nameof(GetUser), new { id = user.Id }, userDto); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating user with email {Email}", request.Email); + return StatusCode(500, "Internal server error while creating user"); + } + } + + /// + /// Update a user + /// + /// User ID + /// User update request + /// No content on success + [HttpPut("{id}")] + public async Task UpdateUser(int id, [FromBody] UpdateUserRequest request) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + try + { + var user = await _context.Users.FindAsync(id); + if (user == null) + { + return NotFound($"User with ID {id} not found"); + } + + // Update only provided fields + if (request.FirstName != null) + { + user.FirstName = request.FirstName; + } + + if (request.LastName != null) + { + user.LastName = request.LastName; + } + + if (request.ProfilePictureUrl != null) + { + user.ProfilePictureUrl = request.ProfilePictureUrl; + } + + if (request.IsActive.HasValue) + { + user.IsActive = request.IsActive.Value; + } + + _context.Entry(user).State = EntityState.Modified; + await _context.SaveChangesAsync(); + + _logger.LogInformation("Updated user with ID {UserId}", id); + + return NoContent(); + } + catch (DbUpdateConcurrencyException) + { + if (!await _context.Users.AnyAsync(e => e.Id == id)) + { + return NotFound($"User with ID {id} not found"); + } + else + { + throw; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating user with ID {UserId}", id); + return StatusCode(500, "Internal server error while updating user"); + } + } + + /// + /// Update the current user's profile + /// + /// User update request + /// No content on success + [HttpPut("me")] + public async Task UpdateCurrentUser([FromBody] UpdateUserRequest request) + { + try + { + var userIdClaim = User.FindFirst("user_id")?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return Unauthorized("Invalid user token"); + } + + return await UpdateUser(userId, request); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating current user profile"); + return StatusCode(500, "Internal server error while updating current user"); + } + } + + /// + /// Delete a user (soft delete by setting IsActive to false) + /// + /// User ID + /// No content on success + [HttpDelete("{id}")] + public async Task DeleteUser(int id) + { + try + { + var user = await _context.Users.FindAsync(id); + if (user == null) + { + return NotFound($"User with ID {id} not found"); + } + + // Soft delete by setting IsActive to false + user.IsActive = false; + _context.Entry(user).State = EntityState.Modified; + await _context.SaveChangesAsync(); + + _logger.LogInformation("Soft deleted user with ID {UserId}", id); + + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting user with ID {UserId}", id); + return StatusCode(500, "Internal server error while deleting user"); + } + } + + /// + /// Permanently delete a user and all associated OAuth providers + /// + /// User ID + /// No content on success + [HttpDelete("{id}/permanent")] + public async Task PermanentlyDeleteUser(int id) + { + try + { + var user = await _context.Users + .Include(u => u.OAuthProviders) + .FirstOrDefaultAsync(u => u.Id == id); + + if (user == null) + { + return NotFound($"User with ID {id} not found"); + } + + // Remove all OAuth providers first due to foreign key constraints + _context.UserOAuthProviders.RemoveRange(user.OAuthProviders); + + // Remove the user + _context.Users.Remove(user); + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Permanently deleted user with ID {UserId} and {ProviderCount} OAuth providers", + id, user.OAuthProviders.Count); + + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error permanently deleting user with ID {UserId}", id); + return StatusCode(500, "Internal server error while permanently deleting user"); + } + } + + /// + /// Reactivate a soft-deleted user + /// + /// User ID + /// No content on success + [HttpPost("{id}/reactivate")] + public async Task ReactivateUser(int id) + { + try + { + var user = await _context.Users.FindAsync(id); + if (user == null) + { + return NotFound($"User with ID {id} not found"); + } + + user.IsActive = true; + _context.Entry(user).State = EntityState.Modified; + await _context.SaveChangesAsync(); + + _logger.LogInformation("Reactivated user with ID {UserId}", id); + + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reactivating user with ID {UserId}", id); + return StatusCode(500, "Internal server error while reactivating user"); + } + } + + /// + /// Get user statistics + /// + /// User statistics + [HttpGet("statistics")] + public async Task> GetUserStatistics() + { + try + { + var totalUsers = await _context.Users.CountAsync(); + var activeUsers = await _context.Users.CountAsync(u => u.IsActive); + var inactiveUsers = totalUsers - activeUsers; + var usersWithLogin = await _context.Users.CountAsync(u => u.LastLoginAt != null); + var recentUsers = await _context.Users.CountAsync(u => u.CreatedAt >= DateTime.UtcNow.AddDays(-30)); + + var providerStats = await _context.UserOAuthProviders + .GroupBy(op => op.Provider) + .Select(g => new + { + Provider = g.Key.ToString(), + Count = g.Count(), + UniqueUsers = g.Select(op => op.UserId).Distinct().Count() + }) + .ToListAsync(); + + return Ok(new + { + totalUsers, + activeUsers, + inactiveUsers, + usersWithLogin, + recentUsers, + providerStatistics = providerStats + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving user statistics"); + return StatusCode(500, "Internal server error while retrieving statistics"); + } + } + } +} \ No newline at end of file diff --git a/Api/Models/DTOs/AuthenticationDtos.cs b/Api/Models/DTOs/AuthenticationDtos.cs index be78ad1..9495c0f 100644 --- a/Api/Models/DTOs/AuthenticationDtos.cs +++ b/Api/Models/DTOs/AuthenticationDtos.cs @@ -36,6 +36,64 @@ namespace Api.Models.DTOs public List Providers { get; set; } = new List(); } + // User CRUD DTOs + public class CreateUserRequest + { + [Required] + [EmailAddress] + [StringLength(255)] + public string Email { get; set; } = string.Empty; + + [StringLength(255)] + public string? FirstName { get; set; } + + [StringLength(255)] + public string? LastName { get; set; } + + [StringLength(500)] + public string? ProfilePictureUrl { get; set; } + + public bool IsActive { get; set; } = true; + } + + public class UpdateUserRequest + { + [StringLength(255)] + public string? FirstName { get; set; } + + [StringLength(255)] + public string? LastName { get; set; } + + [StringLength(500)] + public string? ProfilePictureUrl { get; set; } + + public bool? IsActive { get; set; } + } + + public class UserDto + { + public int Id { get; set; } + public string Email { get; set; } = string.Empty; + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? ProfilePictureUrl { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? LastLoginAt { get; set; } + public bool IsActive { get; set; } + public List OAuthProviders { get; set; } = new List(); + } + + public class UserOAuthProviderDto + { + public int Id { get; set; } + public OAuthProvider Provider { get; set; } + public string ProviderId { get; set; } = string.Empty; + public string? ProviderEmail { get; set; } + public string? ProviderName { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? LastUsedAt { get; set; } + } + public class JwtSettings { public string SecretKey { get; set; } = string.Empty; diff --git a/Api/User.http b/Api/User.http new file mode 100644 index 0000000..7a3b0b1 --- /dev/null +++ b/Api/User.http @@ -0,0 +1,70 @@ +### User Controller API Tests + +@baseUrl = http://localhost:5000 +@authToken = YOUR_JWT_TOKEN_HERE + +### Get all users with pagination +GET {{baseUrl}}/api/user?page=1&pageSize=10 +Authorization: Bearer {{authToken}} + +### Get all users with search +GET {{baseUrl}}/api/user?search=john&isActive=true +Authorization: Bearer {{authToken}} + +### Get specific user by ID +GET {{baseUrl}}/api/user/1 +Authorization: Bearer {{authToken}} + +### Get current user profile +GET {{baseUrl}}/api/user/me +Authorization: Bearer {{authToken}} + +### Create a new user +POST {{baseUrl}}/api/user +Authorization: Bearer {{authToken}} +Content-Type: application/json + +{ + "email": "newuser@example.com", + "firstName": "John", + "lastName": "Doe", + "profilePictureUrl": "https://example.com/profile.jpg", + "isActive": true +} + +### Update a user +PUT {{baseUrl}}/api/user/1 +Authorization: Bearer {{authToken}} +Content-Type: application/json + +{ + "firstName": "Updated John", + "lastName": "Updated Doe", + "isActive": true +} + +### Update current user profile +PUT {{baseUrl}}/api/user/me +Authorization: Bearer {{authToken}} +Content-Type: application/json + +{ + "firstName": "My Updated Name", + "profilePictureUrl": "https://example.com/new-profile.jpg" +} + +### Soft delete user (deactivate) +DELETE {{baseUrl}}/api/user/1 +Authorization: Bearer {{authToken}} + +### Reactivate a soft-deleted user +POST {{baseUrl}}/api/user/1/reactivate +Authorization: Bearer {{authToken}} + +### Permanently delete user +DELETE {{baseUrl}}/api/user/1/permanent +Authorization: Bearer {{authToken}} + +### Get user statistics +GET {{baseUrl}}/api/user/statistics +Authorization: Bearer {{authToken}} \ No newline at end of file diff --git a/Web/src/app/services/authentication.service.ts b/Web/src/app/services/authentication.service.ts index f100b24..53d2191 100644 --- a/Web/src/app/services/authentication.service.ts +++ b/Web/src/app/services/authentication.service.ts @@ -305,7 +305,7 @@ export class AuthenticationService { redirectUri: `${window.location.origin}/authentication/callback` }; - console.log('Google OAuth Config:', googleConfig); + console.log('Google OAuth Config:', JSON.stringify(googleConfig)); return this.startLogin(googleConfig); }